feat: add Responses API compatibility
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user