feat: add Responses API compatibility

This commit is contained in:
wangbo 2026-07-10 23:33:15 +08:00
parent b7351f3b9b
commit 8847d973a8
24 changed files with 5922 additions and 169 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -20,20 +20,43 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
if apiKey == "" {
return Response{}, &ClientError{Code: "missing_credentials", Message: "openai api key is required", Retryable: false}
}
endpoint := openAIEndpoint(request.Kind)
protocol := request.UpstreamProtocol
if protocol == "" && request.Kind == "responses" {
protocol = ProtocolOpenAIResponses
}
endpointKind := request.Kind
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions {
endpointKind = "chat.completions"
}
endpoint := openAIEndpoint(endpointKind)
if endpoint == "" {
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported openai request kind", Retryable: false}
}
body := cloneBody(request.Body)
if request.Kind == "chat.completions" {
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions {
var convertErr error
body, convertErr = ResponsesRequestToChat(request.Body, request.PreviousResponseTurns)
if convertErr != nil {
return Response{}, convertErr
}
}
if endpointKind == "chat.completions" {
body = NormalizeChatCompletionRequestBody(body)
applyOpenAIChatReasoningParams(body, request.Candidate)
} else if request.Kind == "responses" {
delete(body, "messages")
if request.UpstreamPreviousResponseID != "" {
body["previous_response_id"] = request.UpstreamPreviousResponseID
} else {
delete(body, "previous_response_id")
}
}
body["model"] = upstreamModelName(request.Candidate)
stream := openAIEndpointSupportsStream(request.Kind) && (request.Stream || boolValue(body, "stream"))
ensureOpenAIStreamUsage(body, request.Kind, stream)
stream := openAIEndpointSupportsStream(endpointKind) && (request.Stream || boolValue(body, "stream"))
ensureOpenAIStreamUsage(body, endpointKind, stream)
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(openAIBaseURL(request.Kind, request.Candidate), endpoint), bytes.NewReader(raw))
upstreamEndpoint := joinURL(openAIBaseURL(endpointKind, request.Candidate), endpoint)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamEndpoint, bytes.NewReader(raw))
if err != nil {
return Response{}, err
}
@ -45,7 +68,38 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeOpenAIResponse(resp, stream, request.StreamDelta)
var result map[string]any
upstreamResponseID := ""
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses && stream {
result, upstreamResponseID, err = decodeNativeResponsesStream(resp, request.StreamDelta)
} else {
var streamDelta StreamDelta = request.StreamDelta
var adapter *chatResponsesStreamAdapter
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions && stream {
adapter = newChatResponsesStreamAdapter(request.PublicResponseID, request.Model)
streamDelta = func(event StreamDeltaEvent) error { return adapter.delta(event, request.StreamDelta) }
}
result, err = decodeOpenAIResponse(resp, stream, streamDelta)
if err == nil && endpointKind == "chat.completions" {
result = NormalizeChatCompletionResult(result)
}
if err == nil && request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions {
chatResult := result
upstreamResponseID = requestIDFromResult(chatResult)
result = ChatResultToResponse(chatResult, request.PublicResponseID, request.Model, request.Body)
if adapter != nil {
err = adapter.done(result, request.StreamDelta)
}
if err == nil {
return Response{
Result: result, InternalResult: chatResult, RequestID: firstNonEmptyString(requestID, upstreamResponseID), Usage: usageFromOpenAI(chatResult),
Progress: providerProgress(request), ResponseStartedAt: responseStartedAt, ResponseFinishedAt: time.Now(),
UpstreamProtocol: protocol, UpstreamEndpoint: endpoint, UpstreamResponseID: upstreamResponseID,
PublicResponseID: request.PublicResponseID, ResponseConverted: true,
}, nil
}
}
}
if err == nil && request.Kind == "chat.completions" {
result = NormalizeChatCompletionResult(result)
}
@ -56,6 +110,15 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
if requestID == "" {
requestID = requestIDFromResult(result)
}
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses {
if upstreamResponseID == "" {
upstreamResponseID = requestIDFromResult(result)
}
}
publicResponseID := request.PublicResponseID
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses {
publicResponseID = upstreamResponseID
}
return Response{
Result: result,
RequestID: requestID,
@ -64,6 +127,10 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
ResponseStartedAt: responseStartedAt,
ResponseFinishedAt: responseFinishedAt,
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
UpstreamProtocol: protocol,
UpstreamEndpoint: endpoint,
UpstreamResponseID: upstreamResponseID,
PublicResponseID: publicResponseID,
}, nil
}

View File

