feat: add Responses API compatibility
This commit is contained in:
parent
b7351f3b9b
commit
8847d973a8
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
|
||||
|
||||
720
apps/api/internal/clients/responses_compat.go
Normal file
720
apps/api/internal/clients/responses_compat.go
Normal 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}
|
||||
}
|
||||
332
apps/api/internal/clients/responses_compat_test.go
Normal file
332
apps/api/internal/clients/responses_compat_test.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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 {
|
||||
|
||||
36
apps/api/internal/httpapi/responses_streaming_test.go
Normal file
36
apps/api/internal/httpapi/responses_streaming_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
256
apps/api/internal/runner/responses.go
Normal file
256
apps/api/internal/runner/responses.go
Normal 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()
|
||||
}
|
||||
129
apps/api/internal/runner/responses_test.go
Normal file
129
apps/api/internal/runner/responses_test.go
Normal 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}}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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, ''),
|
||||
|
||||
183
apps/api/internal/store/response_chains.go
Normal file
183
apps/api/internal/store/response_chains.go
Normal 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
|
||||
}
|
||||
36
apps/api/internal/store/response_chains_test.go
Normal file
36
apps/api/internal/store/response_chains_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -167,6 +167,7 @@ type RuntimeModelCandidate struct {
|
||||
RunningCount float64
|
||||
WaitingCount float64
|
||||
LastAssignedUnix float64
|
||||
ResponseProtocol string
|
||||
}
|
||||
|
||||
type RuntimeCandidateCacheAffinity struct {
|
||||
|
||||
273
apps/api/migrations/0059_responses_protocols_and_chains.sql
Normal file
273
apps/api/migrations/0059_responses_protocols_and_chains.sql
Normal 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'))
|
||||
);
|
||||
@ -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`);
|
||||
|
||||
|
||||
@ -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')) {
|
||||
|
||||
1200
artifacts/responses-e2e-20260710-api-key.json
Normal file
1200
artifacts/responses-e2e-20260710-api-key.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
259
scripts/responses-e2e.mjs
Normal 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));
|
||||
Loading…
Reference in New Issue
Block a user