@ -0,0 +1,720 @@
package clients
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
const (
ProtocolOpenAIChatCompletions = "openai_chat_completions"
ProtocolOpenAIResponses = "openai_responses"
ProtocolAnthropicMessages = "anthropic_messages"
)
var supportedResponseFallbackParameters = map[string]struct{}{
"model": {}, "input": {}, "messages": {}, "instructions": {}, "tools": {}, "tool_choice": {},
"parallel_tool_calls": {}, "max_output_tokens": {}, "temperature": {}, "top_p": {},
"presence_penalty": {}, "frequency_penalty": {}, "reasoning": {}, "text": {},
"stream": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
}
func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[string]any, error) {
for key := range body {
if _, ok := supportedResponseFallbackParameters[key]; !ok {
return nil, unsupportedResponseParameter(key)
}
}
messages := make([]any, 0)
for _, turn := range history {
priorInput := turn.Request["input"]
if priorInput == nil {
priorInput = turn.Request["messages"]
}
priorMessages, err := responseInputMessages(priorInput)
if err != nil {
return nil, err
}
messages = append(messages, priorMessages...)
if assistant := chatAssistantMessage(turn.Internal, turn.Response); assistant != nil {
messages = append(messages, assistant)
}
}
if instructions := strings.TrimSpace(stringFromAny(body["instructions"])); instructions != "" {
messages = append(messages, map[string]any{"role": "system", "content": instructions})
}
if rawMessages, ok := body["messages"].([]any); ok {
messages = append(messages, rawMessages...)
} else {
currentMessages, err := responseInputMessages(body["input"])
if err != nil {
return nil, err
}
messages = append(messages, currentMessages...)
}
if len(messages) == 0 {
return nil, &ClientError{Code: "invalid_parameter", Message: "input is required", StatusCode: http.StatusBadRequest}
}
out := map[string]any{"messages": messages}
for _, key := range []string{"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls", "stream", "user"} {
if value, ok := body[key]; ok {
out[key] = value
}
}
if value, ok := body["max_output_tokens"]; ok {
out["max_tokens"] = value
}
if rawReasoning, ok := body["reasoning"]; ok {
reasoning, ok := rawReasoning.(map[string]any)
if !ok {
return nil, unsupportedResponseParameter("reasoning")
}
for key := range reasoning {
if key != "effort" {
return nil, unsupportedResponseParameter("reasoning." + key)
}
}
if effort, ok := reasoning["effort"]; ok {
out["reasoning_effort"] = effort
}
}
if rawText, ok := body["text"]; ok {
responseFormat, err := responseTextFormat(rawText)
if err != nil {
return nil, err
}
if responseFormat != nil {
out["response_format"] = responseFormat
}
}
if rawTools, ok := body["tools"]; ok {
tools, err := responseToolsToChat(rawTools)
if err != nil {
return nil, err
}
out["tools"] = tools
}
if rawChoice, ok := body["tool_choice"]; ok {
choice, err := responseToolChoiceToChat(rawChoice)
if err != nil {
return nil, err
}
out["tool_choice"] = choice
}
return out, nil
}
func responseInputMessages(value any) ([]any, error) {
switch typed := value.(type) {
case nil:
return nil, nil
case string:
return []any{map[string]any{"role": "user", "content": typed}}, nil
case []any:
out := make([]any, 0, len(typed))
for _, raw := range typed {
switch item := raw.(type) {
case string:
out = append(out, map[string]any{"role": "user", "content": item})
case map[string]any:
switch stringFromAny(item["type"]) {
case "function_call_output":
callID := firstNonEmptyString(item["call_id"], item["id"])
if callID == "" {
return nil, &ClientError{Code: "invalid_parameter", Message: "function_call_output.call_id is required", StatusCode: http.StatusBadRequest}
}
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": toolResultContent(item["output"])})
case "message", "":
role := firstNonEmptyString(item["role"], "user")
out = append(out, map[string]any{"role": role, "content": responseContentToChat(item["content"])})
default:
return nil, unsupportedResponseParameter("input.type=" + stringFromAny(item["type"]))
}
default:
return nil, unsupportedResponseParameter("input")
}
}
return out, nil
case map[string]any:
return responseInputMessages([]any{typed})
default:
return nil, unsupportedResponseParameter("input")
}
}
func responseContentToChat(value any) any {
items, ok := value.([]any)
if !ok {
return value
}
out := make([]any, 0, len(items))
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
out = append(out, raw)
continue
}
switch stringFromAny(item["type"]) {
case "input_text", "output_text", "text":
out = append(out, map[string]any{"type": "text", "text": stringFromAny(item["text"])})
case "input_image":
url := firstNonEmptyString(item["image_url"], item["url"])
out = append(out, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
default:
out = append(out, item)
}
}
return out
}
func responseToolsToChat(value any) ([]any, error) {
items, ok := value.([]any)
if !ok {
return nil, unsupportedResponseParameter("tools")
}
out := make([]any, 0, len(items))
for _, raw := range items {
tool, ok := raw.(map[string]any)
if !ok || stringFromAny(tool["type"]) != "function" {
return nil, &ClientError{Code: "unsupported_response_tool", Message: "Chat fallback only supports custom function tools", StatusCode: http.StatusBadRequest}
}
function := map[string]any{
"name": tool["name"],
"description": tool["description"],
"parameters": tool["parameters"],
}
if strict, ok := tool["strict"]; ok {
function["strict"] = strict
}
out = append(out, map[string]any{"type": "function", "function": function})
}
return out, nil
}
func responseToolChoiceToChat(value any) (any, error) {
if text, ok := value.(string); ok {
switch text {
case "auto", "none", "required":
return text, nil
default:
return nil, unsupportedResponseParameter("tool_choice")
}
}
choice, ok := value.(map[string]any)
if !ok || stringFromAny(choice["type"]) != "function" || stringFromAny(choice["name"]) == "" {
return nil, unsupportedResponseParameter("tool_choice")
}
return map[string]any{"type": "function", "function": map[string]any{"name": choice["name"]}}, nil
}
func responseTextFormat(value any) (map[string]any, error) {
text, ok := value.(map[string]any)
if !ok {
return nil, unsupportedResponseParameter("text")
}
for key := range text {
if key != "format" {
return nil, unsupportedResponseParameter("text." + key)
}
}
format, ok := text["format"].(map[string]any)
if !ok || len(format) == 0 {
return nil, nil
}
switch stringFromAny(format["type"]) {
case "text":
return map[string]any{"type": "text"}, nil
case "json_object":
return map[string]any{"type": "json_object"}, nil
case "json_schema":
return map[string]any{"type": "json_schema", "json_schema": map[string]any{
"name": format["name"], "schema": format["schema"], "strict": format["strict"],
}}, nil
default:
return nil, unsupportedResponseParameter("text.format.type")
}
}
func chatAssistantMessage(internal map[string]any, visible map[string]any) map[string]any {
if choices, ok := internal["choices"].([]any); ok && len(choices) > 0 {
choice, _ := choices[0].(map[string]any)
if message, ok := choice["message"].(map[string]any); ok {
return cloneMapAny(message)
}
}
output, _ := visible["output"].([]any)
message := map[string]any{"role": "assistant"}
textParts := make([]string, 0)
toolCalls := make([]any, 0)
for _, raw := range output {
item, _ := raw.(map[string]any)
switch stringFromAny(item["type"]) {
case "message":
content, _ := item["content"].([]any)
for _, rawContent := range content {
part, _ := rawContent.(map[string]any)
if stringFromAny(part["type"]) == "output_text" {
textParts = append(textParts, stringFromAny(part["text"]))
}
}
case "function_call":
toolCalls = append(toolCalls, map[string]any{
"id": item["call_id"], "type": "function",
"function": map[string]any{"name": item["name"], "arguments": item["arguments"]},
})
}
}
message["content"] = strings.Join(textParts, "")
if len(toolCalls) > 0 {
message["tool_calls"] = toolCalls
}
if len(textParts) == 0 && len(toolCalls) == 0 {
return nil
}
return message
}
func ChatResultToResponse(chat map[string]any, publicID string, model string, requestBody map[string]any) map[string]any {
createdAt := time.Now().Unix()
if value := intFromAny(chat["created"]); value > 0 {
createdAt = int64(value)
}
output := make([]any, 0)
outputText := ""
choices, _ := chat["choices"].([]any)
if len(choices) > 0 {
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if content := visibleChatContent(message["content"]); content != "" {
outputText = content
output = append(output, map[string]any{
"id": "msg_" + responseIDSuffix(publicID), "type": "message", "status": "completed", "role": "assistant",
"content": []any{map[string]any{"type": "output_text", "text": content, "annotations": []any{}, "logprobs": []any{}}},
})
}
toolCalls, _ := message["tool_calls"].([]any)
for index, rawToolCall := range toolCalls {
toolCall, _ := rawToolCall.(map[string]any)
function, _ := toolCall["function"].(map[string]any)
callID := firstNonEmptyString(toolCall["id"], fmt.Sprintf("call_%d", index))
output = append(output, map[string]any{
"id": "fc_" + responseIDSuffix(publicID) + fmt.Sprintf("_%d", index),
"type": "function_call", "status": "completed", "call_id": callID,
"name": stringFromAny(function["name"]), "arguments": stringFromAny(function["arguments"]),
})
}
}
usage := responseUsageFromChat(chat["usage"])
out := map[string]any{
"id": publicID, "object": "response", "created_at": createdAt, "status": "completed",
"model": model, "output": output, "output_text": outputText, "error": nil, "incomplete_details": nil,
"usage": usage,
}
if previousResponseID := strings.TrimSpace(stringFromAny(requestBody["previous_response_id"])); previousResponseID != "" {
out["previous_response_id"] = previousResponseID
} else {
out["previous_response_id"] = nil
}
applyResponseFinishReason(out, choices)
for _, key := range []string{"instructions", "parallel_tool_calls", "temperature", "top_p", "tools", "tool_choice", "metadata"} {
if value, ok := requestBody[key]; ok {
out[key] = value
}
}
return out
}
func applyResponseFinishReason(response map[string]any, choices []any) {
if len(choices) == 0 {
return
}
choice, _ := choices[0].(map[string]any)
switch strings.TrimSpace(stringFromAny(choice["finish_reason"])) {
case "length":
response["status"] = "incomplete"
response["incomplete_details"] = map[string]any{"reason": "max_output_tokens"}
case "content_filter":
response["status"] = "incomplete"
response["incomplete_details"] = map[string]any{"reason": "content_filter"}
}
}
func responseUsageFromChat(value any) map[string]any {
usage, _ := value.(map[string]any)
normalized := usageFromOpenAIUsage(usage)
return map[string]any{
"input_tokens": normalized.InputTokens,
"input_tokens_details": map[string]any{"cached_tokens": normalized.CachedInputTokens},
"output_tokens": normalized.OutputTokens,
"output_tokens_details": map[string]any{"reasoning_tokens": reasoningTokensFromUsage(usage)},
"total_tokens": normalized.TotalTokens,
}
}
func reasoningTokensFromUsage(usage map[string]any) int {
for _, key := range []string{"completion_tokens_details", "output_tokens_details"} {
if details, ok := usage[key].(map[string]any); ok {
if value := intFromAny(details["reasoning_tokens"]); value > 0 {
return value
}
}
}
return 0
}
func visibleChatContent(value any) string {
if text, ok := value.(string); ok {
return text
}
items, _ := value.([]any)
parts := make([]string, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]any)
if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" && !isReasoningContentBlock(item) {
parts = append(parts, text)
}
}
return strings.Join(parts, "")
}
func decodeNativeResponsesStream(resp *http.Response, onDelta StreamDelta) (map[string]any, string, error) {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
return nil, "", &ClientError{Code: statusCodeName(resp.StatusCode), Message: errorMessage(raw, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode)}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
var completed map[string]any
upstreamID := ""
rawLines := make([]string, 0)
eventName := ""
dataLines := make([]string, 0)
processFrame := func() error {
payload := strings.TrimSpace(strings.Join(dataLines, "\n"))
frameEventName := eventName
eventName = ""
dataLines = dataLines[:0]
if payload == "" || payload == "[DONE]" {
return nil
}
var event map[string]any
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return nil
}
if stringFromAny(event["type"]) == "" && frameEventName != "" {
event["type"] = frameEventName
}
if failure := responseStreamError(event); failure != nil {
return failure
}
if response, ok := event["response"].(map[string]any); ok && upstreamID == "" {
upstreamID = stringFromAny(response["id"])
}
if stringFromAny(event["type"]) == "response.completed" {
completed, _ = event["response"].(map[string]any)
if onDelta != nil {
return onDelta(StreamDeltaEvent{Event: event})
}
return nil
}
if stringFromAny(event["type"]) == "response.failed" {
if onDelta != nil {
if err := onDelta(StreamDeltaEvent{Event: event}); err != nil {
return err
}
}
failed, _ := event["response"].(map[string]any)
failure, _ := failed["error"].(map[string]any)
code := firstNonEmptyString(failure["code"], "response_failed")
message := firstNonEmptyString(failure["message"], "upstream Responses request failed")
return &ClientError{Code: code, Message: message, StatusCode: http.StatusBadRequest, Retryable: false}
}
if onDelta != nil {
return onDelta(StreamDeltaEvent{Text: nativeResponseDeltaText(event), Event: event})
}
return nil
}
for scanner.Scan() {
rawLine := scanner.Text()
rawLines = append(rawLines, rawLine)
line := strings.TrimSpace(rawLine)
if line == "" {
if err := processFrame(); err != nil {
return nil, upstreamID, err
}
continue
}
if strings.HasPrefix(line, "event:") {
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err := processFrame(); err != nil {
return nil, upstreamID, err
}
if err := scanner.Err(); err != nil {
return nil, upstreamID, &ClientError{Code: "stream_read_error", Message: err.Error(), Retryable: true}
}
if completed == nil {
raw := strings.TrimSpace(strings.Join(rawLines, "\n"))
var response map[string]any
if raw != "" && json.Unmarshal([]byte(raw), &response) == nil {
if failure := responseStreamError(response); failure != nil {
return nil, upstreamID, failure
}
if stringFromAny(response["object"]) == "response" {
upstreamID = stringFromAny(response["id"])
return response, upstreamID, nil
}
}
return nil, upstreamID, &ClientError{Code: "invalid_response", Message: "Responses stream ended without response.completed", Retryable: false}
}
return completed, upstreamID, nil
}
func responseStreamError(event map[string]any) error {
if stringFromAny(event["type"]) != "error" && event["error"] == nil {
return nil
}
failure, _ := event["error"].(map[string]any)
if failure == nil {
failure = event
}
code := firstNonEmptyString(failure["code"], "response_failed")
message := firstNonEmptyString(failure["message"], "upstream Responses stream failed")
return &ClientError{Code: code, Message: message, StatusCode: http.StatusBadRequest, Retryable: false}
}
func nativeResponseDeltaText(event map[string]any) string {
if stringFromAny(event["type"]) == "response.output_text.delta" {
return stringFromAny(event["delta"])
}
return ""
}
type chatResponsesStreamAdapter struct {
publicID string
model string
started bool
sequence int
nextOutput int
textOutput int
textAdded bool
text strings.Builder
tools map[int]*chatResponseStreamTool
}
type chatResponseStreamTool struct {
ItemID string
CallID string
Name string
Arguments strings.Builder
Added bool
OutputIndex int
}
func newChatResponsesStreamAdapter(publicID string, model string) *chatResponsesStreamAdapter {
return &chatResponsesStreamAdapter{publicID: publicID, model: model, textOutput: -1, tools: map[int]*chatResponseStreamTool{}}
}
func (a *chatResponsesStreamAdapter) emit(onDelta StreamDelta, event map[string]any) error {
if onDelta == nil {
return nil
}
event["sequence_number"] = a.sequence
a.sequence++
return onDelta(StreamDeltaEvent{Text: nativeResponseDeltaText(event), Event: event})
}
func (a *chatResponsesStreamAdapter) start(onDelta StreamDelta) error {
if a.started || onDelta == nil {
return nil
}
a.started = true
response := map[string]any{"id": a.publicID, "object": "response", "created_at": time.Now().Unix(), "status": "in_progress", "model": a.model, "output": []any{}}
for _, eventType := range []string{"response.created", "response.in_progress"} {
if err := a.emit(onDelta, map[string]any{"type": eventType, "response": response}); err != nil {
return err
}
}
return nil
}
func (a *chatResponsesStreamAdapter) delta(event StreamDeltaEvent, onDelta StreamDelta) error {
if onDelta == nil || event.Event == nil {
return nil
}
if err := a.start(onDelta); err != nil {
return err
}
choices, _ := event.Event["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if content := stringFromAny(delta["content"]); content != "" {
if !a.textAdded {
a.textAdded = true
a.textOutput = a.nextOutput
a.nextOutput++
itemID := "msg_" + responseIDSuffix(a.publicID)
if err := a.emit(onDelta, map[string]any{
"type": "response.output_item.added", "response_id": a.publicID, "output_index": a.textOutput,
"item": map[string]any{"id": itemID, "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}},
}); err != nil {
return err
}
if err := a.emit(onDelta, map[string]any{
"type": "response.content_part.added", "response_id": a.publicID, "item_id": itemID,
"output_index": a.textOutput, "content_index": 0,
"part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}},
}); err != nil {
return err
}
}
a.text.WriteString(content)
if err := a.emit(onDelta, map[string]any{
"type": "response.output_text.delta", "response_id": a.publicID,
"item_id": "msg_" + responseIDSuffix(a.publicID), "output_index": a.textOutput, "content_index": 0, "delta": content,
}); err != nil {
return err
}
}
toolCalls, _ := delta["tool_calls"].([]any)
for _, rawToolCall := range toolCalls {
toolCall, _ := rawToolCall.(map[string]any)
index := intFromAny(toolCall["index"])
tool := a.tools[index]
if tool == nil {
tool = &chatResponseStreamTool{ItemID: fmt.Sprintf("fc_%s_%d", responseIDSuffix(a.publicID), index), OutputIndex: a.nextOutput}
a.nextOutput++
a.tools[index] = tool
}
if callID := stringFromAny(toolCall["id"]); callID != "" {
tool.CallID = callID
}
function, _ := toolCall["function"].(map[string]any)
if name := stringFromAny(function["name"]); name != "" {
tool.Name += name
}
arguments := stringFromAny(function["arguments"])
if !tool.Added && (tool.CallID != "" || tool.Name != "" || arguments != "") {
tool.Added = true
if err := a.emit(onDelta, map[string]any{
"type": "response.output_item.added", "response_id": a.publicID, "output_index": tool.OutputIndex,
"item": map[string]any{"id": tool.ItemID, "type": "function_call", "status": "in_progress", "call_id": tool.CallID, "name": tool.Name, "arguments": ""},
}); err != nil {
return err
}
}
if arguments != "" {
tool.Arguments.WriteString(arguments)
if err := a.emit(onDelta, map[string]any{
"type": "response.function_call_arguments.delta", "response_id": a.publicID,
"item_id": tool.ItemID, "output_index": tool.OutputIndex, "delta": arguments,
}); err != nil {
return err
}
}
}
}
return nil
}
func (a *chatResponsesStreamAdapter) done(result map[string]any, onDelta StreamDelta) error {
if onDelta == nil {
return nil
}
a.alignOutput(result)
if a.text.Len() > 0 {
text := a.text.String()
itemID := "msg_" + responseIDSuffix(a.publicID)
if err := a.emit(onDelta, map[string]any{"type": "response.output_text.done", "response_id": a.publicID, "item_id": itemID, "output_index": a.textOutput, "content_index": 0, "text": text}); err != nil {
return err
}
if err := a.emit(onDelta, map[string]any{
"type": "response.content_part.done", "response_id": a.publicID, "item_id": itemID,
"output_index": a.textOutput, "content_index": 0,
"part": map[string]any{"type": "output_text", "text": text, "annotations": []any{}, "logprobs": []any{}},
}); err != nil {
return err
}
if err := a.emit(onDelta, map[string]any{"type": "response.output_item.done", "response_id": a.publicID, "output_index": a.textOutput, "item": firstResponseOutputItem(result, "message")}); err != nil {
return err
}
}
tools := make([]*chatResponseStreamTool, 0, len(a.tools))
for _, tool := range a.tools {
tools = append(tools, tool)
}
sort.Slice(tools, func(i, j int) bool { return tools[i].OutputIndex < tools[j].OutputIndex })
for _, tool := range tools {
arguments := tool.Arguments.String()
if err := a.emit(onDelta, map[string]any{"type": "response.function_call_arguments.done", "response_id": a.publicID, "item_id": tool.ItemID, "output_index": tool.OutputIndex, "arguments": arguments}); err != nil {
return err
}
if err := a.emit(onDelta, map[string]any{"type": "response.output_item.done", "response_id": a.publicID, "output_index": tool.OutputIndex, "item": responseOutputItemByID(result, tool.ItemID)}); err != nil {
return err
}
}
return nil
}
func (a *chatResponsesStreamAdapter) alignOutput(response map[string]any) {
items, _ := response["output"].([]any)
if len(items) == 0 || a.nextOutput == 0 {
return
}
ordered := make([]any, a.nextOutput)
if a.textOutput >= 0 {
ordered[a.textOutput] = firstResponseOutputItem(response, "message")
}
for _, tool := range a.tools {
ordered[tool.OutputIndex] = responseOutputItemByID(response, tool.ItemID)
}
result := make([]any, 0, len(ordered))
for _, item := range ordered {
if item != nil {
result = append(result, item)
}
}
response["output"] = result
}
func firstResponseOutputItem(response map[string]any, itemType string) any {
items, _ := response["output"].([]any)
for _, raw := range items {
item, _ := raw.(map[string]any)
if stringFromAny(item["type"]) == itemType {
return item
}
}
return nil
}
func responseOutputItemByID(response map[string]any, id string) any {
items, _ := response["output"].([]any)
for _, raw := range items {
item, _ := raw.(map[string]any)
if stringFromAny(item["id"]) == id {
return item
}
}
return nil
}
func responseIDSuffix(publicID string) string {
return strings.TrimPrefix(publicID, "resp_")
}
func unsupportedResponseParameter(parameter string) error {
return &ClientError{Code: "unsupported_response_parameter", Message: "Chat fallback does not support Responses parameter: " + parameter, StatusCode: http.StatusBadRequest}
}

View File

@ -0,0 +1,332 @@
package clients
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" {
t.Fatalf("expected /responses, got %s", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["messages"] != nil {
t.Fatalf("native Responses request must not contain messages: %+v", body)
}
if body["previous_response_id"] != "resp_upstream_parent" {
t.Fatalf("expected translated upstream previous id, got %+v", body["previous_response_id"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "resp_upstream_child", "object": "response", "status": "completed", "previous_response_id": "resp_upstream_parent",
"output": []any{
map[string]any{
"type": "message",
"content": []any{map[string]any{"type": "output_text", "text": "ok"}},
},
},
"usage": map[string]any{"input_tokens": 2, "output_tokens": 1, "total_tokens": 3},
})
}))
defer server.Close()
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo",
Body: map[string]any{"input": "hello", "messages": []any{map[string]any{"role": "user", "content": "illegal"}}},
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
PublicPreviousResponseID: "resp_abcdefghijklmnopqrstuvwxyz123456", UpstreamPreviousResponseID: "resp_upstream_parent",
})
if err != nil {
t.Fatal(err)
}
if response.Result["id"] != "resp_upstream_child" || response.Result["previous_response_id"] != "resp_upstream_parent" {
t.Fatalf("vendor response ids were not preserved: %+v", response.Result)
}
if response.PublicResponseID != "resp_upstream_child" || response.UpstreamResponseID != "resp_upstream_child" || response.UpstreamEndpoint != "/responses" || response.ResponseConverted {
t.Fatalf("unexpected native response metadata: %+v", response)
}
}
func TestOpenAIResponsesChatFallbackPreservesHistoryToolsUsageAndReasoningInternally(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("expected /chat/completions, got %s", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
messages, _ := body["messages"].([]any)
if len(messages) != 4 {
t.Fatalf("expected prior user/assistant plus current system/user/tool messages, got %+v", messages)
}
if body["max_tokens"] != float64(128) || body["reasoning_effort"] != "high" {
t.Fatalf("expected mapped max/reasoning fields: %+v", body)
}
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("expected mapped function tool: %+v", body["tools"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-upstream", "object": "chat.completion", "created": 1710000000, "model": "demo",
"choices": []any{map[string]any{"index": 0, "finish_reason": "tool_calls", "message": map[string]any{
"role": "assistant", "content": "visible", "reasoning_content": "hidden reasoning",
"tool_calls": []any{map[string]any{"id": "call_stable", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"x\":1}"}}},
}}},
"usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 5, "total_tokens": 25, "prompt_tokens_details": map[string]any{"cached_tokens": 7}},
})
}))
defer server.Close()
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo", Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
PreviousResponseTurns: []ResponseTurn{{
Request: map[string]any{"input": "prior user", "instructions": "must not be replayed"},
Internal: map[string]any{
"choices": []any{map[string]any{
"message": map[string]any{"role": "assistant", "content": "prior assistant"},
}},
},
}},
Body: map[string]any{
"instructions": "current instruction", "previous_response_id": "resp_parent", "input": []any{map[string]any{"type": "function_call_output", "call_id": "call_prior", "output": "done"}},
"max_output_tokens": 128, "reasoning": map[string]any{"effort": "high"},
"tools": []any{map[string]any{"type": "function", "name": "lookup", "description": "demo", "parameters": map[string]any{"type": "object"}}},
},
})
if err != nil {
t.Fatal(err)
}
if !response.ResponseConverted || response.UpstreamProtocol != ProtocolOpenAIChatCompletions || response.UpstreamEndpoint != "/chat/completions" {
t.Fatalf("unexpected conversion metadata: %+v", response)
}
if response.Result["previous_response_id"] != "resp_parent" {
t.Fatalf("Chat fallback response lost previous_response_id: %+v", response.Result)
}
output, _ := response.Result["output"].([]any)
if len(output) != 2 {
t.Fatalf("expected message plus function call: %+v", response.Result)
}
call, _ := output[1].(map[string]any)
if call["call_id"] != "call_stable" || call["arguments"] != "{\"x\":1}" {
t.Fatalf("function call continuity lost: %+v", call)
}
if strings.Contains(string(mustJSON(t, response.Result)), "hidden reasoning") {
t.Fatalf("visible Responses result leaked reasoning: %+v", response.Result)
}
if !strings.Contains(string(mustJSON(t, response.InternalResult)), "hidden reasoning") {
t.Fatalf("internal snapshot must retain reasoning for continuation: %+v", response.InternalResult)
}
usage, _ := response.Result["usage"].(map[string]any)
details, _ := usage["input_tokens_details"].(map[string]any)
if details["cached_tokens"] != 7 {
t.Fatalf("cached usage was not preserved: %+v", usage)
}
}
func TestOpenAIResponsesNativeStreamForwardsEventsWithoutChatAggregation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_upstream\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n"))
_, _ = w.Write([]byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"response_id\":\"resp_upstream\",\"delta\":\"hello\"}\n\n"))
_, _ = w.Write([]byte("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_upstream\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"))
}))
defer server.Close()
events := make([]StreamDeltaEvent, 0)
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
})
if err != nil {
t.Fatal(err)
}
if len(events) != 3 || events[0].Event["type"] != "response.created" || events[1].Event["type"] != "response.output_text.delta" || events[2].Event["type"] != "response.completed" {
t.Fatalf("unexpected forwarded events: %+v", events)
}
if response.Result["object"] != "response" || response.Result["choices"] != nil || response.UpstreamResponseID != "resp_upstream" {
t.Fatalf("native stream was rewritten as Chat: %+v", response)
}
}
func TestOpenAIResponsesChatFallbackStreamsFunctionArgumentFragments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("expected chat fallback endpoint, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_stream_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_x\",\"arguments\":\"{\\\"x\\\":\"}},{\"index\":1,\"id\":\"call_stream_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_y\",\"arguments\":\"{\\\"y\\\":\"}}]},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}},{\"index\":1,\"function\":{\"arguments\":\"2}\"}}]},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
events := make([]StreamDeltaEvent, 0)
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo", Body: map[string]any{
"input": "call it", "stream": true,
"tools": []any{map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}}},
}, Stream: true,
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
})
if err != nil {
t.Fatal(err)
}
types := make([]string, 0, len(events))
fragments := map[string]string{}
outputIndexes := map[int]bool{}
for _, event := range events {
eventType := stringFromAny(event.Event["type"])
types = append(types, eventType)
if sequence := intFromAny(event.Event["sequence_number"]); sequence != len(types)-1 {
t.Fatalf("converted event sequence is not contiguous at %d: %+v", len(types)-1, event.Event)
}
if eventType == "response.function_call_arguments.delta" {
itemID := stringFromAny(event.Event["item_id"])
fragments[itemID] += stringFromAny(event.Event["delta"])
outputIndexes[intFromAny(event.Event["output_index"])] = true
}
}
if !containsTestString(types, "response.created") || !containsTestString(types, "response.in_progress") || !containsTestString(types, "response.output_item.added") || !containsTestString(types, "response.function_call_arguments.done") || len(fragments) != 2 || len(outputIndexes) != 2 {
t.Fatalf("unexpected converted stream events types=%v fragments=%v indexes=%v", types, fragments, outputIndexes)
}
output, _ := response.Result["output"].([]any)
if len(output) != 2 {
t.Fatalf("expected two final function calls: %+v", response.Result)
}
first, _ := output[0].(map[string]any)
second, _ := output[1].(map[string]any)
if first["call_id"] != "call_stream_0" || first["arguments"] != "{\"x\":1}" || second["call_id"] != "call_stream_1" || second["arguments"] != "{\"y\":2}" {
t.Fatalf("parallel streamed calls were not aggregated: %+v", output)
}
}
func TestChatResultToResponseMapsIncompleteFinishReason(t *testing.T) {
response := ChatResultToResponse(map[string]any{
"choices": []any{map[string]any{"finish_reason": "length", "message": map[string]any{"role": "assistant", "content": "partial"}}},
}, "resp_12345678901234567890123456789012", "demo", map[string]any{})
if response["status"] != "incomplete" {
t.Fatalf("expected incomplete response, got %+v", response)
}
details, _ := response["incomplete_details"].(map[string]any)
if details["reason"] != "max_output_tokens" {
t.Fatalf("finish reason was not mapped: %+v", details)
}
}
func TestNativeResponsesStreamSupportsMultilineDataFrames(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: response.completed\n"))
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\n"))
_, _ = w.Write([]byte("data: \"response\":{\"id\":\"resp_vendor-with-dash\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}}\n\n"))
}))
defer server.Close()
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIResponses,
})
if err != nil {
t.Fatal(err)
}
if response.Result["id"] != "resp_vendor-with-dash" {
t.Fatalf("multiline frame was not decoded: %+v", response.Result)
}
}
func TestChatResponsesStreamAdapterEmitsTextItemLifecycle(t *testing.T) {
adapter := newChatResponsesStreamAdapter("resp_12345678901234567890123456789012", "demo")
events := make([]string, 0)
onDelta := func(event StreamDeltaEvent) error {
events = append(events, stringFromAny(event.Event["type"]))
return nil
}
err := adapter.delta(StreamDeltaEvent{Event: map[string]any{
"choices": []any{map[string]any{"delta": map[string]any{"content": "hello"}}},
}}, onDelta)
if err != nil {
t.Fatal(err)
}
result := ChatResultToResponse(map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "hello"}}},
}, adapter.publicID, "demo", map[string]any{})
if err := adapter.done(result, onDelta); err != nil {
t.Fatal(err)
}
want := []string{
"response.created", "response.in_progress", "response.output_item.added", "response.content_part.added",
"response.output_text.delta", "response.output_text.done", "response.content_part.done", "response.output_item.done",
}
if strings.Join(events, ",") != strings.Join(want, ",") {
t.Fatalf("unexpected text event lifecycle got=%v want=%v", events, want)
}
}
func TestResponsesChatFallbackRejectsBuiltInToolsAndUnknownParameters(t *testing.T) {
_, err := ResponsesRequestToChat(map[string]any{"input": "hello", "tools": []any{map[string]any{"type": "web_search_preview"}}}, nil)
if ErrorCode(err) != "unsupported_response_tool" {
t.Fatalf("expected unsupported_response_tool, got %v", err)
}
_, err = ResponsesRequestToChat(map[string]any{"input": "hello", "conversation": "conv_1"}, nil)
if ErrorCode(err) != "unsupported_response_parameter" {
t.Fatalf("expected unsupported_response_parameter, got %v", err)
}
}
func TestResponsesChatFallbackKeepsClientManagedStateAuthoritative(t *testing.T) {
input := []any{
map[string]any{"type": "message", "role": "user", "content": "first"},
map[string]any{"type": "message", "role": "assistant", "content": "second"},
map[string]any{"type": "message", "role": "user", "content": "third"},
}
body, err := ResponsesRequestToChat(map[string]any{"input": input}, nil)
if err != nil {
t.Fatal(err)
}
messages, _ := body["messages"].([]any)
if len(messages) != len(input) {
t.Fatalf("client-managed input was supplemented by Gateway history: %+v", messages)
}
for index, raw := range messages {
message, _ := raw.(map[string]any)
expected, _ := input[index].(map[string]any)
if message["role"] != expected["role"] || message["content"] != expected["content"] {
t.Fatalf("client-managed message %d changed: got=%+v want=%+v", index, message, expected)
}
}
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}
func containsTestString(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}

View File

@ -11,17 +11,28 @@ import (
)
type Request struct {
Kind string
ModelType string
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
RemoteTaskID string
RemoteTaskPayload map[string]any
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
Stream bool
StreamDelta StreamDelta
Kind string
ModelType string
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
RemoteTaskID string
RemoteTaskPayload map[string]any
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
Stream bool
StreamDelta StreamDelta
UpstreamProtocol string
PublicResponseID string
PublicPreviousResponseID string
UpstreamPreviousResponseID string
PreviousResponseTurns []ResponseTurn
}
type ResponseTurn struct {
Request map[string]any
Response map[string]any
Internal map[string]any
}
type Response struct {
@ -33,6 +44,14 @@ type Response struct {
ResponseStartedAt time.Time
ResponseFinishedAt time.Time
ResponseDurationMS int64
UpstreamProtocol string
UpstreamEndpoint string
UpstreamResponseID string
PublicResponseID string
ParentResponseID string
ResponseChainDepth int
ResponseConverted bool
InternalResult map[string]any
}
type Usage struct {

View File

@ -965,8 +965,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Router /api/v1/voice_clone [post]
// @Router /chat/completions [post]
// @Router /v1/chat/completions [post]
// @Router /responses [post]
// @Router /v1/responses [post]
// @Router /embeddings [post]
// @Router /v1/embeddings [post]
// @Router /reranks [post]
@ -1085,6 +1083,26 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
return s.createTask("chat.completions", false)
}
// openAIResponsesDoc godoc
// @Summary 创建 OpenAI Responses
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。
// @Tags responses
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param input body ResponsesRequest true "Responses 请求Chat 回退只支持自定义 function tools"
// @Success 200 {object} ResponsesCompatibleResponse
// @Header 200 {string} X-Gateway-Task-Id "网关审计任务 ID"
// @Failure 400 {object} ErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Router /responses [post]
// @Router /v1/responses [post]
func openAIResponsesDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
@ -1116,6 +1134,7 @@ type taskExecutor interface {
}
func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, task store.GatewayTask, user *auth.User, streamMode bool, includeUsage bool) {
w.Header().Set("X-Gateway-Task-Id", task.ID)
if streamMode {
flusher := prepareCompatibleStream(w)
streamWriter := newCompatibleStreamWriter(kind, model, includeUsage)
@ -1306,6 +1325,10 @@ func scopeForTaskKind(kind string) string {
func statusFromRunError(err error) int {
switch {
case clients.ErrorCode(err) == "invalid_previous_response_id" || clients.ErrorCode(err) == "response_chain_too_deep" || clients.ErrorCode(err) == "unsupported_model_protocol" || clients.ErrorCode(err) == "unsupported_response_tool" || clients.ErrorCode(err) == "unsupported_response_parameter":
return http.StatusBadRequest
case clients.ErrorCode(err) == "response_chain_unavailable":
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
return http.StatusBadRequest
case clients.ErrorCode(err) == "cloned_voice_not_found":

View File

@ -228,10 +228,32 @@ type ChatMessage struct {
}
type ResponsesRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Input interface{} `json:"input" example:"Tell me a short story"`
Stream bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Tools []map[string]interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty" example:"true"`
MaxOutputTokens int `json:"max_output_tokens,omitempty" example:"512"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
Text map[string]interface{} `json:"text,omitempty"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
TopP float64 `json:"top_p,omitempty" example:"1"`
Store *bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty" example:"false"`
}
type ResponsesCompatibleResponse struct {
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
Object string `json:"object" example:"response"`
CreatedAt int64 `json:"created_at" example:"1710000000"`
Status string `json:"status" example:"completed"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_abcdef0123456789abcdef0123456789"`
Output []map[string]interface{} `json:"output"`
OutputText string `json:"output_text,omitempty" example:"Hello"`
Usage map[string]interface{} `json:"usage,omitempty"`
}
type ImageGenerationRequest struct {

View File

@ -0,0 +1,36 @@
package httpapi
import (
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
)
func TestResponsesStreamWriterForwardsStandardEventsAndNeverWritesDoneMarker(t *testing.T) {
recorder := httptest.NewRecorder()
writer := newCompatibleStreamWriter("responses", "demo", true)
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
"type": "response.created", "response": map[string]any{"id": "resp_123", "status": "in_progress"},
}})
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
"type": "response.function_call_arguments.delta", "item_id": "fc_1", "delta": "{\"x\":",
}})
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
"type": "response.completed", "sequence_number": 2, "response": map[string]any{"id": "resp_123", "status": "completed", "output": []any{}},
}})
writer.writeDone(recorder, map[string]any{"id": "resp_123", "object": "response", "status": "completed", "output": []any{}})
body := recorder.Body.String()
for _, expected := range []string{"event: response.created", "event: response.function_call_arguments.delta", "event: response.completed"} {
if !strings.Contains(body, expected) {
t.Fatalf("missing %q in stream: %s", expected, body)
}
}
if strings.Contains(body, "[DONE]") || strings.Contains(body, "chat.completion") {
t.Fatalf("Responses stream leaked Chat framing: %s", body)
}
if strings.Count(body, "event: response.completed") != 1 {
t.Fatalf("Responses stream duplicated response.completed: %s", body)
}
}

View File

@ -28,6 +28,8 @@ type compatibleStreamWriter struct {
sentRole bool
sentFinish bool
sentUsage bool
responseSequence int
sentResponseDone bool
}
func newCompatibleStreamWriter(kind string, model string, includeUsage bool) *compatibleStreamWriter {
@ -42,6 +44,19 @@ func newCompatibleStreamWriter(kind string, model string, includeUsage bool) *co
func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients.StreamDeltaEvent) {
if s.kind == "responses" {
if event.Event != nil {
eventType, _ := event.Event["type"].(string)
if eventType != "" {
if sequence := intFromStreamValue(event.Event["sequence_number"]); sequence >= s.responseSequence {
s.responseSequence = sequence + 1
}
if eventType == "response.completed" {
s.sentResponseDone = true
}
sendSSE(w, eventType, event.Event)
return
}
}
if event.Text != "" {
sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": event.Text})
}
@ -67,7 +82,11 @@ func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients
func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[string]any) {
if s.kind == "responses" {
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output})
if s.sentResponseDone {
return
}
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "sequence_number": s.responseSequence, "response": output})
s.sentResponseDone = true
return
}
s.captureOutputMetadata(output)
@ -88,6 +107,19 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
s.writeDoneMarker(w)
}
func intFromStreamValue(value any) int {
switch typed := value.(type) {
case int:
return typed
case int64:
return int(typed)
case float64:
return int(typed)
default:
return -1
}
}
func (s *compatibleStreamWriter) writeChatChunk(w http.ResponseWriter, chunk map[string]any) {
chunk = clients.NormalizeChatCompletionStreamEvent(chunk)
s.captureChunkMetadata(chunk)

View File

@ -70,6 +70,15 @@ func taskMetrics(task store.GatewayTask, user *auth.User, body map[string]any, c
if response.ResponseDurationMS > 0 {
metrics["responseDurationMs"] = response.ResponseDurationMS
}
if response.UpstreamProtocol != "" {
metrics["upstreamProtocol"] = response.UpstreamProtocol
metrics["upstreamEndpoint"] = response.UpstreamEndpoint
metrics["responseConverted"] = response.ResponseConverted
metrics["publicResponseId"] = response.PublicResponseID
metrics["upstreamResponseId"] = response.UpstreamResponseID
metrics["parentResponseId"] = response.ParentResponseID
metrics["responseChainDepth"] = response.ResponseChainDepth
}
switch task.Kind {
case "chat.completions", "responses":
metrics["stream"] = boolFromMap(body, "stream")
@ -119,6 +128,16 @@ func attemptMetrics(candidate store.RuntimeModelCandidate, attemptNo int, simula
"loadAvoided": candidate.LoadAvoided,
"simulated": simulated,
}
if candidate.ResponseProtocol != "" {
metrics["upstreamProtocol"] = candidate.ResponseProtocol
if candidate.ResponseProtocol == clients.ProtocolOpenAIResponses {
metrics["upstreamEndpoint"] = "/responses"
metrics["responseConverted"] = false
} else if candidate.ResponseProtocol == clients.ProtocolOpenAIChatCompletions {
metrics["upstreamEndpoint"] = "/chat/completions"
metrics["responseConverted"] = true
}
}
if candidate.LoadLimited {
metrics["loadMetrics"] = map[string]any{
"rpm": map[string]any{

View File

@ -0,0 +1,256 @@
package runner
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
responseChainMaxTTL = 30 * 24 * time.Hour
responseChainMaxDepth = 100
)
type responseExecutionContext struct {
PublicResponseID string
PublicPreviousResponseID string
UpstreamPreviousResponseID string
Protocol string
Store bool
PreviousChain *store.ResponseChain
PreviousTurns []clients.ResponseTurn
ChainDepth int
}
func (s *Service) prepareResponseExecution(ctx context.Context, task store.GatewayTask, body map[string]any) (responseExecutionContext, error) {
result := responseExecutionContext{
PublicResponseID: gatewayResponseID(task.ID),
Store: true,
}
if value, ok := body["store"].(bool); ok {
result.Store = value
}
previousID := strings.TrimSpace(stringFromAny(body["previous_response_id"]))
if previousID == "" {
// No Gateway response chain was requested. The caller owns the complete
// conversation state in input/messages, so it must be forwarded as-is.
return result, nil
}
chain, err := s.store.GetResponseChain(ctx, previousID, task)
if err != nil {
return responseExecutionContext{}, &clients.ClientError{
Code: "invalid_previous_response_id", Message: "previous_response_id is invalid or expired", StatusCode: http.StatusBadRequest,
}
}
if !sameResponseModel(chain.RequestedModel, task.Model) {
return responseExecutionContext{}, &clients.ClientError{
Code: "invalid_previous_response_id", Message: "previous_response_id belongs to a different model", StatusCode: http.StatusBadRequest,
}
}
history, historyErr := s.store.ListResponseChainHistory(ctx, chain)
if historyErr != nil {
if errors.Is(historyErr, store.ErrResponseChainTooDeep) {
return responseExecutionContext{}, &clients.ClientError{Code: "response_chain_too_deep", Message: historyErr.Error(), StatusCode: http.StatusBadRequest}
}
return responseExecutionContext{}, historyErr
}
chainDepth, depthErr := responseHistoryDepth(history)
if depthErr != nil {
return responseExecutionContext{}, depthErr
}
result.PublicPreviousResponseID = chain.PublicResponseID
result.UpstreamPreviousResponseID = chain.UpstreamResponseID
result.Protocol = chain.UpstreamProtocol
result.PreviousChain = &chain
result.ChainDepth = chainDepth
if chain.UpstreamProtocol == clients.ProtocolOpenAIChatCompletions {
// Chat upstreams are stateless from the Responses API perspective. Only
// this Gateway-managed mode replays stored turns; native Responses keeps
// the provider's previous_response_id state authoritative.
result.PreviousTurns = make([]clients.ResponseTurn, 0, len(history))
for _, turn := range history {
result.PreviousTurns = append(result.PreviousTurns, clients.ResponseTurn{
Request: turn.RequestSnapshot, Response: turn.ResponseSnapshot, Internal: turn.InternalSnapshot,
})
}
}
return result, nil
}
func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, execution responseExecutionContext) ([]store.RuntimeModelCandidate, error) {
if execution.PreviousChain != nil {
for _, candidate := range candidates {
if candidate.PlatformModelID != execution.PreviousChain.PlatformModelID {
continue
}
if !candidateSupportsProtocol(candidate, execution.PreviousChain.UpstreamProtocol) {
break
}
candidate.ResponseProtocol = execution.PreviousChain.UpstreamProtocol
return []store.RuntimeModelCandidate{candidate}, nil
}
return nil, responseChainUnavailableError()
}
type indexedCandidate struct {
candidate store.RuntimeModelCandidate
index int
group int
}
items := make([]indexedCandidate, 0, len(candidates))
hasAnthropicOnly := false
hasDeclaredUnsupported := false
for index, candidate := range candidates {
protocols := candidateSupportedProtocols(candidate)
group := 1
protocol := clients.ProtocolOpenAIChatCompletions
if containsString(protocols, clients.ProtocolOpenAIResponses) {
group = 0
protocol = clients.ProtocolOpenAIResponses
} else if !containsString(protocols, clients.ProtocolOpenAIChatCompletions) {
hasDeclaredUnsupported = true
if containsString(protocols, clients.ProtocolAnthropicMessages) {
hasAnthropicOnly = true
}
continue
}
candidate.ResponseProtocol = protocol
items = append(items, indexedCandidate{candidate: candidate, index: index, group: group})
}
if len(items) == 0 {
if hasAnthropicOnly {
return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model only supports Anthropic Messages; the public Anthropic adapter is not available", StatusCode: http.StatusBadRequest}
}
if hasDeclaredUnsupported {
return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model does not declare an OpenAI-compatible protocol", StatusCode: http.StatusBadRequest}
}
return nil, responseChainUnavailableError()
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].group != items[j].group {
return items[i].group < items[j].group
}
return items[i].index < items[j].index
})
out := make([]store.RuntimeModelCandidate, 0, len(items))
for _, item := range items {
out = append(out, item.candidate)
}
return out, nil
}
func candidateSupportedProtocols(candidate store.RuntimeModelCandidate) []string {
capability := effectiveModelCapability(candidate)
textCapability, _ := capability["text_generate"].(map[string]any)
value, present := textCapability["supportedApiProtocols"]
if !present {
value, present = capability["supportedApiProtocols"]
}
if !present {
return []string{clients.ProtocolOpenAIChatCompletions}
}
protocols := stringListFromAny(value)
out := make([]string, 0, len(protocols))
for _, protocol := range protocols {
switch protocol {
case clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses, clients.ProtocolAnthropicMessages:
out = append(out, protocol)
}
}
return out
}
func candidateSupportsProtocol(candidate store.RuntimeModelCandidate, protocol string) bool {
return containsString(candidateSupportedProtocols(candidate), protocol)
}
func responseChainUnavailableError() error {
return &clients.ClientError{
Code: "response_chain_unavailable", Message: "the platform model pinned to this response chain is unavailable", StatusCode: http.StatusServiceUnavailable,
}
}
func gatewayResponseID(taskID string) string {
return "resp_" + strings.ReplaceAll(strings.TrimSpace(taskID), "-", "")
}
func (s *Service) persistResponseChain(ctx context.Context, task store.GatewayTask, body map[string]any, candidate store.RuntimeModelCandidate, execution responseExecutionContext, response clients.Response) error {
if task.Kind != "responses" || !execution.Store {
return nil
}
expiresAt := responseChainExpiry(response.Result, candidate.ResponseProtocol)
publicResponseID := strings.TrimSpace(response.PublicResponseID)
if publicResponseID == "" {
publicResponseID = strings.TrimSpace(stringFromAny(response.Result["id"]))
}
if publicResponseID == "" {
return &clients.ClientError{Code: "invalid_response", Message: "Responses result is missing id", StatusCode: http.StatusBadGateway}
}
return s.store.CreateResponseChain(ctx, store.CreateResponseChainInput{ResponseChain: store.ResponseChain{
PublicResponseID: publicResponseID,
ParentResponseID: execution.PublicPreviousResponseID,
GatewayTaskID: task.ID,
GatewayUserID: task.GatewayUserID,
UserID: task.UserID,
UserSource: task.UserSource,
GatewayTenantID: task.GatewayTenantID,
TenantID: task.TenantID,
TenantKey: task.TenantKey,
APIKeyID: task.APIKeyID,
PlatformID: candidate.PlatformID,
PlatformModelID: candidate.PlatformModelID,
ClientID: candidate.ClientID,
UpstreamProtocol: candidate.ResponseProtocol,
UpstreamEndpoint: response.UpstreamEndpoint,
UpstreamResponseID: response.UpstreamResponseID,
RequestSnapshot: cloneMap(body),
ResponseSnapshot: cloneMap(response.Result),
InternalSnapshot: cloneMap(response.InternalResult),
ExpiresAt: expiresAt,
}})
}
func sameResponseModel(previous string, current string) bool {
return strings.EqualFold(strings.TrimSpace(previous), strings.TrimSpace(current))
}
func responseHistoryDepth(history []store.ResponseChain) (int, error) {
if len(history) >= responseChainMaxDepth {
return 0, &clients.ClientError{Code: "response_chain_too_deep", Message: "response chain exceeds the maximum depth", StatusCode: http.StatusBadRequest}
}
return len(history), nil
}
func responseChainExpiry(result map[string]any, protocol string) time.Time {
now := time.Now()
maximum := now.Add(responseChainMaxTTL)
if protocol != clients.ProtocolOpenAIResponses {
return maximum
}
var parsed time.Time
switch value := result["expire_at"].(type) {
case float64:
parsed = time.Unix(int64(value), 0)
case int64:
parsed = time.Unix(value, 0)
case string:
parsed, _ = time.Parse(time.RFC3339, value)
}
if parsed.IsZero() || parsed.After(maximum) {
return maximum
}
return parsed
}
func responseExecutionFailure(err error) (string, string) {
if errors.Is(err, store.ErrInvalidPreviousResponseID) {
return "invalid_previous_response_id", "previous_response_id is invalid or expired"
}
return clients.ErrorCode(err), err.Error()
}

View File

@ -0,0 +1,129 @@
package runner
import (
"context"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestCandidateSupportedProtocolsDefaultsToChat(t *testing.T) {
candidate := store.RuntimeModelCandidate{Capabilities: map[string]any{"text_generate": map[string]any{"supportTool": true}}}
protocols := candidateSupportedProtocols(candidate)
if len(protocols) != 1 || protocols[0] != clients.ProtocolOpenAIChatCompletions {
t.Fatalf("legacy capability must default to Chat: %+v", protocols)
}
}
func TestPrepareResponseCandidatesPrioritizesNativeAndKeepsGroupOrder(t *testing.T) {
candidates := []store.RuntimeModelCandidate{
{PlatformModelID: "chat-1", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
{PlatformModelID: "native-1", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)},
{PlatformModelID: "native-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIResponses)},
{PlatformModelID: "chat-2", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
}
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{})
if err != nil {
t.Fatal(err)
}
got := []string{prepared[0].PlatformModelID, prepared[1].PlatformModelID, prepared[2].PlatformModelID, prepared[3].PlatformModelID}
want := []string{"native-1", "native-2", "chat-1", "chat-2"}
for index := range want {
if got[index] != want[index] {
t.Fatalf("unexpected protocol ordering got=%v want=%v", got, want)
}
}
if prepared[0].ResponseProtocol != clients.ProtocolOpenAIResponses || prepared[2].ResponseProtocol != clients.ProtocolOpenAIChatCompletions {
t.Fatalf("protocol assignment mismatch: %+v", prepared)
}
}
func TestPrepareResponseCandidatesPinsPreviousPlatformModelAndProtocol(t *testing.T) {
chain := store.ResponseChain{PlatformModelID: "pinned", UpstreamProtocol: clients.ProtocolOpenAIChatCompletions}
candidates := []store.RuntimeModelCandidate{
{PlatformModelID: "other", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions)},
{PlatformModelID: "pinned", Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses)},
}
prepared, err := prepareResponseCandidates(candidates, responseExecutionContext{PreviousChain: &chain})
if err != nil {
t.Fatal(err)
}
if len(prepared) != 1 || prepared[0].PlatformModelID != "pinned" || prepared[0].ResponseProtocol != clients.ProtocolOpenAIChatCompletions {
t.Fatalf("previous chain was not pinned: %+v", prepared)
}
_, err = prepareResponseCandidates(candidates[:1], responseExecutionContext{PreviousChain: &chain})
if clients.ErrorCode(err) != "response_chain_unavailable" {
t.Fatalf("expected response_chain_unavailable, got %v", err)
}
}
func TestPrepareResponseCandidatesRejectsAnthropicOnly(t *testing.T) {
_, err := prepareResponseCandidates([]store.RuntimeModelCandidate{{Capabilities: responseProtocolCapability(clients.ProtocolAnthropicMessages)}}, responseExecutionContext{})
if clients.ErrorCode(err) != "unsupported_model_protocol" {
t.Fatalf("expected unsupported_model_protocol, got %v", err)
}
}
func TestGatewayResponseIDAndExpiryPolicy(t *testing.T) {
id := gatewayResponseID("01234567-89ab-cdef-0123-456789abcdef")
if id != "resp_0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected public response id: %s", id)
}
now := time.Now()
chatExpiry := responseChainExpiry(map[string]any{}, clients.ProtocolOpenAIChatCompletions)
if chatExpiry.Before(now.Add(responseChainMaxTTL-time.Minute)) || chatExpiry.After(now.Add(responseChainMaxTTL+time.Minute)) {
t.Fatalf("Chat chain must use the 30-day retention policy: %v", chatExpiry)
}
upstreamExpiry := now.Add(24 * time.Hour).Unix()
nativeExpiry := responseChainExpiry(map[string]any{"expire_at": float64(upstreamExpiry)}, clients.ProtocolOpenAIResponses)
if nativeExpiry.Unix() != upstreamExpiry {
t.Fatalf("native chain must honor an earlier upstream expiry: %v", nativeExpiry)
}
}
func TestResponseModelAndDepthValidation(t *testing.T) {
if !sameResponseModel("DeepSeek-V4-Pro", "deepseek-v4-pro") || sameResponseModel("Qwen3.7-Plus", "DeepSeek-V4-Pro") {
t.Fatal("response continuation model matching is not strict")
}
history := make([]store.ResponseChain, responseChainMaxDepth)
if _, err := responseHistoryDepth(history); clients.ErrorCode(err) != "response_chain_too_deep" {
t.Fatalf("expected response_chain_too_deep, got %v", err)
}
}
func TestCandidateProtocolOverrideAndExplicitEmpty(t *testing.T) {
candidate := store.RuntimeModelCandidate{
Capabilities: responseProtocolCapability(clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses),
CapabilityOverride: responseProtocolCapability(clients.ProtocolAnthropicMessages),
}
protocols := candidateSupportedProtocols(candidate)
if len(protocols) != 1 || protocols[0] != clients.ProtocolAnthropicMessages {
t.Fatalf("platform capability override was not applied: %v", protocols)
}
empty := store.RuntimeModelCandidate{Capabilities: responseProtocolCapability()}
if protocols := candidateSupportedProtocols(empty); len(protocols) != 0 {
t.Fatalf("an explicitly empty protocol list must not become legacy Chat: %v", protocols)
}
if _, err := prepareResponseCandidates([]store.RuntimeModelCandidate{empty}, responseExecutionContext{}); clients.ErrorCode(err) != "unsupported_model_protocol" {
t.Fatalf("expected unsupported_model_protocol, got %v", err)
}
}
func TestPersistResponseChainSkipsStoreFalse(t *testing.T) {
service := &Service{}
err := service.persistResponseChain(context.Background(), store.GatewayTask{Kind: "responses"}, nil, store.RuntimeModelCandidate{}, responseExecutionContext{Store: false}, clients.Response{})
if err != nil {
t.Fatalf("store:false must not require a response-chain store: %v", err)
}
}
func responseProtocolCapability(protocols ...string) map[string]any {
items := make([]any, len(protocols))
for index, protocol := range protocols {
items[index] = protocol
}
return map[string]any{"text_generate": map[string]any{"supportedApiProtocols": items}}
}

View File

@ -93,6 +93,7 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
return Result{}, err
}
body := normalizeRequest(task.Kind, restoredRequest)
responseExecution := responseExecutionContext{}
modelType := modelTypeFromKind(task.Kind, body)
if err := s.store.MarkTaskRunning(ctx, task.ID, modelType, s.slimTaskRequestSnapshot(task, body)); err != nil {
return Result{}, err
@ -145,6 +146,18 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
return Result{}, err
}
}
if task.Kind == "responses" {
responseExecution, err = s.prepareResponseExecution(ctx, task, body)
if err != nil {
code, message := responseExecutionFailure(err)
s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_chain", Reason: code, ModelType: modelType})
failed, finishErr := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", err)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, err
}
}
runnerPolicy, err := s.store.GetActiveRunnerPolicy(ctx)
if err != nil {
return Result{}, err
@ -156,6 +169,9 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
CacheAffinityPolicy: runnerPolicy.CacheAffinityPolicy,
})
if err != nil {
if task.Kind == "responses" && responseExecution.PreviousChain != nil {
err = responseChainUnavailableError()
}
s.recordFailedAttempt(ctx, failedAttemptRecord{
Task: task,
Body: body,
@ -214,6 +230,18 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
}
return Result{Task: failed, Output: failed.Result}, err
}
if task.Kind == "responses" {
candidates, err = prepareResponseCandidates(candidates, responseExecution)
if err != nil {
code, message := responseExecutionFailure(err)
s.recordFailedAttempt(ctx, failedAttemptRecord{Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: code, Cause: err, Simulated: task.RunMode == "simulation", Scope: "response_protocol", Reason: code, ModelType: modelType})
failed, finishErr := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", err)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, err
}
}
firstCandidateBody := body
normalizedModelType := modelType
attemptNo := task.AttemptCount
@ -325,7 +353,7 @@ candidatesLoop:
break candidatesLoop
}
candidateBody := preprocessing.Body
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
if err == nil {
attemptNo = nextAttemptNo
billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate))
@ -536,7 +564,7 @@ candidatesLoop:
return Result{Task: failed, Output: failed.Result}, lastErr
}
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
simulated := isSimulation(task, candidate)
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
reservations := s.rateLimitReservations(ctx, user, candidate, body)
@ -628,6 +656,12 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
return clients.Response{}, err
}
callStartedAt := time.Now()
publicResponseID := ""
publicPreviousResponseID := ""
if task.Kind == "responses" && candidate.ResponseProtocol == clients.ProtocolOpenAIChatCompletions {
publicResponseID = responseExecution.PublicResponseID
publicPreviousResponseID = responseExecution.PublicPreviousResponseID
}
response, err := client.Run(ctx, clients.Request{
Kind: task.Kind,
ModelType: candidate.ModelType,
@ -643,8 +677,13 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, attemptID, remoteTaskID, payload)
},
Stream: boolFromMap(providerBody, "stream"),
StreamDelta: onDelta,
Stream: boolFromMap(providerBody, "stream"),
StreamDelta: onDelta,
UpstreamProtocol: candidate.ResponseProtocol,
PublicResponseID: publicResponseID,
PublicPreviousResponseID: publicPreviousResponseID,
UpstreamPreviousResponseID: responseExecution.UpstreamPreviousResponseID,
PreviousResponseTurns: responseExecution.PreviousTurns,
})
callFinishedAt := time.Now()
if response.ResponseStartedAt.IsZero() {
@ -715,6 +754,20 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
return clients.Response{}, err
}
response.Result = uploadedResult
if task.Kind == "responses" {
response.UpstreamProtocol = candidate.ResponseProtocol
response.ParentResponseID = responseExecution.PublicPreviousResponseID
response.ResponseChainDepth = responseExecution.ChainDepth
if candidate.ResponseProtocol == clients.ProtocolOpenAIChatCompletions {
response.Result["id"] = responseExecution.PublicResponseID
response.PublicResponseID = responseExecution.PublicResponseID
} else {
response.PublicResponseID = strings.TrimSpace(stringFromAny(response.Result["id"]))
}
if err := s.persistResponseChain(ctx, task, body, candidate, responseExecution, response); err != nil {
return clients.Response{}, fmt.Errorf("persist response chain: %w", err)
}
}
if task.Kind == "voice.clone" {
voice, err := s.persistVoiceCloneResult(ctx, task, user, candidate, attemptID, body, response.Result)
if err != nil {
@ -1283,9 +1336,6 @@ func loadAvoidanceFallbackDecision(err error) failoverDecision {
func normalizeRequest(kind string, body map[string]any) map[string]any {
out := cloneMap(body)
if kind == "responses" && out["messages"] == nil && out["input"] != nil {
out["messages"] = []any{map[string]any{"role": "user", "content": out["input"]}}
}
return out
}

View File

@ -31,7 +31,18 @@ func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType
COALESCE(p.dynamic_priority, p.priority) AS effective_priority,
m.id::text, COALESCE(m.base_model_id::text, ''), COALESCE(b.canonical_model_key, ''),
COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), m.model_name, COALESCE(m.model_alias, ''),
$2::text AS requested_model_type, m.display_name, m.capabilities, m.capability_override,
$2::text AS requested_model_type, m.display_name,
CASE
WHEN b.capabilities #> '{text_generate,supportedApiProtocols}' IS NOT NULL
THEN jsonb_set(
COALESCE(m.capabilities, '{}'::jsonb),
'{text_generate,supportedApiProtocols}',
b.capabilities #> '{text_generate,supportedApiProtocols}',
true
)
ELSE m.capabilities
END AS effective_capabilities,
m.capability_override,
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''),
COALESCE(b.pricing_rule_set_id::text, ''),

View File

@ -0,0 +1,183 @@
package store
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
var ErrInvalidPreviousResponseID = errors.New("invalid previous response id")
var ErrResponseChainTooDeep = errors.New("response chain exceeds the maximum depth")
const maxResponseChainHistory = 100
type ResponseChain struct {
PublicResponseID string
ParentResponseID string
GatewayTaskID string
RequestedModel string
GatewayUserID string
UserID string
UserSource string
GatewayTenantID string
TenantID string
TenantKey string
APIKeyID string
PlatformID string
PlatformModelID string
ClientID string
UpstreamProtocol string
UpstreamEndpoint string
UpstreamResponseID string
RequestSnapshot map[string]any
ResponseSnapshot map[string]any
InternalSnapshot map[string]any
CreatedAt time.Time
ExpiresAt time.Time
}
type CreateResponseChainInput struct {
ResponseChain
}
func (s *Store) CreateResponseChain(ctx context.Context, input CreateResponseChainInput) error {
requestJSON, err := json.Marshal(input.RequestSnapshot)
if err != nil {
return err
}
responseJSON, err := json.Marshal(input.ResponseSnapshot)
if err != nil {
return err
}
internalJSON, err := json.Marshal(input.InternalSnapshot)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO gateway_response_chains (
public_response_id, parent_response_id, gateway_task_id,
gateway_user_id, user_id, user_source, gateway_tenant_id, tenant_id, tenant_key, api_key_id,
platform_id, platform_model_id, client_id, upstream_protocol, upstream_endpoint, upstream_response_id,
request_snapshot, response_snapshot, internal_snapshot, expires_at
) VALUES (
$1, NULLIF($2, ''), $3::uuid,
NULLIF($4, '')::uuid, $5, $6, NULLIF($7, '')::uuid, NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''),
$11::uuid, $12::uuid, $13, $14, $15, NULLIF($16, ''),
$17::jsonb, $18::jsonb, $19::jsonb, $20
)
ON CONFLICT (public_response_id) DO NOTHING`,
input.PublicResponseID, input.ParentResponseID, input.GatewayTaskID,
input.GatewayUserID, input.UserID, input.UserSource, input.GatewayTenantID, input.TenantID, input.TenantKey, input.APIKeyID,
input.PlatformID, input.PlatformModelID, input.ClientID, input.UpstreamProtocol, input.UpstreamEndpoint, input.UpstreamResponseID,
requestJSON, responseJSON, internalJSON, input.ExpiresAt,
)
return err
}
func (s *Store) GetResponseChain(ctx context.Context, publicResponseID string, owner GatewayTask) (ResponseChain, error) {
chain, err := s.scanResponseChain(s.pool.QueryRow(ctx, `
SELECT chain.public_response_id, COALESCE(chain.parent_response_id, ''), chain.gateway_task_id::text,
task.requested_model,
COALESCE(chain.gateway_user_id::text, ''), chain.user_id, chain.user_source,
COALESCE(chain.gateway_tenant_id::text, ''), COALESCE(chain.tenant_id, ''), COALESCE(chain.tenant_key, ''), COALESCE(chain.api_key_id, ''),
chain.platform_id::text, chain.platform_model_id::text, chain.client_id, chain.upstream_protocol, chain.upstream_endpoint,
COALESCE(chain.upstream_response_id, ''), chain.request_snapshot, chain.response_snapshot, chain.internal_snapshot,
chain.created_at, chain.expires_at
FROM gateway_response_chains chain
JOIN gateway_tasks task ON task.id = chain.gateway_task_id
WHERE chain.public_response_id = $1`, strings.TrimSpace(publicResponseID)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ResponseChain{}, ErrInvalidPreviousResponseID
}
return ResponseChain{}, err
}
if chain.ExpiresAt.Before(time.Now()) || !sameResponseChainOwner(chain, owner) {
return ResponseChain{}, ErrInvalidPreviousResponseID
}
return chain, nil
}
func (s *Store) ListResponseChainHistory(ctx context.Context, current ResponseChain) ([]ResponseChain, error) {
rows, err := s.pool.Query(ctx, `
WITH RECURSIVE history AS (
SELECT chain.*, 0 AS depth
FROM gateway_response_chains chain
WHERE chain.public_response_id = $1
UNION ALL
SELECT parent.*, history.depth + 1
FROM gateway_response_chains parent
JOIN history ON parent.public_response_id = history.parent_response_id
WHERE history.depth < $2
)
SELECT history.public_response_id, COALESCE(history.parent_response_id, ''), history.gateway_task_id::text,
task.requested_model,
COALESCE(history.gateway_user_id::text, ''), history.user_id, history.user_source,
COALESCE(history.gateway_tenant_id::text, ''), COALESCE(history.tenant_id, ''), COALESCE(history.tenant_key, ''), COALESCE(history.api_key_id, ''),
history.platform_id::text, history.platform_model_id::text, history.client_id, history.upstream_protocol, history.upstream_endpoint,
COALESCE(history.upstream_response_id, ''), history.request_snapshot, history.response_snapshot, history.internal_snapshot,
history.created_at, history.expires_at
FROM history
JOIN gateway_tasks task ON task.id = history.gateway_task_id
ORDER BY depth DESC`, current.PublicResponseID, maxResponseChainHistory)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ResponseChain, 0)
for rows.Next() {
item, scanErr := s.scanResponseChain(rows)
if scanErr != nil {
return nil, scanErr
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) > maxResponseChainHistory {
return nil, ErrResponseChainTooDeep
}
return out, nil
}
type responseChainScanner interface {
Scan(dest ...any) error
}
func (s *Store) scanResponseChain(scanner responseChainScanner) (ResponseChain, error) {
var item ResponseChain
var requestJSON, responseJSON, internalJSON []byte
err := scanner.Scan(
&item.PublicResponseID, &item.ParentResponseID, &item.GatewayTaskID, &item.RequestedModel,
&item.GatewayUserID, &item.UserID, &item.UserSource,
&item.GatewayTenantID, &item.TenantID, &item.TenantKey, &item.APIKeyID,
&item.PlatformID, &item.PlatformModelID, &item.ClientID,
&item.UpstreamProtocol, &item.UpstreamEndpoint, &item.UpstreamResponseID,
&requestJSON, &responseJSON, &internalJSON, &item.CreatedAt, &item.ExpiresAt,
)
if err != nil {
return ResponseChain{}, err
}
item.RequestSnapshot = decodeObject(requestJSON)
item.ResponseSnapshot = decodeObject(responseJSON)
item.InternalSnapshot = decodeObject(internalJSON)
return item, nil
}
func sameResponseChainOwner(chain ResponseChain, task GatewayTask) bool {
if chain.GatewayUserID != "" || task.GatewayUserID != "" {
if chain.GatewayUserID == "" || chain.GatewayUserID != task.GatewayUserID {
return false
}
} else if chain.UserID != task.UserID || chain.UserSource != task.UserSource {
return false
}
return chain.GatewayTenantID == task.GatewayTenantID &&
chain.TenantID == task.TenantID &&
chain.TenantKey == task.TenantKey
}

View File

@ -0,0 +1,36 @@
package store
import "testing"
func TestSameResponseChainOwnerIsolatesUserAndTenant(t *testing.T) {
chain := ResponseChain{
GatewayUserID: "user-1", GatewayTenantID: "tenant-1", TenantID: "external-tenant", TenantKey: "tenant-key",
}
owner := GatewayTask{
GatewayUserID: "user-1", GatewayTenantID: "tenant-1", TenantID: "external-tenant", TenantKey: "tenant-key",
}
if !sameResponseChainOwner(chain, owner) {
t.Fatal("matching user and tenant should own the response chain")
}
owner.GatewayUserID = "user-2"
if sameResponseChainOwner(chain, owner) {
t.Fatal("another user must not access the response chain")
}
owner.GatewayUserID = "user-1"
owner.GatewayTenantID = "tenant-2"
if sameResponseChainOwner(chain, owner) {
t.Fatal("another tenant must not access the response chain")
}
}
func TestSameResponseChainOwnerSupportsExternalIdentityFallback(t *testing.T) {
chain := ResponseChain{UserID: "external-user", UserSource: "server-main", TenantID: "tenant-a"}
owner := GatewayTask{UserID: "external-user", UserSource: "server-main", TenantID: "tenant-a"}
if !sameResponseChainOwner(chain, owner) {
t.Fatal("matching external identity should own the response chain")
}
owner.UserSource = "gateway"
if sameResponseChainOwner(chain, owner) {
t.Fatal("identity source must participate in isolation")
}
}

View File

@ -167,6 +167,7 @@ type RuntimeModelCandidate struct {
RunningCount float64
WaitingCount float64
LastAssignedUnix float64
ResponseProtocol string
}
type RuntimeCandidateCacheAffinity struct {

View File

@ -0,0 +1,273 @@
CREATE TABLE IF NOT EXISTS gateway_response_chains (
public_response_id text PRIMARY KEY,
parent_response_id text REFERENCES gateway_response_chains(public_response_id) ON DELETE RESTRICT,
gateway_task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
user_id text NOT NULL,
user_source text NOT NULL DEFAULT 'gateway',
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
tenant_id text,
tenant_key text,
api_key_id text,
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE RESTRICT,
platform_model_id uuid NOT NULL REFERENCES platform_models(id) ON DELETE RESTRICT,
client_id text NOT NULL,
upstream_protocol text NOT NULL,
upstream_endpoint text NOT NULL,
upstream_response_id text,
request_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
response_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
internal_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
CONSTRAINT gateway_response_chains_public_id_check
CHECK (public_response_id ~ '^resp_[A-Za-z0-9_-]+$'),
CONSTRAINT gateway_response_chains_protocol_check
CHECK (upstream_protocol IN ('openai_chat_completions', 'openai_responses'))
);
ALTER TABLE gateway_response_chains
DROP CONSTRAINT IF EXISTS gateway_response_chains_public_id_check;
ALTER TABLE gateway_response_chains
ADD CONSTRAINT gateway_response_chains_public_id_check
CHECK (public_response_id ~ '^resp_[A-Za-z0-9_-]+$');
CREATE INDEX IF NOT EXISTS idx_gateway_response_chains_owner
ON gateway_response_chains(gateway_user_id, user_id, gateway_tenant_id, tenant_id, tenant_key);
CREATE INDEX IF NOT EXISTS idx_gateway_response_chains_expiry
ON gateway_response_chains(expires_at);
CREATE INDEX IF NOT EXISTS idx_gateway_response_chains_platform_model
ON gateway_response_chains(platform_model_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_gateway_response_chains_upstream_response
ON gateway_response_chains(upstream_response_id)
WHERE upstream_response_id IS NOT NULL;
-- Keep capability data provider-specific. A missing supportedApiProtocols field
-- intentionally remains legacy-compatible and means Chat Completions only.
UPDATE base_model_catalog
SET capabilities = jsonb_set(
capabilities,
'{text_generate,supportedApiProtocols}',
'["openai_chat_completions","openai_responses"]'::jsonb,
true
),
default_snapshot = CASE
WHEN COALESCE(default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN default_snapshot
ELSE jsonb_set(
default_snapshot,
'{capabilities,text_generate,supportedApiProtocols}',
'["openai_chat_completions","openai_responses"]'::jsonb,
true
)
END,
updated_at = now()
WHERE provider_key = 'volces-openai'
AND provider_model_name = 'doubao-seed-2-0-pro-260215';
WITH provider AS (
SELECT id FROM model_catalog_providers
WHERE provider_key = 'aliyun-bailian-openai' OR provider_code = 'aliyun-bailian-openai'
LIMIT 1
), template AS (
SELECT * FROM base_model_catalog
WHERE provider_key = 'aliyun-bailian-openai'
AND provider_model_name IN ('qwen3.7-max', 'qwen3.7-plus')
ORDER BY CASE WHEN provider_model_name = 'qwen3.7-plus' THEN 0 ELSE 1 END
LIMIT 1
), payload AS (
SELECT
provider.id AS provider_id,
'aliyun-bailian-openai'::text AS provider_key,
'aliyun-bailian-openai:qwen3.7-plus'::text AS canonical_model_key,
'qwen3.7-plus'::text AS provider_model_name,
'["text_generate","tools_call"]'::jsonb AS model_type,
'Qwen3.7-Plus'::text AS display_name,
jsonb_set(
COALESCE(template.capabilities, '{"originalTypes":["text_generate","tools_call"],"text_generate":{"supportTool":true},"tools_call":{"supportTool":true}}'::jsonb),
'{text_generate,supportedApiProtocols}',
'["openai_chat_completions","openai_responses"]'::jsonb,
true
) AS capabilities,
COALESCE(template.base_billing_config, '{}'::jsonb) AS base_billing_config,
COALESCE(template.default_rate_limit_policy, '{}'::jsonb) AS default_rate_limit_policy,
COALESCE(template.metadata, '{}'::jsonb) || jsonb_build_object(
'alias', 'Qwen3.7-Plus',
'selectable', true,
'sourceProviderCode', 'aliyun-bailian-openai'
) AS metadata,
COALESCE(template.pricing_version, 1) AS pricing_version
FROM provider
LEFT JOIN template ON true
), snapshot AS (
SELECT payload.*,
jsonb_build_object(
'providerKey', provider_key,
'canonicalModelKey', canonical_model_key,
'providerModelName', provider_model_name,
'modelType', model_type,
'modelAlias', display_name,
'capabilities', capabilities,
'baseBillingConfig', base_billing_config,
'defaultRateLimitPolicy', default_rate_limit_policy,
'metadata', metadata,
'pricingVersion', pricing_version,
'status', 'active'
) AS default_snapshot
FROM payload
)
INSERT INTO base_model_catalog (
provider_id, provider_key, canonical_model_key, provider_model_name, model_type,
display_name, capabilities, base_billing_config, default_rate_limit_policy,
metadata, catalog_type, default_snapshot, pricing_version, status
)
SELECT provider_id, provider_key, canonical_model_key, provider_model_name, model_type,
display_name, capabilities, base_billing_config, default_rate_limit_policy,
metadata, 'system', default_snapshot, pricing_version, 'active'
FROM snapshot
ON CONFLICT (canonical_model_key) DO UPDATE
SET provider_id = EXCLUDED.provider_id,
provider_key = EXCLUDED.provider_key,
provider_model_name = EXCLUDED.provider_model_name,
model_type = EXCLUDED.model_type,
display_name = EXCLUDED.display_name,
capabilities = jsonb_set(
base_model_catalog.capabilities,
'{text_generate,supportedApiProtocols}',
'["openai_chat_completions","openai_responses"]'::jsonb,
true
),
default_snapshot = EXCLUDED.default_snapshot,
status = 'active',
updated_at = now();
INSERT INTO model_catalog_providers (
provider_key, provider_code, provider_type, display_name, icon_path, source,
capability_schema, default_rate_limit_policy, metadata, status
) VALUES (
'deepseek-openai', 'deepseek-openai', 'openai', 'DeepSeek',
'https://static.51easyai.com/deepseek-color%20%281%29.webp',
'gateway.responses-protocol-migration', '{}'::jsonb, '{}'::jsonb,
'{"seed":"responses-protocols","sourceSpecType":"openai"}'::jsonb, 'active'
)
ON CONFLICT (provider_key) DO UPDATE
SET provider_code = EXCLUDED.provider_code,
provider_type = EXCLUDED.provider_type,
display_name = EXCLUDED.display_name,
icon_path = EXCLUDED.icon_path,
metadata = model_catalog_providers.metadata || EXCLUDED.metadata,
status = 'active',
updated_at = now();
WITH provider AS (
SELECT id FROM model_catalog_providers
WHERE provider_key = 'deepseek-openai' OR provider_code = 'deepseek-openai'
LIMIT 1
), template AS (
SELECT * FROM base_model_catalog
WHERE provider_model_name = 'deepseek-v4-pro'
ORDER BY CASE WHEN provider_key = 'deepseek-openai' THEN 0 ELSE 1 END
LIMIT 1
), payload AS (
SELECT
provider.id AS provider_id,
'deepseek-openai'::text AS provider_key,
'deepseek-openai:deepseek-v4-pro'::text AS canonical_model_key,
'deepseek-v4-pro'::text AS provider_model_name,
'["text_generate","tools_call"]'::jsonb AS model_type,
'DeepSeek-V4-Pro'::text AS display_name,
jsonb_set(
COALESCE(template.capabilities, '{"originalTypes":["text_generate","tools_call"],"text_generate":{"supportTool":true},"tools_call":{"supportTool":true}}'::jsonb),
'{text_generate,supportedApiProtocols}',
'["openai_chat_completions","anthropic_messages"]'::jsonb,
true
) AS capabilities,
COALESCE(template.base_billing_config, '{}'::jsonb) AS base_billing_config,
COALESCE(template.default_rate_limit_policy, '{}'::jsonb) AS default_rate_limit_policy,
COALESCE(template.metadata, '{}'::jsonb) || jsonb_build_object(
'alias', 'DeepSeek-V4-Pro',
'selectable', true,
'sourceProviderCode', 'deepseek-openai',
'sourceProviderName', 'DeepSeek 官网'
) AS metadata,
COALESCE(template.pricing_version, 1) AS pricing_version
FROM provider
LEFT JOIN template ON true
), snapshot AS (
SELECT payload.*,
jsonb_build_object(
'providerKey', provider_key,
'canonicalModelKey', canonical_model_key,
'providerModelName', provider_model_name,
'modelType', model_type,
'modelAlias', display_name,
'capabilities', capabilities,
'baseBillingConfig', base_billing_config,
'defaultRateLimitPolicy', default_rate_limit_policy,
'metadata', metadata,
'pricingVersion', pricing_version,
'status', 'active'
) AS default_snapshot
FROM payload
)
INSERT INTO base_model_catalog (
provider_id, provider_key, canonical_model_key, provider_model_name, model_type,
display_name, capabilities, base_billing_config, default_rate_limit_policy,
metadata, catalog_type, default_snapshot, pricing_version, status
)
SELECT provider_id, provider_key, canonical_model_key, provider_model_name, model_type,
display_name, capabilities, base_billing_config, default_rate_limit_policy,
metadata, 'system', default_snapshot, pricing_version, 'active'
FROM snapshot
ON CONFLICT (canonical_model_key) DO UPDATE
SET provider_id = EXCLUDED.provider_id,
provider_key = EXCLUDED.provider_key,
provider_model_name = EXCLUDED.provider_model_name,
model_type = EXCLUDED.model_type,
display_name = EXCLUDED.display_name,
capabilities = jsonb_set(
base_model_catalog.capabilities,
'{text_generate,supportedApiProtocols}',
'["openai_chat_completions","anthropic_messages"]'::jsonb,
true
),
default_snapshot = EXCLUDED.default_snapshot,
status = 'active',
updated_at = now();
UPDATE platform_models model
SET base_model_id = base.id,
model_name = base.provider_model_name,
provider_model_name = CASE
WHEN platform.provider = 'volces-openai' THEN 'doubao-seed-2-0-pro-260215'
WHEN platform.provider = 'aliyun-bailian-openai' THEN 'qwen3.7-plus'
WHEN platform.provider = 'deepseek-openai' THEN 'deepseek-v4-pro'
ELSE model.provider_model_name
END,
model_alias = CASE
WHEN platform.provider = 'volces-openai' THEN 'Doubao Seed 2.0 Pro'
WHEN platform.provider = 'aliyun-bailian-openai' THEN 'Qwen3.7-Plus'
WHEN platform.provider = 'deepseek-openai' THEN 'DeepSeek-V4-Pro'
END,
display_name = CASE
WHEN platform.provider = 'volces-openai' THEN 'Doubao Seed 2.0 Pro'
WHEN platform.provider = 'aliyun-bailian-openai' THEN 'Qwen3.7-Plus'
WHEN platform.provider = 'deepseek-openai' THEN 'DeepSeek-V4-Pro'
END,
model_type = '["text_generate","tools_call"]'::jsonb,
enabled = true,
updated_at = now()
FROM integration_platforms platform, base_model_catalog base
WHERE model.platform_id = platform.id
AND (
(platform.provider = 'volces-openai'
AND base.canonical_model_key = 'volces-openai:doubao-seed-2-0-pro-260215'
AND (model.model_name = 'doubao-seed-2-0-pro-260215' OR model.provider_model_name = 'doubao-seed-2-0-pro-260215' OR model.model_alias = 'Doubao Seed 2.0 Pro'))
OR
(platform.provider = 'aliyun-bailian-openai'
AND base.canonical_model_key = 'aliyun-bailian-openai:qwen3.7-plus'
AND (model.model_name = 'qwen3.7-plus' OR model.provider_model_name = 'qwen3.7-plus' OR model.display_name = 'Qwen3.7-Plus'))
OR
(platform.provider = 'deepseek-openai'
AND base.canonical_model_key = 'deepseek-openai:deepseek-v4-pro'
AND (model.model_name = 'deepseek-v4-pro' OR model.provider_model_name = 'deepseek-v4-pro' OR model.display_name = 'DeepSeek-V4-Pro'))
);

View File

@ -33,6 +33,8 @@ type FieldDefinition = {
placeholder?: string;
type: FieldType;
scope?: FieldScope;
fixedOptions?: ValueOption[];
allowCustom?: boolean;
};
type ValueOption = { label: string; value: string };
@ -50,6 +52,19 @@ const textFields: FieldDefinition[] = [
{ key: 'thinkingEffortLevels', label: '推理深度', hint: '声明模型支持的 OpenAI reasoning_effort 取值,平台差异由网关适配', placeholder: 'none, minimal, low, medium, high, xhigh', type: 'list' },
];
const apiProtocolField: FieldDefinition = {
key: 'supportedApiProtocols',
label: '支持的接口规范',
hint: '字段缺失时按 OpenAI Chat 处理Responses 入口会优先选择原生 Responses 候选',
type: 'list',
allowCustom: false,
fixedOptions: [
{ value: 'openai_chat_completions', label: 'OpenAI Chat' },
{ value: 'openai_responses', label: 'OpenAI Responses' },
{ value: 'anthropic_messages', label: 'Anthropic' },
],
};
const embeddingFields: FieldDefinition[] = [
{ key: 'dimensions', label: '向量维度', placeholder: '1024, 768, 512', type: 'numberList' },
];
@ -481,7 +496,7 @@ function MultiValueControl(props: {
</label>
))}
</div>
<div className="capabilityCustomValue">
{props.field.allowCustom !== false ? <div className="capabilityCustomValue">
<Input
size="sm"
value={customValue}
@ -495,7 +510,7 @@ function MultiValueControl(props: {
}}
/>
<Button className="capabilityAddValueButton" type="button" size="sm" variant="outline" onClick={addCustom}></Button>
</div>
</div> : null}
</div>
);
}
@ -605,6 +620,7 @@ function defaultDirectValueForConfigKey(key: string) {
}
function multiOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ValueOption[] {
if (field.fixedOptions) return field.fixedOptions;
let options: string[] = [];
if (field.key === 'output_resolutions') options = field.scope ? videoResolutionOptions : imageResolutionOptions;
if (field.key === 'aspect_ratio_allowed') options = field.scope ? videoAspectRatioOptions : imageAspectRatioOptions;
@ -699,7 +715,7 @@ function hasMeaningfulValue(value: unknown): boolean {
}
function capabilityFieldGroups(type: string): Array<{ title: string; icon: ReactNode; fields: FieldDefinition[] }> {
if (isTextType(type)) return [{ title: '文本默认能力', icon: <Brain size={15} />, fields: textFields }];
if (isTextType(type)) return [{ title: '文本默认能力', icon: <Brain size={15} />, fields: type === 'text_generate' ? [apiProtocolField, ...textFields] : textFields }];
if (type === 'text_embedding') return [{ title: '向量默认能力', icon: <Brain size={15} />, fields: embeddingFields }];
if (isImageType(type)) return [{ title: '图像默认能力', icon: <Image size={15} />, fields: imageFieldsFor(type) }];
if (isVideoType(type)) return [{ title: '视频默认能力', icon: <Video size={15} />, fields: videoFieldsFor(type) }];
@ -736,6 +752,13 @@ function summarizeEnabledCapabilities(types: string[], typeConfigs: Record<strin
function capabilitySummaryHighlights(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
const highlights: string[] = [];
const protocolLabels: Record<string, string> = {
openai_chat_completions: 'Chat',
openai_responses: 'Responses',
anthropic_messages: 'Anthropic',
};
const protocols = uniqueStrings(types.flatMap((type) => flattenStringValues(typeConfigs[type]?.supportedApiProtocols)));
if (protocols.length) highlights.push(`接口 ${protocols.map((item) => protocolLabels[item] ?? item).join('/')}`);
const textLimits = uniqueStrings(types.flatMap((type) => stringValue(typeConfigs[type]?.max_context_tokens ? typeConfigs[type].max_context_tokens : '').split(',').filter(Boolean)));
if (textLimits.length) highlights.push(`上下文 ${textLimits[0]} Token`);

View File

@ -126,6 +126,7 @@ const managedRootKeys = new Set<string>([
]);
const managedNestedKeys = new Set<string>([
'supportedApiProtocols',
'supportTool',
'supportThinking',
'supportThinkingModeSwitch',
@ -275,6 +276,7 @@ export function capabilitiesFromForm(state: CapabilityEditorState): Record<strin
export function defaultCapabilityConfig(type: string): Record<string, unknown> {
if (isTextLike(type)) {
return {
...(type === 'text_generate' ? { supportedApiProtocols: ['openai_chat_completions'] } : {}),
supportTool: type === 'tools_call',
supportThinking: false,
supportThinkingModeSwitch: false,
@ -398,7 +400,7 @@ function shouldKeepNestedValue(key: string, value: unknown) {
if (key === 'output_resolutions' || key === 'aspect_ratio_allowed' || key === 'aspect_ratio_range' || key === 'input_aspect_ratio_range' || key === 'duration_range') {
return !Array.isArray(value);
}
if (key === 'thinkingEffortLevels' || key === 'dimensions') {
if (key === 'thinkingEffortLevels' || key === 'dimensions' || key === 'supportedApiProtocols') {
return !Array.isArray(value);
}
if (key.endsWith('_count') || key === 'output_max_size' || key.endsWith('_tokens')) {

File diff suppressed because it is too large Load Diff

View File

@ -755,6 +755,16 @@ export interface ModelCatalogSource {
rateLimits: ModelCatalogRateLimits;
}
export type SupportedTextApiProtocol =
| 'openai_chat_completions'
| 'openai_responses'
| 'anthropic_messages';
export interface TextGenerateCapability {
supportedApiProtocols?: SupportedTextApiProtocol[];
[key: string]: unknown;
}
export interface ModelCatalogItem {
id: string;
alias: string;

259
scripts/responses-e2e.mjs Normal file
View File

@ -0,0 +1,259 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { execFileSync } from 'node:child_process';
const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, '');
const authentication = resolveGatewayAPIKey();
const modelCases = [
{
model: process.env.GATEWAY_E2E_DOUBAO_MODEL || 'Doubao Seed 2.0 Pro',
platform: '火山',
protocol: 'openai_responses',
converted: false,
},
{
model: process.env.GATEWAY_E2E_QWEN_MODEL || 'Qwen3.7-Plus',
platform: '百炼',
protocol: 'openai_responses',
converted: false,
},
{
model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro',
platform: 'DeepSeek 官网',
protocol: 'openai_chat_completions',
converted: true,
},
];
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function resolveGatewayAPIKey() {
if (process.env.GATEWAY_E2E_API_KEY) {
return { value: process.env.GATEWAY_E2E_API_KEY, source: 'environment', keyId: null };
}
const container = process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres';
const database = process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway';
const user = process.env.GATEWAY_E2E_DB_USER || 'easyai';
const query = `SELECT key.id::text || E'\\t' || key.key_secret FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.status = 'active' WHERE key.deleted_at IS NULL AND key.status = 'active' AND key.key_secret IS NOT NULL AND key.key_secret <> '' AND key.scopes ? 'chat' AND (key.expires_at IS NULL OR key.expires_at > now()) AND (wallet.balance - wallet.frozen_balance) > 0 ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC LIMIT 1`;
let output = '';
try {
output = execFileSync('docker', ['exec', container, 'psql', '-U', user, '-d', database, '-At', '-c', query], { encoding: 'utf8' }).trim();
} catch {
throw new Error('Unable to load an active chat-scoped Gateway API Key from the database; set GATEWAY_E2E_API_KEY explicitly');
}
const separator = output.indexOf('\t');
if (separator <= 0 || separator === output.length - 1) {
throw new Error('The database does not contain an active chat-scoped Gateway API Key with a stored secret');
}
return { keyId: output.slice(0, separator), value: output.slice(separator + 1), source: 'database' };
}
async function requestJSON(token, path, body) {
const response = await fetch(`${baseURL}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) throw new Error(`POST ${path} failed ${response.status}: ${text}`);
return { body: JSON.parse(text), taskId: response.headers.get('x-gateway-task-id') };
}
async function requestStream(token, path, body) {
const response = await fetch(`${baseURL}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, stream: true }),
});
const text = await response.text();
if (!response.ok) throw new Error(`POST ${path} failed ${response.status}: ${text}`);
const events = parseSSE(text);
const errorEvent = events.find((event) => event.event === 'error');
if (errorEvent) throw new Error(`Responses stream failed: ${JSON.stringify(errorEvent.data)}`);
const completed = events.findLast((event) => event.event === 'response.completed');
assert(completed?.data?.response, `stream is missing response.completed: ${text}`);
validateEventOrder(events);
return { events, body: completed.data.response, taskId: response.headers.get('x-gateway-task-id') };
}
function parseSSE(text) {
return text.split(/\r?\n\r?\n/).flatMap((block) => {
if (!block.trim()) return [];
let event = 'message';
const data = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith('event:')) event = line.slice(6).trim();
if (line.startsWith('data:')) data.push(line.slice(5).trim());
}
if (!data.length) return [];
return [{ event, data: JSON.parse(data.join('\n')) }];
});
}
function validateEventOrder(events) {
const names = events.map((event) => event.event);
const created = names.indexOf('response.created');
const inProgress = names.indexOf('response.in_progress');
const completed = names.lastIndexOf('response.completed');
assert(created >= 0, `missing response.created: ${names.join(', ')}`);
assert(inProgress > created, `response.in_progress is out of order: ${names.join(', ')}`);
assert(completed > inProgress, `response.completed is out of order: ${names.join(', ')}`);
assert(completed === names.length - 1, `response.completed must be the final event: ${names.join(', ')}`);
assert(!names.includes('[DONE]'), 'Responses stream must not contain a Chat [DONE] event');
}
function responseText(response) {
if (typeof response.output_text === 'string') return response.output_text;
return (response.output || [])
.flatMap((item) => item.type === 'message' ? item.content || [] : [])
.filter((item) => item.type === 'output_text')
.map((item) => item.text || '')
.join('');
}
function functionCall(response) {
return (response.output || []).find((item) => item.type === 'function_call');
}
async function getTask(token, taskId) {
assert(taskId, 'response is missing X-Gateway-Task-Id');
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${token}` },
});
const text = await response.text();
if (!response.ok) throw new Error(`GET task failed ${response.status}: ${text}`);
return JSON.parse(text);
}
function auditSummary(task, expected) {
const metrics = task.metrics || {};
assert(String(metrics.platformName || '').includes(expected.platform), `${expected.model} used unexpected platform: ${metrics.platformName}`);
assert(metrics.upstreamProtocol === expected.protocol, `${expected.model} used ${metrics.upstreamProtocol}, expected ${expected.protocol}`);
assert(Boolean(metrics.responseConverted) === expected.converted, `${expected.model} responseConverted mismatch`);
assert(metrics.upstreamEndpoint === (expected.converted ? '/chat/completions' : '/responses'), `${expected.model} upstream endpoint mismatch`);
if (expected.converted) {
assert(metrics.publicResponseId === `resp_${String(task.id).replaceAll('-', '')}`, `${expected.model} Chat fallback id is not derived from the task id`);
} else {
assert(metrics.publicResponseId === metrics.upstreamResponseId, `${expected.model} native Responses id was not preserved`);
}
return {
taskId: task.id,
publicResponseId: metrics.publicResponseId,
upstreamResponseId: metrics.upstreamResponseId,
platform: metrics.platformName,
platformModelId: metrics.platformModelId,
protocol: metrics.upstreamProtocol,
upstreamEndpoint: metrics.upstreamEndpoint,
responseConverted: metrics.responseConverted,
parentResponseId: metrics.parentResponseId || null,
chainDepth: metrics.responseChainDepth,
usage: task.usage,
finalChargeAmount: task.finalChargeAmount,
billingSummary: task.billingSummary,
billings: task.billings,
};
}
const token = authentication.value;
const results = [];
for (const expected of modelCases) {
const nonce = `E2E-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
const first = await requestJSON(token, '/v1/responses', {
model: expected.model,
input: `请记住校验码 ${nonce}。只回复“已记住”。`,
store: true,
});
assert(/^resp_[A-Za-z0-9_-]+$/.test(first.body.id), `${expected.model} returned invalid response id`);
const second = await requestJSON(token, '/v1/responses', {
model: expected.model,
input: '我刚才让你记住的校验码是什么?只回复校验码。',
previous_response_id: first.body.id,
store: true,
});
assert(second.body.previous_response_id === first.body.id, `${expected.model} response did not preserve previous_response_id`);
assert(responseText(second.body).includes(nonce), `${expected.model} did not preserve ordinary conversation continuity`);
const toolOutput = `TOOL-${nonce}`;
const normalizedModel = expected.model.toLowerCase();
const requiresNonThinkingToolMode = normalizedModel.startsWith('qwen3.7-') || normalizedModel.includes('deepseek-v4-pro');
const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {};
const forcedToolChoice = 'required';
const toolFirst = await requestStream(token, '/v1/responses', {
model: expected.model,
input: '调用 lookup_verification_code 获取校验结果。',
tools: [{
type: 'function',
name: 'lookup_verification_code',
description: '返回测试校验结果',
parameters: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'], additionalProperties: false },
strict: true,
}],
tool_choice: forcedToolChoice,
parallel_tool_calls: true,
...toolReasoning,
store: true,
});
const call = functionCall(toolFirst.body);
assert(call?.call_id, `${expected.model} tool response is missing call_id`);
assert(toolFirst.events.some((event) => event.event === 'response.function_call_arguments.delta'), `${expected.model} stream is missing function arguments delta`);
const toolSecond = await requestStream(token, '/v1/responses', {
model: expected.model,
previous_response_id: toolFirst.body.id,
input: [{ type: 'function_call_output', call_id: call.call_id, output: JSON.stringify({ verification: toolOutput }) }],
...toolReasoning,
store: true,
});
assert(responseText(toolSecond.body).includes(toolOutput), `${expected.model} did not consume function_call_output`);
const auditedTasks = await Promise.all([
getTask(token, first.taskId),
getTask(token, second.taskId),
getTask(token, toolFirst.taskId),
getTask(token, toolSecond.taskId),
]);
const ordinaryFirstAudit = auditSummary(auditedTasks[0], expected);
const ordinarySecondAudit = auditSummary(auditedTasks[1], expected);
const toolFirstAudit = auditSummary(auditedTasks[2], expected);
const toolSecondAudit = auditSummary(auditedTasks[3], expected);
assert(ordinarySecondAudit.parentResponseId === first.body.id, `${expected.model} ordinary parent response id mismatch`);
assert(ordinarySecondAudit.chainDepth === 1, `${expected.model} ordinary chain depth mismatch`);
assert(toolSecondAudit.parentResponseId === toolFirst.body.id, `${expected.model} tool parent response id mismatch`);
assert(toolSecondAudit.chainDepth === 1, `${expected.model} tool chain depth mismatch`);
results.push({
model: expected.model,
ordinaryConversation: {
passed: true,
first: ordinaryFirstAudit,
second: ordinarySecondAudit,
},
toolCalling: {
passed: true,
callId: call.call_id,
first: toolFirstAudit,
second: toolSecondAudit,
firstEventTypes: toolFirst.events.map((event) => event.event),
secondEventTypes: toolSecond.events.map((event) => event.event),
},
});
}
const report = {
ok: true,
generatedAt: new Date().toISOString(),
baseURL,
authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId },
results,
};
const outputPath = process.env.GATEWAY_E2E_OUTPUT;
if (outputPath) {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
}
console.log(JSON.stringify(report, null, 2));