From 8e98ad9cab7a664a8539283182dfeb020df9a280 Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 23:02:51 +0000 Subject: [PATCH 1/6] feat(providers): translate chat completions onto Responses-only upstreams --- docs/providers/chatgpt.mdx | 29 +- docs/providers/overview.mdx | 2 +- internal/providers/chat_via_responses.go | 337 ++++++++++ .../providers/chat_via_responses_input.go | 281 ++++++++ .../chat_via_responses_input_test.go | 370 +++++++++++ .../providers/chat_via_responses_output.go | 163 +++++ .../chat_via_responses_output_test.go | 353 +++++++++++ .../providers/chat_via_responses_stream.go | 572 +++++++++++++++++ .../chat_via_responses_stream_test.go | 475 ++++++++++++++ internal/providers/chat_via_responses_test.go | 598 ++++++++++++++++++ internal/providers/chatgpt/chatgpt.go | 28 +- internal/providers/chatgpt/chatgpt_test.go | 46 +- 12 files changed, 3228 insertions(+), 26 deletions(-) create mode 100644 internal/providers/chat_via_responses.go create mode 100644 internal/providers/chat_via_responses_input.go create mode 100644 internal/providers/chat_via_responses_input_test.go create mode 100644 internal/providers/chat_via_responses_output.go create mode 100644 internal/providers/chat_via_responses_output_test.go create mode 100644 internal/providers/chat_via_responses_stream.go create mode 100644 internal/providers/chat_via_responses_stream_test.go create mode 100644 internal/providers/chat_via_responses_test.go diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index f3b84d963..30a4a1ede 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -62,11 +62,25 @@ is not supported when using Codex with a ChatGPT account`. Codex sessions authenticated with an OpenAI API key, through the `openai` provider. -## Responses API only - -The Codex backend serves `/responses` and nothing else, so -`/v1/chat/completions` and `/v1/embeddings` answer `501` for `chatgpt` models. -Use `/v1/responses`, which is what Codex sends anyway. +## Supported surfaces + +The Codex backend serves `/responses` and nothing else. GoModel translates +`/v1/chat/completions` onto it — request, response, and streaming — so chat +clients work too. `/v1/embeddings` still answers `501`: the backend has no +embeddings endpoint. + +Chat requests are converted to Responses requests, executed upstream, and +converted back to chat completions. The returned `chatcmpl-` IDs are labels, +not resource handles: the backend pins `store: false` and rejects +`previous_response_id`, so no chaining is possible and chat clients resend +full history as usual. + +Chat parameters with no Responses equivalent are rejected with a `400` that +names the field, before any upstream call: `n` greater than 1, `logit_bias`, +`stop`, `seed`, `frequency_penalty`, `presence_penalty`, `logprobs`, +`top_logprobs`, `modalities`, `audio`, `web_search_options`, and +the deprecated `functions` / `function_call` pair. `prediction` is dropped +instead: it is a speed hint and never changes the answer. The backend also validates against a strict parameter allowlist. GoModel adapts requests rather than failing them, so callers keep using the standard @@ -79,6 +93,11 @@ Responses API: | `instructions`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`, `text`, `include` | Forwarded | | `temperature`, `top_p`, `max_output_tokens`, `previous_response_id`, `truncation`, `metadata`, `user`, `service_tier`, `top_logprobs` | Dropped — unsupported upstream | +Translated chat requests go through the same allowlist: `temperature`, +`top_p`, and `max_tokens` / `max_completion_tokens` map to valid Responses +fields and are then silently dropped upstream, exactly as for native Responses +requests. + Because the backend streams only, a non-streaming `POST /v1/responses` is served by streaming upstream and returning the final response object. Clients see a normal non-streaming response. See diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 055ac06c8..3d0f3f69b 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -44,7 +44,7 @@ support, not every individual model capability exposed by an upstream provider. | Provider | Credential | Example Model | Chat | `/responses` | Embed | Files | Batches | Passthru | Guide | | -------- | ---------- | ------------- | :--: | :----------: | :---: | :---: | :-----: | :------: | ----- | | OpenAI | `OPENAI_API_KEY` | `gpt-5.5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | -| ChatGPT subscription | `CHATGPT_API_KEY` (Codex sign-in token) | `gpt-5.6-sol` | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | [ChatGPT subscription](/providers/chatgpt) | +| ChatGPT subscription | `CHATGPT_API_KEY` (Codex sign-in token) | `gpt-5.6-sol` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [ChatGPT subscription](/providers/chatgpt) | | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | [Anthropic](/providers/anthropic) | | Cohere | `COHERE_API_KEY` | `command-a-plus-05-2026` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [Cohere](/providers/cohere) | | Google Gemini | `GEMINI_API_KEY` | `gemini-3.7-flash` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | [Google Gemini](/providers/gemini) | diff --git a/internal/providers/chat_via_responses.go b/internal/providers/chat_via_responses.go new file mode 100644 index 000000000..c63377be2 --- /dev/null +++ b/internal/providers/chat_via_responses.go @@ -0,0 +1,337 @@ +package providers + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +// ResponsesProvider is the minimal interface needed by the shared Chat-to-Responses adapter. +// Any provider that supports Responses and StreamResponses can use the +// ChatViaResponses and StreamChatViaResponses helpers to implement the Chat Completions API. +type ResponsesProvider interface { + Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) + StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) +} + +// ConvertChatRequestToResponses converts a ChatRequest to a ResponsesRequest. +// It validates the request first and returns an error naming the field when +// the request carries a parameter that has no Responses API equivalent. +func ConvertChatRequestToResponses(req *core.ChatRequest) (*core.ResponsesRequest, error) { + if req == nil { + return nil, core.NewInvalidRequestError("chat request is required", nil) + } + if err := validateChatRequestForResponsesTranslation(req); err != nil { + return nil, err + } + + tools, err := flattenChatToolsForResponses(req.Tools) + if err != nil { + return nil, err + } + toolChoice, err := flattenChatToolChoiceForResponses(req.ToolChoice) + if err != nil { + return nil, err + } + + responsesReq := &core.ResponsesRequest{ + Model: req.Model, + Provider: req.Provider, + Tools: tools, + ToolChoice: toolChoice, + ParallelToolCalls: req.ParallelToolCalls, + Temperature: req.Temperature, + TopP: req.TopP, + Stream: req.Stream, + Reasoning: req.Reasoning, + User: req.User, + ServiceTier: req.ServiceTier, + ExtraFields: core.CloneUnknownJSONFields(req.ExtraFields), + // Chat stream_options are not forwarded: Responses stream options + // differ, and include_usage only decides whether the stream converter + // emits a client-facing usage chunk. + } + + // prediction and n have no Responses equivalent: prediction is a pure + // speed hint and a validated n=1 is a no-op, so neither is rejected — + // but both are stripped from the translated request rather than leaking + // upstream as unknown fields. + responsesReq.ExtraFields = responsesReq.ExtraFields.Without("prediction", "n") + + // An explicit max_completion_tokens extra wins over the mapped max_tokens, + // mirroring the documented Bailian quirk. Both map to max_output_tokens. + if raw := responsesReq.ExtraFields.Lookup("max_completion_tokens"); raw != nil { + var maxCompletionTokens int + if err := json.Unmarshal(raw, &maxCompletionTokens); err == nil { + responsesReq.MaxOutputTokens = &maxCompletionTokens + responsesReq.ExtraFields = responsesReq.ExtraFields.Without("max_completion_tokens") + } + } + if responsesReq.MaxOutputTokens == nil && req.MaxTokens != nil { + responsesReq.MaxOutputTokens = req.MaxTokens + } + + // metadata is typed on ResponsesRequest but not on ChatRequest, so it + // arrives as an extra. Lift it onto the typed field; a malformed value + // stays in the extras as a passthrough instead of being dropped. + if raw := responsesReq.ExtraFields.Lookup("metadata"); raw != nil { + var metadata map[string]string + if err := json.Unmarshal(raw, &metadata); err == nil { + responsesReq.Metadata = metadata + responsesReq.ExtraFields = responsesReq.ExtraFields.Without("metadata") + } + } + + // response_format becomes text.format, the exact inverse of + // responsesTextFormatToChatResponseFormat. + if raw := responsesReq.ExtraFields.Lookup("response_format"); raw != nil { + text, err := chatResponseFormatToResponsesText(raw) + if err != nil { + return nil, err + } + responsesReq.Text = text + responsesReq.ExtraFields = responsesReq.ExtraFields.Without("response_format") + } + + input, instructions, err := ConvertMessagesToResponsesInput(req.Messages) + if err != nil { + return nil, err + } + responsesReq.Input = input + responsesReq.Instructions = instructions + + return responsesReq, nil +} + +// unsupportedChatResponsesTranslationExtraFields lists Chat Completions +// parameters with no Responses API equivalent. ChatRequest has no typed +// fields for them, so the request decoder always places them in ExtraFields; +// sweeping the extras covers both the typed and the untyped spelling. +var unsupportedChatResponsesTranslationExtraFields = []string{ + "logit_bias", + "stop", + "seed", + "frequency_penalty", + "presence_penalty", + // Chat logprobs booleans do not map: Responses logprobs need "include" + // values, and Responses-only upstreams such as Codex drop them. + "logprobs", + "top_logprobs", + "modalities", + "audio", + // prediction and n are absent from this list: Responses has no + // equivalent for either, so ConvertChatRequestToResponses strips them + // from the translated request (prediction is a pure speed hint, a + // validated n=1 a no-op) instead of rejecting them. + "web_search_options", + // Deprecated function_call/functions are superseded by tool_choice/tools. + "function_call", + "functions", +} + +func validateChatRequestForResponsesTranslation(req *core.ChatRequest) error { + if raw := req.ExtraFields.Lookup("n"); !core.IsJSONNull(raw) { + var n float64 + if err := json.Unmarshal(raw, &n); err != nil || n != 1 { + // Responses always returns a single output; n=1 is a harmless + // no-op, anything else cannot be honored. + return unsupportedChatResponsesTranslationField("n") + } + } + for _, field := range unsupportedChatResponsesTranslationExtraFields { + // An explicit JSON null spells "not set" on the wire, so it is + // tolerated like an absent field rather than rejected. + if raw := req.ExtraFields.Lookup(field); !core.IsJSONNull(raw) { + return unsupportedChatResponsesTranslationField(field) + } + } + return nil +} + +func unsupportedChatResponsesTranslationField(field string) error { + return core.NewInvalidRequestError( + fmt.Sprintf("chat field %q is only supported by native Chat Completions providers; use an OpenAI-compatible provider or passthrough for this request", field), + nil, + ) +} + +// chatResponseFormatToResponsesText converts a Chat Completions response_format +// into the Responses "text" settings. Plain text yields nil (Responses +// default). Chat nests json_schema fields under a json_schema member, while +// the Responses API places them directly on the format object. +func chatResponseFormatToResponsesText(raw json.RawMessage) (any, error) { + var format map[string]any + if err := json.Unmarshal(raw, &format); err != nil { + return nil, unsupportedChatResponsesTranslationField("response_format") + } + + formatType, _ := format["type"].(string) + switch strings.TrimSpace(formatType) { + case "", "text": + return nil, nil + case "json_object": + return map[string]any{"format": map[string]any{"type": "json_object"}}, nil + case "json_schema": + jsonSchema, ok := format["json_schema"].(map[string]any) + if !ok { + return nil, unsupportedChatResponsesTranslationField("response_format") + } + flattened := make(map[string]any, len(jsonSchema)+1) + flattened["type"] = "json_schema" + for key, value := range jsonSchema { + flattened[key] = value + } + return map[string]any{"format": flattened}, nil + default: + return nil, unsupportedChatResponsesTranslationField("response_format") + } +} + +// flattenChatToolsForResponses flattens chat function tools +// ({type:"function", function:{...}}) into the Responses shape +// ({type:"function", name, ...}), the inverse of normalizeResponsesToolForChat. +// Non-function tools have no meaning on a chat request and are rejected. +func flattenChatToolsForResponses(tools []map[string]any) ([]map[string]any, error) { + if len(tools) == 0 { + return nil, nil + } + + flattened := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { + toolType, _ := tool["type"].(string) + if strings.TrimSpace(toolType) != "function" { + return nil, unsupportedChatResponsesTranslationField("tools") + } + flattened = append(flattened, flattenChatToolForResponses(tool)) + } + return flattened, nil +} + +func flattenChatToolForResponses(tool map[string]any) map[string]any { + if len(tool) == 0 { + return tool + } + + function, ok := tool["function"].(map[string]any) + if !ok { + // Already flat (Responses-shaped); pass through unchanged. + return cloneStringAnyMap(tool) + } + + flattened := cloneStringAnyMap(tool) + delete(flattened, "function") + for _, key := range []string{"name", "description", "parameters", "strict"} { + delete(flattened, key) + if value, ok := function[key]; ok { + flattened[key] = value + } + } + return flattened +} + +// flattenChatToolChoiceForResponses maps a chat tool_choice onto the Responses +// shape: strings pass through, and {type:"function", function:{name}} flattens +// to {type:"function", name}, the inverse of normalizeResponsesToolChoiceForChat. +func flattenChatToolChoiceForResponses(choice any) (any, error) { + if choice == nil { + return nil, nil + } + if choiceString, ok := choice.(string); ok { + return choiceString, nil + } + + choiceMap, ok := choice.(map[string]any) + if !ok { + return nil, unsupportedChatResponsesTranslationField("tool_choice") + } + choiceType, _ := choiceMap["type"].(string) + if strings.TrimSpace(choiceType) != "function" { + // Chat tool_choice objects only ever name a function; anything else + // cannot be honored on the translated path. + return nil, unsupportedChatResponsesTranslationField("tool_choice") + } + + function, ok := choiceMap["function"].(map[string]any) + if !ok { + // Already flat (Responses-shaped {type:"function", name}); pass through. + return cloneStringAnyMap(choiceMap), nil + } + + flattened := cloneStringAnyMap(choiceMap) + delete(flattened, "function") + if name, ok := function["name"]; ok { + flattened["name"] = name + } + return flattened, nil +} + +// ChatViaResponses implements the Chat Completions API by converting to/from Responses format. +// providerName attributes errors raised here, before the router stamps the +// response with its provider. +func ChatViaResponses(ctx context.Context, p ResponsesProvider, req *core.ChatRequest, providerName string) (*core.ChatResponse, error) { + responsesReq, err := ConvertChatRequestToResponses(req) + if err != nil { + return nil, err + } + + resp, err := p.Responses(ctx, responsesReq) + if err != nil { + return nil, err + } + if resp == nil { + return nil, core.NewEmptyProviderResponseError(providerName) + } + if resp.Status == "failed" || resp.Error != nil { + // A failed Responses generation has no honest chat completion + // shape, mirroring the stream converter's failed handling. + message := "provider reported a failed response" + var code string + if resp.Error != nil { + code = resp.Error.Code + if strings.TrimSpace(resp.Error.Message) != "" { + message = resp.Error.Message + } + } + providerErr := core.NewProviderError(providerName, http.StatusBadGateway, message, nil) + if code != "" { + providerErr = providerErr.WithCode(code) + } + return nil, providerErr + } + + chatResp := ConvertResponsesResponseToChat(resp) + if chatResp == nil || len(chatResp.Choices) == 0 { + return nil, core.NewNoChoicesProviderError(providerName) + } + return chatResp, nil +} + +// StreamChatViaResponses implements streaming Chat Completions API by converting to/from Responses format. +func StreamChatViaResponses(ctx context.Context, p ResponsesProvider, req *core.ChatRequest, providerName string) (io.ReadCloser, error) { + responsesReq, err := ConvertChatRequestToResponses(req) + if err != nil { + return nil, err + } + // The upstream call must stream: Responses-only providers emit SSE, and + // the converter below turns it into chat completion chunks. + responsesReq.Stream = true + + // stream_options is not forwarded; include_usage only decides whether the + // converter emits a client-facing usage chunk after the terminal event. + // The usage-enforcement flag forces the chunk, matching the mirror + // direction (StreamResponsesViaChat forces upstream include_usage). + includeUsage := (req.StreamOptions != nil && req.StreamOptions.IncludeUsage) || core.GetEnforceReturningUsageData(ctx) + + stream, err := p.StreamResponses(ctx, responsesReq) + if err != nil { + return nil, err + } + + return NewOpenAIChatStreamConverter(stream, req.Model, providerName, includeUsage), nil +} diff --git a/internal/providers/chat_via_responses_input.go b/internal/providers/chat_via_responses_input.go new file mode 100644 index 000000000..e06a3e4c2 --- /dev/null +++ b/internal/providers/chat_via_responses_input.go @@ -0,0 +1,281 @@ +package providers + +import ( + "fmt" + "strings" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +// ConvertMessagesToResponsesInput converts Chat Completions messages into a +// Responses API input array plus a top-level instructions string. It is the +// inverse of ConvertResponsesInputToMessages: system and developer messages +// become instructions (joined with a blank line), user and assistant messages +// become message items, assistant tool calls become function_call items, and +// tool results become function_call_output items. +// +// Message content parts are emitted with Responses spellings as generic +// blocks rather than core.ContentPart values: ContentPart marshals to the +// Chat Completions shape, rewriting "input_text" to "text", which the +// Responses API rejects (see chatgpt/request.go). +func ConvertMessagesToResponsesInput(messages []core.Message) (input any, instructions string, err error) { + items := make([]core.ResponsesInputElement, 0, len(messages)) + instructionParts := make([]string, 0, 1) + for i := range messages { + msg := messages[i] + switch msg.Role { + case "system", "developer": + if text := core.ExtractTextContent(msg.Content); strings.TrimSpace(text) != "" { + instructionParts = append(instructionParts, text) + } + case "assistant": + assistantItems, convErr := chatAssistantMessageToResponsesItems(msg) + if convErr != nil { + return nil, "", convErr + } + items = append(items, assistantItems...) + case "tool": + item, convErr := chatToolMessageToResponsesItem(msg) + if convErr != nil { + return nil, "", convErr + } + items = append(items, item) + default: + // "user" and any other role travel as a plain message item. + item, convErr := chatMessageToResponsesItem(msg, "input_text") + if convErr != nil { + return nil, "", convErr + } + items = append(items, item) + } + } + if len(items) == 0 { + return nil, strings.Join(instructionParts, "\n\n"), nil + } + return items, strings.Join(instructionParts, "\n\n"), nil +} + +// chatMessageToResponsesItem converts a plain (non-tool) chat message into a +// Responses message input item. textType is "input_text" for user content and +// "output_text" for assistant content, matching the Responses vocabulary for +// each role. +func chatMessageToResponsesItem(msg core.Message, textType string) (core.ResponsesInputElement, error) { + blocks, err := chatContentToResponsesBlocks(msg.Content, textType) + if err != nil { + return core.ResponsesInputElement{}, err + } + item := core.ResponsesInputElement{ + Type: "message", + Role: msg.Role, + ExtraFields: chatMessageExtraFieldsForResponses(msg.ExtraFields), + } + // An empty message carries an empty string, not an empty part: OpenAI + // never emits an empty text block. + if len(blocks) == 0 { + item.Content = "" + } else { + item.Content = blocks + } + return item, nil +} + +// chatAssistantMessageToResponsesItems converts an assistant message into a +// reasoning item (when replay state is present), a message item, and one +// function_call item per tool call, in that order. +func chatAssistantMessageToResponsesItems(msg core.Message) ([]core.ResponsesInputElement, error) { + items := make([]core.ResponsesInputElement, 0, len(msg.ToolCalls)+2) + if reasoning, ok := chatReasoningReplayItem(msg); ok { + items = append(items, reasoning) + } + // A tool-call-only turn must not gain a blank message item, mirroring + // buildResponsesMessageContent. + if core.ExtractTextContent(msg.Content) != "" || core.HasStructuredContent(msg.Content) || len(msg.ToolCalls) == 0 { + item, err := chatMessageToResponsesItem(msg, "output_text") + if err != nil { + return nil, err + } + items = append(items, item) + } + for _, call := range msg.ToolCalls { + callID := ResponsesFunctionCallCallID(call.ID) + items = append(items, core.ResponsesInputElement{ + Type: "function_call", + CallID: callID, + Name: call.Function.Name, + Arguments: call.Function.Arguments, + ExtraFields: toolCallExtraContent(call.ExtraFields), + }) + } + return items, nil +} + +// chatToolMessageToResponsesItem converts a tool-role message into a +// function_call_output item. Non-string content is stringified via JSON, +// mirroring stringifyResponsesInputValueWithError on the inbound path. +func chatToolMessageToResponsesItem(msg core.Message) (core.ResponsesInputElement, error) { + output, err := stringifyResponsesInputValueWithError(msg.Content) + if err != nil { + return core.ResponsesInputElement{}, core.NewInvalidRequestError( + "chat tool message content must be JSON-serializable for a Responses function_call_output item", + err, + ) + } + return core.ResponsesInputElement{ + Type: "function_call_output", + CallID: msg.ToolCallID, + Output: output, + ExtraFields: core.CloneUnknownJSONFields(msg.ExtraFields), + }, nil +} + +// chatReasoningReplayItem rebuilds the Responses reasoning item of an +// assistant turn when the message carries replay state in extra_content +// (attached by ConvertResponsesInputToMessages on the way in). Reasoning text +// alone is not replayable, so without replay state the turn emits no +// reasoning item. The item is emitted even when there is no readable +// reasoning text, because a redacted or encrypted reasoning block has none +// and still has to reach the upstream. +func chatReasoningReplayItem(msg core.Message) (core.ResponsesInputElement, bool) { + replayState := msg.ExtraFields.Lookup(core.ExtraContentField) + if core.IsJSONNull(replayState) { + return core.ResponsesInputElement{}, false + } + extra := map[string]json.RawMessage{ + "summary": json.RawMessage(`[]`), + core.ExtraContentField: replayState, + } + if text := chatMessageReasoningText(msg); text != "" { + content, err := json.Marshal([]map[string]string{{"type": "reasoning_text", "text": text}}) + if err == nil { + extra["content"] = content + } + } + return core.ResponsesInputElement{ + Type: "reasoning", + ExtraFields: core.UnknownJSONFieldsFromMap(extra), + }, true +} + +// chatMessageReasoningText returns the reasoning text recorded on a request +// message, accepting the same member spellings as the response side +// ("reasoning_content", then the vendor "reasoning" member). +func chatMessageReasoningText(msg core.Message) string { + for _, member := range []string{"reasoning_content", "reasoning"} { + raw := msg.ExtraFields.Lookup(member) + if len(raw) == 0 { + continue + } + var content string + if err := json.Unmarshal(raw, &content); err == nil && content != "" { + return content + } + } + return "" +} + +// chatMessageExtraFieldsForResponses strips the chat-only reasoning members +// consumed by the reasoning replay from a message's unknown fields; every +// other extension travels onto the Responses item unchanged. +func chatMessageExtraFieldsForResponses(fields core.UnknownJSONFields) core.UnknownJSONFields { + return fields.Without("reasoning_content", "reasoning", core.ExtraContentField) +} + +// chatContentToResponsesBlocks converts chat message content into Responses +// content blocks. A plain string becomes a single text block; structured +// parts keep their order. Unknown part types are rejected rather than coerced +// to text. +func chatContentToResponsesBlocks(content any, textType string) ([]any, error) { + switch c := content.(type) { + case nil: + return nil, nil + case string: + if c == "" { + return nil, nil + } + return []any{map[string]any{"type": textType, "text": c}}, nil + case []core.ContentPart: + return chatPartsToResponsesBlocks(c, textType) + case []any: + parts, ok := core.NormalizeContentParts(c) + if !ok { + return nil, nil + } + return chatPartsToResponsesBlocks(parts, textType) + default: + text := core.ExtractTextContent(content) + if text == "" { + return nil, nil + } + return []any{map[string]any{"type": textType, "text": text}}, nil + } +} + +// chatPartsToResponsesBlocks maps chat content parts onto their Responses +// spellings: "text" becomes the role-appropriate text type, "image_url" +// becomes "input_image", and "file" flattens its nested payload into the +// "input_file" members. Malformed parts of a known type are skipped, matching +// buildResponsesContentItemsFromParts; unknown part types are an error. +func chatPartsToResponsesBlocks(parts []core.ContentPart, textType string) ([]any, error) { + blocks := make([]any, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "text", "input_text", "output_text": + if part.Text == "" { + continue + } + blocks = append(blocks, map[string]any{"type": textType, "text": part.Text}) + case "image_url", "input_image": + if part.ImageURL == nil { + continue + } + url := strings.TrimSpace(part.ImageURL.URL) + if url == "" { + continue + } + block := map[string]any{"type": "input_image", "image_url": url} + if detail := strings.TrimSpace(part.ImageURL.Detail); detail != "" { + block["detail"] = detail + } + blocks = append(blocks, block) + case "input_audio": + if part.InputAudio == nil { + continue + } + data := strings.TrimSpace(part.InputAudio.Data) + format := strings.TrimSpace(part.InputAudio.Format) + if data == "" || format == "" { + continue + } + blocks = append(blocks, map[string]any{ + "type": "input_audio", + "input_audio": map[string]any{"data": data, "format": format}, + }) + case "file", "input_file": + if !core.ValidFilePayload(part.File) { + continue + } + block := map[string]any{"type": "input_file"} + if fileData := strings.TrimSpace(part.File.FileData); fileData != "" { + block["file_data"] = fileData + } + if fileURL := strings.TrimSpace(part.File.FileURL); fileURL != "" { + block["file_url"] = fileURL + } + if fileID := strings.TrimSpace(part.File.FileID); fileID != "" { + block["file_id"] = fileID + } + if filename := strings.TrimSpace(part.File.Filename); filename != "" { + block["filename"] = filename + } + blocks = append(blocks, block) + default: + return nil, core.NewInvalidRequestError( + fmt.Sprintf("chat message content part type %q is only supported by native chat providers; it has no Responses input equivalent", part.Type), + nil, + ) + } + } + return blocks, nil +} diff --git a/internal/providers/chat_via_responses_input_test.go b/internal/providers/chat_via_responses_input_test.go new file mode 100644 index 000000000..ecdd6ef3a --- /dev/null +++ b/internal/providers/chat_via_responses_input_test.go @@ -0,0 +1,370 @@ +package providers + +import ( + "net/http" + "strings" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/core" +) + +func responsesInputItems(t *testing.T, input any) []core.ResponsesInputElement { + t.Helper() + items, ok := input.([]core.ResponsesInputElement) + require.True(t, ok, "input must be []core.ResponsesInputElement, got %T", input) + return items +} + +func TestConvertMessagesToResponsesInput_RoleMapping(t *testing.T) { + tests := []struct { + name string + messages []core.Message + wantInstructions string + wantItems []core.ResponsesInputElement + }{ + { + name: "system message becomes instructions", + messages: []core.Message{ + {Role: "system", Content: "be terse"}, + {Role: "user", Content: "hi"}, + }, + wantInstructions: "be terse", + wantItems: []core.ResponsesInputElement{ + {Type: "message", Role: "user", Content: []any{map[string]any{"type": "input_text", "text": "hi"}}}, + }, + }, + { + name: "developer message becomes instructions", + messages: []core.Message{ + {Role: "developer", Content: "be nice"}, + }, + wantInstructions: "be nice", + }, + { + name: "multiple system messages join with a blank line", + messages: []core.Message{ + {Role: "system", Content: "one"}, + {Role: "developer", Content: "two"}, + {Role: "user", Content: "hi"}, + }, + wantInstructions: "one\n\ntwo", + wantItems: []core.ResponsesInputElement{ + {Type: "message", Role: "user", Content: []any{map[string]any{"type": "input_text", "text": "hi"}}}, + }, + }, + { + name: "system message with structured content joins text parts", + messages: []core.Message{ + {Role: "system", Content: []core.ContentPart{ + {Type: "text", Text: "one"}, + {Type: "text", Text: "two"}, + }}, + }, + wantInstructions: "one two", + }, + { + name: "assistant message becomes output_text message item", + messages: []core.Message{ + {Role: "assistant", Content: "hello"}, + }, + wantItems: []core.ResponsesInputElement{ + {Type: "message", Role: "assistant", Content: []any{map[string]any{"type": "output_text", "text": "hello"}}}, + }, + }, + { + name: "tool message becomes function_call_output item", + messages: []core.Message{ + {Role: "tool", ToolCallID: "call_1", Content: "sunny"}, + }, + wantItems: []core.ResponsesInputElement{ + {Type: "function_call_output", CallID: "call_1", Output: "sunny"}, + }, + }, + { + name: "empty message list", + messages: nil, + wantInstructions: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, instructions, err := ConvertMessagesToResponsesInput(tt.messages) + require.NoError(t, err) + assert.Equal(t, tt.wantInstructions, instructions) + if len(tt.wantItems) == 0 { + assert.Nil(t, input) + return + } + assert.Equal(t, tt.wantItems, responsesInputItems(t, input)) + }) + } +} + +func TestConvertMessagesToResponsesInput_MultimodalParts(t *testing.T) { + messages := []core.Message{ + {Role: "user", Content: []core.ContentPart{ + {Type: "text", Text: "what is this"}, + {Type: "image_url", ImageURL: &core.ImageURLContent{URL: "https://example.com/a.png", Detail: "low"}}, + {Type: "input_audio", InputAudio: &core.InputAudioContent{Data: "aGk=", Format: "wav"}}, + {Type: "file", File: &core.FileContent{FileURL: "https://example.com/doc.pdf", Filename: "doc.pdf"}}, + {Type: "file", File: &core.FileContent{FileID: "file-123"}}, + }}, + } + + input, instructions, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + assert.Equal(t, "", instructions) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + item := items[0] + assert.Equal(t, "message", item.Type) + assert.Equal(t, "user", item.Role) + + blocks, ok := item.Content.([]any) + require.True(t, ok, "content must be []any blocks, got %T", item.Content) + require.Len(t, blocks, 5) + + assert.Equal(t, map[string]any{"type": "input_text", "text": "what is this"}, blocks[0]) + assert.Equal(t, map[string]any{"type": "input_image", "image_url": "https://example.com/a.png", "detail": "low"}, blocks[1]) + assert.Equal(t, map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "aGk=", "format": "wav"}}, blocks[2]) + assert.Equal(t, map[string]any{"type": "input_file", "file_url": "https://example.com/doc.pdf", "filename": "doc.pdf"}, blocks[3]) + assert.Equal(t, map[string]any{"type": "input_file", "file_id": "file-123"}, blocks[4]) + + encoded, err := json.Marshal(input) + require.NoError(t, err) + assert.NotContains(t, string(encoded), `"type":"text"`, "chat spelling leaked into Responses input: %s", encoded) +} + +func TestConvertMessagesToResponsesInput_AssistantToolCalls(t *testing.T) { + messages := []core.Message{ + { + Role: "assistant", + Content: "let me check", + ToolCalls: []core.ToolCall{ + {ID: "call_1", Type: "function", Function: core.FunctionCall{Name: "get_weather", Arguments: `{"city":"Oslo"}`}}, + {ID: "call_2", Type: "function", Function: core.FunctionCall{Name: "get_time", Arguments: `{}`}}, + }, + }, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 3) + + // The message item comes first, then one function_call item per tool call + // in order. + assert.Equal(t, "message", items[0].Type) + assert.Equal(t, "assistant", items[0].Role) + assert.Equal(t, []any{map[string]any{"type": "output_text", "text": "let me check"}}, items[0].Content) + + assert.Equal(t, core.ResponsesInputElement{ + Type: "function_call", + CallID: "call_1", + Name: "get_weather", + Arguments: `{"city":"Oslo"}`, + }, items[1]) + assert.Equal(t, core.ResponsesInputElement{ + Type: "function_call", + CallID: "call_2", + Name: "get_time", + Arguments: `{}`, + }, items[2]) +} + +func TestConvertMessagesToResponsesInput_AssistantToolCallOnlyTurn(t *testing.T) { + messages := []core.Message{ + { + Role: "assistant", + Content: "", + ContentNull: true, + ToolCalls: []core.ToolCall{ + {ID: "call_9", Type: "function", Function: core.FunctionCall{Name: "ping", Arguments: `{}`}}, + }, + }, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + // A tool-call-only turn emits no blank message item. + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Equal(t, "function_call", items[0].Type) + assert.Equal(t, "call_9", items[0].CallID) +} + +func TestConvertMessagesToResponsesInput_MintsCallIDWhenEmpty(t *testing.T) { + messages := []core.Message{ + { + Role: "assistant", + Content: "", + ToolCalls: []core.ToolCall{ + {Type: "function", Function: core.FunctionCall{Name: "ping", Arguments: `{}`}}, + }, + }, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.True(t, strings.HasPrefix(items[0].CallID, "call_"), "minted call_id = %q", items[0].CallID) +} + +func TestConvertMessagesToResponsesInput_ToolOutputStringify(t *testing.T) { + tests := []struct { + name string + content core.MessageContent + wantOutput string + }{ + {name: "string passes through", content: "done", wantOutput: "done"}, + {name: "nil becomes empty", content: nil, wantOutput: ""}, + { + name: "structured parts stringify via JSON", + content: []core.ContentPart{{Type: "text", Text: "row"}}, + wantOutput: `[{"type":"text","text":"row"}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "tool", ToolCallID: "call_1", Content: tt.content}, + }) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Equal(t, "function_call_output", items[0].Type) + assert.Equal(t, "call_1", items[0].CallID) + assert.Equal(t, tt.wantOutput, items[0].Output) + }) + } +} + +func TestConvertMessagesToResponsesInput_ReasoningReplay(t *testing.T) { + replayState := json.RawMessage(`{"openai":{"encrypted_content":"abc"}}`) + messages := []core.Message{ + { + Role: "assistant", + Content: "answer", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_content": json.RawMessage(`"thinking hard"`), + core.ExtraContentField: replayState, + }), + }, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 2) + + // The reasoning item precedes the assistant message item and carries the + // replay state plus the reasoning text. + reasoning := items[0] + assert.Equal(t, "reasoning", reasoning.Type) + + encoded, err := json.Marshal(reasoning) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "reasoning", + "summary": [], + "content": [{"type": "reasoning_text", "text": "thinking hard"}], + "extra_content": {"openai": {"encrypted_content": "abc"}} + }`, string(encoded)) + + message := items[1] + assert.Equal(t, "message", message.Type) + assert.Equal(t, "assistant", message.Role) + // Consumed reasoning members must not leak onto the message item. + assert.True(t, message.ExtraFields.IsEmpty(), "message extras = %v", message.ExtraFields) +} + +func TestConvertMessagesToResponsesInput_ReasoningReplayWithoutText(t *testing.T) { + messages := []core.Message{ + { + Role: "assistant", + Content: "answer", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + core.ExtraContentField: json.RawMessage(`{"openai":{"encrypted_content":"abc"}}`), + }), + }, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + // Redacted reasoning has no readable text and still replays. + items := responsesInputItems(t, input) + require.Len(t, items, 2) + assert.Equal(t, "reasoning", items[0].Type) + + encoded, err := json.Marshal(items[0]) + require.NoError(t, err) + assert.NotContains(t, string(encoded), `"content"`) +} + +func TestConvertMessagesToResponsesInput_ReasoningTextWithoutReplayStateDropped(t *testing.T) { + messages := []core.Message{ + { + Role: "assistant", + Content: "answer", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_content": json.RawMessage(`"thinking hard"`), + }), + }, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + // Reasoning text alone is not replayable: no reasoning item is emitted. + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Equal(t, "message", items[0].Type) + assert.True(t, items[0].ExtraFields.IsEmpty(), "reasoning text must not leak onto the message item") +} + +func TestConvertMessagesToResponsesInput_RejectsUnknownPartType(t *testing.T) { + tests := []struct { + name string + messages []core.Message + wantName string + }{ + { + name: "video_url part on user message", + messages: []core.Message{ + {Role: "user", Content: []core.ContentPart{{Type: "video_url", VideoURL: &core.VideoURLContent{URL: "https://example.com/v.mp4"}}}}, + }, + wantName: "video_url", + }, + { + name: "unknown part on assistant message", + messages: []core.Message{ + {Role: "assistant", Content: []core.ContentPart{{Type: "hologram"}}}, + }, + wantName: "hologram", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput(tt.messages) + require.Error(t, err) + assert.Nil(t, input) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, tt.wantName) + }) + } +} diff --git a/internal/providers/chat_via_responses_output.go b/internal/providers/chat_via_responses_output.go new file mode 100644 index 000000000..b784ef2ac --- /dev/null +++ b/internal/providers/chat_via_responses_output.go @@ -0,0 +1,163 @@ +package providers + +import ( + "strings" + + "github.com/goccy/go-json" + + "github.com/google/uuid" + + "github.com/enterpilot/gomodel/internal/core" +) + +// ConvertResponsesResponseToChat converts a Responses API response into a +// chat completion, the inverse of ConvertChatResponseToResponses. The +// client-facing ID is minted ("chatcmpl-"); the upstream "resp_" ID is +// a Responses resource handle and must not leak onto the chat surface, where +// it would promise chaining the stateless chat API cannot honor. +// +// The whole output array collapses into one choice at index 0: message items +// contribute their output_text parts to content and refusal parts to the +// message's refusal member, function_call items become tool_calls, and +// reasoning items surface as reasoning_content (with their extra_content +// replay state preserved so the next translated request can echo it back). +// +// A response with status "failed" has no honest chat completion shape; the +// caller is expected to turn ResponsesError into an upstream error instead of +// converting, so the finish reason is left empty here. +func ConvertResponsesResponseToChat(resp *core.ResponsesResponse) *core.ChatResponse { + message := core.ResponseMessage{Role: "assistant"} + extra := map[string]json.RawMessage{} + + var texts []string + var refusals []string + var reasoning []string + for _, item := range resp.Output { + switch item.Type { + case "message": + for _, part := range item.Content { + switch part.Type { + case "output_text": + texts = append(texts, part.Text) + case "refusal": + refusals = append(refusals, part.Text) + } + } + case "function_call": + message.ToolCalls = append(message.ToolCalls, core.ToolCall{ + ID: ResponsesFunctionCallCallID(item.CallID), + Type: "function", + Function: core.FunctionCall{ + Name: item.Name, + Arguments: item.Arguments, + }, + // extra_content replay state rides along so the next + // translated request can echo it back. + ExtraFields: toolCallExtraContent(item.ExtraFields), + }) + case "reasoning": + if text := responsesOutputReasoningText(item); text != "" { + reasoning = append(reasoning, text) + } + if replay := item.ExtraFields.Lookup(core.ExtraContentField); !core.IsJSONNull(replay) { + extra[core.ExtraContentField] = replay + } + } + } + message.Content = strings.Join(texts, "") + if refusal := strings.Join(refusals, ""); refusal != "" { + extra["refusal"] = chatViaResponsesJSONString(refusal) + } + if text := strings.Join(reasoning, "\n\n"); text != "" { + extra["reasoning_content"] = chatViaResponsesJSONString(text) + } + message.ExtraFields = core.UnknownJSONFieldsFromMap(extra) + + converted := &core.ChatResponse{ + ID: "chatcmpl-" + uuid.New().String(), + Object: "chat.completion", + Created: resp.CreatedAt, + Model: resp.Model, + Provider: resp.Provider, + Choices: []core.Choice{{ + Index: 0, + Message: message, + FinishReason: responsesChatFinishReason(resp), + }}, + } + if resp.Usage != nil { + converted.Usage = core.Usage{ + PromptTokens: resp.Usage.InputTokens, + CompletionTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.TotalTokens, + PromptTokensDetails: resp.Usage.PromptTokensDetails, + CompletionTokensDetails: resp.Usage.CompletionTokensDetails, + RawUsage: resp.Usage.RawUsage, + } + } + return converted +} + +// responsesChatFinishReason maps the Responses status onto a chat finish +// reason, the inverse of ApplyResponsesFinishReason. Incomplete reasons +// without a chat equivalent ("max_messages", "steered", anything new) yield +// "" rather than an invented reason; "failed" and non-terminal statuses +// likewise yield "" and are the caller's to handle. +func responsesChatFinishReason(resp *core.ResponsesResponse) string { + switch resp.Status { + case "completed": + for _, item := range resp.Output { + if item.Type == "function_call" { + return "tool_calls" + } + } + return "stop" + case "incomplete": + if resp.IncompleteDetails == nil { + return "" + } + switch resp.IncompleteDetails.Reason { + case "max_output_tokens": + return "length" + case "content_filter": + return "content_filter" + default: + return "" + } + default: + return "" + } +} + +// responsesOutputReasoningText extracts readable reasoning from an output +// reasoning item. Summary parts are accepted as a fallback, mirroring the +// input-side responsesInputReasoning: some payloads carry only a summary. +// Encrypted-only reasoning stays opaque and is omitted. +func responsesOutputReasoningText(item core.ResponsesOutputItem) string { + texts := make([]string, 0, len(item.Content)) + for _, part := range item.Content { + if part.Type == "reasoning_text" && part.Text != "" { + texts = append(texts, part.Text) + } + } + if len(texts) > 0 { + return strings.Join(texts, "\n\n") + } + raw := item.ExtraFields.Lookup("summary") + if core.IsJSONNull(raw) { + return "" + } + var summary []responsesReasoningPart + if err := json.Unmarshal(raw, &summary); err != nil { + return "" + } + return reasoningTextParts(summary, "summary_text") +} + +func chatViaResponsesJSONString(s string) json.RawMessage { + raw, err := json.Marshal(s) + if err != nil { + return nil + } + return raw +} diff --git a/internal/providers/chat_via_responses_output_test.go b/internal/providers/chat_via_responses_output_test.go new file mode 100644 index 000000000..cf64c52d6 --- /dev/null +++ b/internal/providers/chat_via_responses_output_test.go @@ -0,0 +1,353 @@ +package providers + +import ( + "strings" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/core" +) + +func TestConvertResponsesResponseToChat_TextOnly(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Object: "response", + CreatedAt: 1730000000, + Model: "gpt-5", + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "msg_1", + Type: "message", + Role: "assistant", + Status: "completed", + Content: []core.ResponsesContentItem{ + {Type: "output_text", Text: "Hello, "}, + {Type: "output_text", Text: "world."}, + }, + }, + }, + } + + chat := ConvertResponsesResponseToChat(resp) + require.NotNil(t, chat) + require.Len(t, chat.Choices, 1) + + choice := chat.Choices[0] + assert.Equal(t, 0, choice.Index) + assert.Equal(t, "stop", choice.FinishReason) + assert.Equal(t, "assistant", choice.Message.Role) + assert.Equal(t, "Hello, world.", choice.Message.Content) + assert.Empty(t, choice.Message.ToolCalls) + assert.Equal(t, "gpt-5", chat.Model) + assert.Equal(t, int64(1730000000), chat.Created) + assert.Equal(t, "chat.completion", chat.Object) +} + +func TestConvertResponsesResponseToChat_ToolCalls(t *testing.T) { + tests := []struct { + name string + items []core.ResponsesOutputItem + want []core.ToolCall + }{ + { + name: "single", + items: []core.ResponsesOutputItem{ + { + ID: "fc_1", + Type: "function_call", + Status: "completed", + CallID: "call_abc", + Name: "lookup_weather", + Arguments: `{"city":"Warsaw"}`, + }, + }, + want: []core.ToolCall{{ + ID: "call_abc", + Type: "function", + Function: core.FunctionCall{Name: "lookup_weather", Arguments: `{"city":"Warsaw"}`}, + }}, + }, + { + name: "parallel calls keep output order", + items: []core.ResponsesOutputItem{ + { + ID: "fc_1", + Type: "function_call", + Status: "completed", + CallID: "call_1", + Name: "first", + Arguments: `{}`, + }, + { + ID: "fc_2", + Type: "function_call", + Status: "completed", + CallID: "call_2", + Name: "second", + Arguments: `{"x":1}`, + }, + }, + want: []core.ToolCall{ + {ID: "call_1", Type: "function", Function: core.FunctionCall{Name: "first", Arguments: `{}`}}, + {ID: "call_2", Type: "function", Function: core.FunctionCall{Name: "second", Arguments: `{"x":1}`}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: tt.items, + } + + chat := ConvertResponsesResponseToChat(resp) + require.Len(t, chat.Choices, 1) + + choice := chat.Choices[0] + assert.Equal(t, "tool_calls", choice.FinishReason) + assert.Equal(t, tt.want, choice.Message.ToolCalls) + assert.Equal(t, "", choice.Message.Content) + }) + } +} + +func TestConvertResponsesResponseToChat_ToolCallExtraContent(t *testing.T) { + replay := json.RawMessage(`{"openai":{"item_reference":"fc_1"}}`) + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "fc_1", + Type: "function_call", + Status: "completed", + CallID: "call_abc", + Name: "lookup_weather", + Arguments: `{"city":"Warsaw"}`, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + core.ExtraContentField: replay, + "upstream_trace": json.RawMessage(`"ignored"`), + }), + }, + }, + } + + chat := ConvertResponsesResponseToChat(resp) + require.Len(t, chat.Choices, 1) + require.Len(t, chat.Choices[0].Message.ToolCalls, 1) + + toolCall := chat.Choices[0].Message.ToolCalls[0] + got := toolCall.ExtraFields.Lookup(core.ExtraContentField) + require.NotEmpty(t, got, "extra_content replay state must land on the tool call") + assert.JSONEq(t, string(replay), string(got)) + assert.Nil(t, toolCall.ExtraFields.Lookup("upstream_trace"), "only extra_content is replay state") +} + +func TestConvertResponsesResponseToChat_Reasoning(t *testing.T) { + replay := json.RawMessage(`{"anthropic":{"thinking_blocks":[{"type":"thinking","signature":"sig-1"}]}}`) + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "rs_1", + Type: "reasoning", + Status: "completed", + Content: []core.ResponsesContentItem{ + {Type: "reasoning_text", Text: "first thought"}, + {Type: "reasoning_text", Text: "second thought"}, + }, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + core.ExtraContentField: replay, + "summary": json.RawMessage(`[]`), + }), + }, + { + ID: "msg_1", + Type: "message", + Role: "assistant", + Status: "completed", + Content: []core.ResponsesContentItem{{Type: "output_text", Text: "answer"}}, + }, + }, + } + + chat := ConvertResponsesResponseToChat(resp) + require.Len(t, chat.Choices, 1) + message := chat.Choices[0].Message + + assert.Equal(t, "answer", message.Content) + + raw := message.ExtraFields.Lookup("reasoning_content") + require.NotEmpty(t, raw, "reasoning item must surface as reasoning_content") + var reasoning string + require.NoError(t, json.Unmarshal(raw, &reasoning)) + assert.Equal(t, "first thought\n\nsecond thought", reasoning) + + gotReplay := message.ExtraFields.Lookup(core.ExtraContentField) + assert.JSONEq(t, string(replay), string(gotReplay), "replay state must survive for the next translated request") +} + +func TestConvertResponsesResponseToChat_ReasoningSummaryFallback(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "rs_1", + Type: "reasoning", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "summary": json.RawMessage(`[{"type":"summary_text","text":"thought briefly"}]`), + }), + }, + }, + } + + chat := ConvertResponsesResponseToChat(resp) + require.Len(t, chat.Choices, 1) + + raw := chat.Choices[0].Message.ExtraFields.Lookup("reasoning_content") + require.NotEmpty(t, raw) + var reasoning string + require.NoError(t, json.Unmarshal(raw, &reasoning)) + assert.Equal(t, "thought briefly", reasoning) +} + +func TestConvertResponsesResponseToChat_Refusal(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "msg_1", + Type: "message", + Role: "assistant", + Status: "completed", + Content: []core.ResponsesContentItem{ + {Type: "refusal", Text: "I cannot help with that."}, + }, + }, + }, + } + + chat := ConvertResponsesResponseToChat(resp) + require.Len(t, chat.Choices, 1) + message := chat.Choices[0].Message + + assert.Equal(t, "", message.Content) + raw := message.ExtraFields.Lookup("refusal") + require.NotEmpty(t, raw, "refusal part must surface as the message refusal member") + var refusal string + require.NoError(t, json.Unmarshal(raw, &refusal)) + assert.Equal(t, "I cannot help with that.", refusal) +} + +func TestConvertResponsesResponseToChat_FinishReason(t *testing.T) { + functionCall := core.ResponsesOutputItem{ + ID: "fc_1", + Type: "function_call", + Status: "completed", + CallID: "call_1", + Name: "f", + Arguments: `{}`, + } + + tests := []struct { + name string + status string + reason *core.ResponsesIncompleteDetails + output []core.ResponsesOutputItem + expected string + }{ + {name: "completed text", status: "completed", expected: "stop"}, + {name: "completed with function call", status: "completed", output: []core.ResponsesOutputItem{functionCall}, expected: "tool_calls"}, + { + name: "incomplete max_output_tokens", + status: "incomplete", + reason: &core.ResponsesIncompleteDetails{Reason: "max_output_tokens"}, + expected: "length", + }, + { + name: "incomplete content_filter", + status: "incomplete", + reason: &core.ResponsesIncompleteDetails{Reason: "content_filter"}, + expected: "content_filter", + }, + { + name: "incomplete unknown reason stays empty", + status: "incomplete", + reason: &core.ResponsesIncompleteDetails{Reason: "max_messages"}, + expected: "", + }, + {name: "incomplete without details", status: "incomplete", expected: ""}, + {name: "failed stays empty for the caller", status: "failed", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: tt.status, + IncompleteDetails: tt.reason, + Output: tt.output, + } + + chat := ConvertResponsesResponseToChat(resp) + require.Len(t, chat.Choices, 1) + assert.Equal(t, tt.expected, chat.Choices[0].FinishReason) + }) + } +} + +func TestConvertResponsesResponseToChat_Usage(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Usage: &core.ResponsesUsage{ + InputTokens: 12, + OutputTokens: 7, + TotalTokens: 19, + PromptTokensDetails: &core.PromptTokensDetails{CachedTokens: 5}, + CompletionTokensDetails: &core.CompletionTokensDetails{ReasoningTokens: 3}, + }, + } + + chat := ConvertResponsesResponseToChat(resp) + require.NotNil(t, chat) + + assert.Equal(t, 12, chat.Usage.PromptTokens) + assert.Equal(t, 7, chat.Usage.CompletionTokens) + assert.Equal(t, 19, chat.Usage.TotalTokens) + require.NotNil(t, chat.Usage.PromptTokensDetails) + assert.Equal(t, 5, chat.Usage.PromptTokensDetails.CachedTokens) + require.NotNil(t, chat.Usage.CompletionTokensDetails) + assert.Equal(t, 3, chat.Usage.CompletionTokensDetails.ReasoningTokens) +} + +func TestConvertResponsesResponseToChat_WithoutUsage(t *testing.T) { + resp := &core.ResponsesResponse{ID: "resp_upstream", Status: "completed"} + + chat := ConvertResponsesResponseToChat(resp) + require.NotNil(t, chat) + assert.Equal(t, core.Usage{}, chat.Usage) +} + +func TestConvertResponsesResponseToChat_MintsClientFacingID(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_8f14e45fceea167a5a36dedd4bea2543", + Status: "completed", + } + + chat := ConvertResponsesResponseToChat(resp) + require.NotNil(t, chat) + + assert.True(t, strings.HasPrefix(chat.ID, "chatcmpl-"), "client-facing ID must be chatcmpl- prefixed, got %q", chat.ID) + assert.NotContains(t, chat.ID, resp.ID, "upstream resp_ ID must not leak onto the chat surface") + assert.NotEqual(t, chat.ID, ConvertResponsesResponseToChat(resp).ID, "each conversion mints a fresh ID") +} diff --git a/internal/providers/chat_via_responses_stream.go b/internal/providers/chat_via_responses_stream.go new file mode 100644 index 000000000..fca46e091 --- /dev/null +++ b/internal/providers/chat_via_responses_stream.go @@ -0,0 +1,572 @@ +package providers + +import ( + "bytes" + "errors" + "io" + "strings" + "time" + + "github.com/goccy/go-json" + + "github.com/google/uuid" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/streaming" +) + +// maxResponsesStreamEventBytes caps one upstream Responses SSE event. +// Reasoning summaries and encrypted reasoning blobs are large, so the +// scanner default is too small; this mirrors the chatgpt provider's line cap. +const maxResponsesStreamEventBytes = 8 << 20 + +// OpenAIChatStreamConverter wraps a Responses API SSE stream and converts it +// to OpenAI chat.completion.chunk SSE. It serves providers whose upstream +// speaks only the Responses API (the ChatGPT Codex backend). +// +// The converter is a state machine over the upstream event types, the +// inverse of OpenAIResponsesStreamConverter: +// - response.created emits the first chunk with delta {role: "assistant"}. +// - response.output_item.added announces items; function_call items get a +// dense 0-based chat tool_calls[].index in arrival order (the upstream +// output_index counts reasoning and message items too, so it is not the +// chat index) and emit a start chunk with id/name. +// - output_text / refusal / reasoning deltas map to delta.content, +// delta.refusal, and the reasoning_content extension. +// - function_call_arguments.delta maps to a tool_calls delta carrying only +// the arguments fragment. +// - response.completed / response.incomplete emit the finish chunk +// (finish_reason from the terminal response's status and output, never +// re-emitting that output as content), an optional usage chunk when the +// chat request asked for it, and [DONE]. +// - response.failed, an error event, or a stream that ends without a +// terminal event emit the repo's in-band error convention and Read +// returns a streaming.ErrStreamIncomplete error; finish_reason "stop" +// is never reported for an interrupted or failed stream. +// +// Events are classified on the JSON "type" field of the data payload only; +// SSE "event:" lines are never relied on (some proxies strip them). +type OpenAIChatStreamConverter struct { + reader io.ReadCloser + model string + provider string + includeUsage bool + // chatID is the client-facing ID minted once at construction and carried + // by every chunk. The upstream resp_ ID never reaches the chat client. + chatID string + created int64 + + scanner streaming.EventScanner + buffer streaming.StreamBuffer + readBuf []byte + + sentRole bool + items map[string]*chatStreamItemState + itemsByIndex map[int]*chatStreamItemState + nextToolCallIdx int + finished bool // terminal success events (finish chunk, usage, [DONE]) emitted + failed bool // in-band error emitted + closed bool + endErr error // returned by Read once the error bytes are drained +} + +// NewOpenAIChatStreamConverter creates a converter that transforms a +// Responses API SSE stream into OpenAI chat.completion.chunk SSE. The +// returned reader owns reader and closes it on Close. +func NewOpenAIChatStreamConverter(reader io.ReadCloser, model, provider string, includeUsage bool) io.ReadCloser { + return &OpenAIChatStreamConverter{ + reader: reader, + model: model, + provider: provider, + includeUsage: includeUsage, + chatID: "chatcmpl-" + uuid.New().String(), + created: time.Now().Unix(), + scanner: streaming.EventScanner{MaxEventBytes: maxResponsesStreamEventBytes}, + buffer: streaming.NewStreamBuffer(4096), + readBuf: make([]byte, 4096), + items: make(map[string]*chatStreamItemState), + itemsByIndex: make(map[int]*chatStreamItemState), + } +} + +// chatStreamItemState tracks one upstream output item: for function_call +// items the dense chat tool_calls[].index plus the id and name the start +// chunk announced. +type chatStreamItemState struct { + toolIndex int + callID string + name string + started bool +} + +// responsesStreamEventView decodes the members of a Responses API stream +// event the converter classifies on. +type responsesStreamEventView struct { + Type string `json:"type"` + Delta string `json:"delta"` + ItemID string `json:"item_id"` + OutputIndex int `json:"output_index"` + Item json.RawMessage `json:"item"` + Response json.RawMessage `json:"response"` + // Code and Message belong to the top-level "error" event. + Code string `json:"code"` + Message string `json:"message"` +} + +// responsesStreamItemView decodes the item of a response.output_item.added +// event. +type responsesStreamItemView struct { + ID string `json:"id"` + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` +} + +// responsesStreamErrorView decodes the error member of a failed terminal +// response. +type responsesStreamErrorView struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// responsesTerminalResponseView decodes the response object carried by the +// terminal response.completed / response.incomplete / response.failed +// events. Only the members deciding the chat finish_reason and usage chunk +// are read; the full output is never re-emitted after its deltas. +type responsesTerminalResponseView struct { + Status string `json:"status"` + Output []struct { + Type string `json:"type"` + } `json:"output"` + IncompleteDetails *struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` + Error *responsesStreamErrorView `json:"error"` + Usage *responsesTerminalUsageView `json:"usage"` +} + +// responsesTerminalUsageView decodes the usage object of a terminal +// Responses event, with the Responses API field names. +type responsesTerminalUsageView struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + InputTokensDetails *core.PromptTokensDetails `json:"input_tokens_details"` + OutputTokensDetails *core.CompletionTokensDetails `json:"output_tokens_details"` +} + +// chatCompletionStreamUsage is the conservative Chat Completions +// representation of a Responses API usage object, renamed from +// input/output_tokens to prompt/completion_tokens. +type chatCompletionStreamUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokensDetails *core.PromptTokensDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokensDetails *core.CompletionTokensDetails `json:"completion_tokens_details,omitempty"` +} + +type chatCompletionStreamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Provider string `json:"provider,omitempty"` + Choices []chatCompletionStreamChoice `json:"choices"` + Usage *chatCompletionStreamUsage `json:"usage,omitempty"` +} + +type chatCompletionStreamChoice struct { + Index int `json:"index"` + // Delta is a map so the chunk can carry the extension members the repo + // relays (reasoning_content) alongside the spec members. + Delta map[string]any `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +// processEvent translates one upstream SSE event into chat chunks appended +// to the output buffer. +func (sc *OpenAIChatStreamConverter) processEvent(raw streaming.RawEvent) { + if sc.finished || sc.failed || raw.Comment || raw.Oversized { + return + } + data := bytes.TrimSpace(raw.Data) + if len(data) == 0 || data[0] != '{' { + return + } + var event responsesStreamEventView + if err := json.Unmarshal(data, &event); err != nil { + return + } + switch event.Type { + case "response.created": + sc.handleCreated(event.Response) + case "response.in_progress", "response.queued": + // response.in_progress duplicates the response.created payload. + case "response.output_item.added": + sc.handleItemAdded(event.OutputIndex, event.Item) + case "response.output_text.delta": + sc.emitDelta(map[string]any{"content": event.Delta}) + case "response.refusal.delta": + sc.emitDelta(map[string]any{"refusal": event.Delta}) + case "response.reasoning_text.delta", "response.reasoning_summary_text.delta": + sc.emitDelta(map[string]any{"reasoning_content": event.Delta}) + case "response.function_call_arguments.delta": + sc.handleArgumentsDelta(event.ItemID, event.OutputIndex, event.Delta) + case "response.completed", "response.incomplete": + sc.handleTerminal(event.Type, event.Response) + case "response.failed": + sc.handleFailed(event.Response) + case "error": + sc.failUpstream(event.Code, event.Message) + } + // Everything else (content_part.*, *.done, output_item.done, annotation + // events, hosted-tool items) carries nothing the deltas did not already + // deliver. +} + +// handleCreated takes model and created from the response.created payload +// when present and emits the first chunk announcing the assistant role. +func (sc *OpenAIChatStreamConverter) handleCreated(raw json.RawMessage) { + var response struct { + Model string `json:"model"` + CreatedAt int64 `json:"created_at"` + } + _ = json.Unmarshal(raw, &response) + if response.Model != "" { + sc.model = response.Model + } + if response.CreatedAt != 0 { + sc.created = response.CreatedAt + } + sc.ensureRoleChunk() +} + +// ensureRoleChunk emits the first chunk carrying delta {role: "assistant"} +// exactly once, ahead of the first delta the client sees. +func (sc *OpenAIChatStreamConverter) ensureRoleChunk() { + if sc.sentRole || sc.finished || sc.failed { + return + } + sc.sentRole = true + sc.emitChunk(map[string]any{"role": "assistant"}, nil) +} + +func (sc *OpenAIChatStreamConverter) emitDelta(delta map[string]any) { + sc.ensureRoleChunk() + sc.emitChunk(delta, nil) +} + +// handleItemAdded registers an announced output item by item_id and +// output_index. A function_call item claims the next dense chat +// tool_calls[].index and emits its start chunk (id, type, name, empty +// arguments); the chat id is the item's call_id, which clients echo back as +// tool_call_id. +func (sc *OpenAIChatStreamConverter) handleItemAdded(outputIndex int, raw json.RawMessage) { + var item responsesStreamItemView + if err := json.Unmarshal(raw, &item); err != nil { + return + } + state := sc.registerItem(item.ID, outputIndex, item.Type) + if item.Type != "function_call" { + return + } + state.callID = item.CallID + state.name = item.Name + sc.emitToolCallStart(state) +} + +// registerItem records an output item, claiming a dense tool-call index for +// function_call items. +func (sc *OpenAIChatStreamConverter) registerItem(id string, outputIndex int, itemType string) *chatStreamItemState { + state := &chatStreamItemState{toolIndex: -1} + if itemType == "function_call" { + state.toolIndex = sc.nextToolCallIdx + sc.nextToolCallIdx++ + } + if id != "" { + sc.items[id] = state + } + sc.itemsByIndex[outputIndex] = state + return state +} + +// handleArgumentsDelta emits one tool-call delta carrying the arguments +// fragment under the item's dense chat index. Deltas of parallel calls may +// interleave; each carries its item_id, so they never share an index. +func (sc *OpenAIChatStreamConverter) handleArgumentsDelta(itemID string, outputIndex int, delta string) { + state := sc.items[itemID] + if state == nil { + state = sc.itemsByIndex[outputIndex] + } + if state == nil { + // A delta for an item the stream never announced: register it so the + // arguments still land under a stable dense index (Postel's law). + state = sc.registerItem(itemID, outputIndex, "function_call") + } + if state.toolIndex < 0 { + return + } + sc.emitToolCallStart(state) + if delta == "" { + return + } + sc.emitDelta(map[string]any{"tool_calls": []any{map[string]any{ + "index": state.toolIndex, + "function": map[string]any{"arguments": delta}, + }}}) +} + +// emitToolCallStart emits the first chunk of a function_call item exactly +// once, carrying the dense index, the call id, and the function name with +// empty arguments. +func (sc *OpenAIChatStreamConverter) emitToolCallStart(state *chatStreamItemState) { + if state.started { + return + } + state.started = true + function := map[string]any{"arguments": ""} + if state.name != "" { + function["name"] = state.name + } + call := map[string]any{ + "index": state.toolIndex, + "type": "function", + "function": function, + } + if state.callID != "" { + call["id"] = state.callID + } + sc.emitDelta(map[string]any{"tool_calls": []any{call}}) +} + +// handleTerminal inspects the terminal event's response object, emits the +// finish chunk, then the usage chunk when the chat request asked for usage, +// then [DONE]. The role chunk goes out first when the stream saw neither +// response.created nor any delta, so clients always see a role ahead of the +// finish. The finish reason mirrors responsesChatFinishReason on the +// non-streaming path: incomplete reasons without a chat equivalent yield a +// null finish_reason rather than an invented one. The terminal event's full +// output is never re-emitted as content; the deltas already carried it. +func (sc *OpenAIChatStreamConverter) handleTerminal(eventType string, raw json.RawMessage) { + var response responsesTerminalResponseView + if err := json.Unmarshal(raw, &response); err != nil { + // An unreadable terminal payload tells nothing; the stream ending + // without a usable terminal event is reported as truncated. + return + } + if response.Status == "" { + // The event type carries the status when the payload omits it. + response.Status = strings.TrimPrefix(eventType, "response.") + } + if response.Status == "failed" { + sc.failTerminalError(response.Error) + return + } + sc.ensureRoleChunk() + sc.finished = true + sc.emitChunk(map[string]any{}, terminalFinishReason(&response)) + if sc.includeUsage && response.Usage != nil { + sc.emitUsage(response.Usage) + } + sc.buffer.AppendString("data: [DONE]\n\n") +} + +// terminalFinishReason maps the terminal response's status onto a chat +// finish reason, returning nil when no honest mapping exists. +func terminalFinishReason(response *responsesTerminalResponseView) *string { + switch response.Status { + case "completed": + for _, item := range response.Output { + if item.Type == "function_call" { + reason := "tool_calls" + return &reason + } + } + reason := "stop" + return &reason + case "incomplete": + if response.IncompleteDetails == nil { + return nil + } + switch response.IncompleteDetails.Reason { + case "max_output_tokens": + reason := "length" + return &reason + case "content_filter": + reason := "content_filter" + return &reason + } + } + return nil +} + +// emitUsage emits one chunk with an empty choices array carrying the usage +// renamed to the Chat Completions field names. +func (sc *OpenAIChatStreamConverter) emitUsage(usage *responsesTerminalUsageView) { + sc.emitPayload(chatCompletionStreamChunk{ + ID: sc.chatID, + Object: "chat.completion.chunk", + Created: sc.created, + Model: sc.model, + Provider: sc.provider, + Choices: []chatCompletionStreamChoice{}, + Usage: &chatCompletionStreamUsage{ + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + PromptTokensDetails: usage.InputTokensDetails, + CompletionTokensDetails: usage.OutputTokensDetails, + }, + }) +} + +// handleFailed ends the stream on a response.failed event: the terminal +// response's error member describes the failure. +func (sc *OpenAIChatStreamConverter) handleFailed(raw json.RawMessage) { + var response struct { + Error *responsesStreamErrorView `json:"error"` + } + _ = json.Unmarshal(raw, &response) + sc.failTerminalError(response.Error) +} + +func (sc *OpenAIChatStreamConverter) failTerminalError(upstreamError *responsesStreamErrorView) { + code, message := "", "" + if upstreamError != nil { + code, message = upstreamError.Code, upstreamError.Message + } + sc.failUpstream(code, message) +} + +// failUpstream emits the in-band error event for a stream the provider +// failed. Like appendFailedEvents in the inverse converter, an upstream that +// names no code gets "provider_error". +func (sc *OpenAIChatStreamConverter) failUpstream(code, message string) { + if sc.finished || sc.failed { + return + } + sc.failed = true + if code == "" { + code = "provider_error" + } + if strings.TrimSpace(message) == "" { + message = "provider stream failed" + } + sc.emitError(code, message) + sc.endErr = streaming.IncompleteStreamError(errors.New(message)) +} + +// failTruncated ends a stream that stopped before its terminal event with +// the repo's stream-error convention (#1017): an in-band error event, then +// Read returns the read failure wrapped in streaming.ErrStreamIncomplete. +func (sc *OpenAIChatStreamConverter) failTruncated(err error) { + if sc.finished || sc.failed { + return + } + sc.failed = true + sc.endErr = streaming.IncompleteStreamError(err) + sc.emitError("stream_incomplete", streaming.ErrStreamIncomplete.Error()) +} + +// emitError renders the chat dialect's in-band error event, the same shape +// the server's completion guard appends to a truncated chat stream. +func (sc *OpenAIChatStreamConverter) emitError(code, message string) { + sc.emitPayload(map[string]any{"error": map[string]any{ + "type": string(core.ErrorTypeProvider), + "message": message, + "param": nil, + "code": code, + }}) +} + +func (sc *OpenAIChatStreamConverter) emitChunk(delta map[string]any, finishReason *string) { + if delta == nil { + delta = map[string]any{} + } + sc.emitPayload(chatCompletionStreamChunk{ + ID: sc.chatID, + Object: "chat.completion.chunk", + Created: sc.created, + Model: sc.model, + Provider: sc.provider, + Choices: []chatCompletionStreamChoice{ + {Index: 0, Delta: delta, FinishReason: finishReason}, + }, + }) +} + +// emitPayload marshals payload as one SSE event and appends it to the output +// buffer. Chat chunks carry no SSE "event:" line. +func (sc *OpenAIChatStreamConverter) emitPayload(payload any) { + data, err := json.Marshal(payload) + if err != nil { + return + } + event := streaming.Event{Data: data} + sc.buffer.AppendBytes(event.Encode()) +} + +func (sc *OpenAIChatStreamConverter) Read(p []byte) (int, error) { + if sc.closed { + return 0, io.EOF + } + if sc.buffer.Len() > 0 { + return sc.buffer.Read(p), nil + } + if sc.endErr != nil { + return sc.closeRead(sc.endErr) + } + if sc.finished { + return sc.closeRead(io.EOF) + } + + nr, readErr := sc.reader.Read(sc.readBuf) + if nr > 0 { + for _, raw := range sc.scanner.Feed(sc.readBuf[:nr]) { + sc.processEvent(raw) + } + } + if readErr != nil { + // The upstream stream ended; flush a trailing unterminated event, + // then finish. A stream that saw no terminal event is truncated, + // whatever the read error (a clean EOF included). + for _, raw := range sc.scanner.Flush() { + sc.processEvent(raw) + } + if !sc.finished && !sc.failed { + sc.failTruncated(readErr) + } + if sc.buffer.Len() > 0 { + return sc.buffer.Read(p), nil + } + if sc.endErr != nil { + return sc.closeRead(sc.endErr) + } + return sc.closeRead(io.EOF) + } + if sc.buffer.Len() > 0 { + return sc.buffer.Read(p), nil + } + + // No data yet, try again + return 0, nil +} + +// closeRead ends the Read side: buffers are released and the upstream reader +// closed before err (io.EOF or the stream error) is returned. +func (sc *OpenAIChatStreamConverter) closeRead(err error) (int, error) { + sc.closed = true + sc.buffer.Release() + _ = sc.reader.Close() + return 0, err +} + +func (sc *OpenAIChatStreamConverter) Close() error { + if sc.closed { + return nil + } + sc.closed = true + sc.buffer.Release() + return sc.reader.Close() +} diff --git a/internal/providers/chat_via_responses_stream_test.go b/internal/providers/chat_via_responses_stream_test.go new file mode 100644 index 000000000..a1a63cdfb --- /dev/null +++ b/internal/providers/chat_via_responses_stream_test.go @@ -0,0 +1,475 @@ +package providers + +import ( + "errors" + "io" + "strings" + "testing" + + "encoding/json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/streaming" +) + +// responsesStream builds an SSE stream from JSON payloads, one data: line +// each. No event: lines are emitted: the converter must classify on the +// payload's type field alone. +func chatViaResponsesStreamOf(events ...string) string { + var b strings.Builder + for _, event := range events { + b.WriteString("data: ") + b.WriteString(event) + b.WriteString("\n\n") + } + return b.String() +} + +const ( + chatViaResponsesCreated = `{"type":"response.created","sequence_number":0,"response":{"id":"resp_abc123","object":"response","status":"in_progress","model":"gpt-5.1-codex","created_at":1700000000,"output":[]}}` + chatViaResponsesMessage = `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}` +) + +func chatViaResponsesTextStream() string { + return chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_abc123","object":"response","status":"in_progress","model":"gpt-5.1-codex","created_at":1700000000,"output":[]}}`, + chatViaResponsesMessage, + `{"type":"response.content_part.added","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}}`, + `{"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}`, + `{"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}`, + `{"type":"response.output_text.done","sequence_number":6,"item_id":"msg_1","output_index":0,"content_index":0,"text":"Hello world"}`, + `{"type":"response.content_part.done","sequence_number":7,"item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello world","annotations":[]}}`, + `{"type":"response.output_item.done","sequence_number":8,"output_index":0,"item":{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello world","annotations":[]}]}}`, + `{"type":"response.completed","sequence_number":9,"response":{"id":"resp_abc123","object":"response","status":"completed","model":"gpt-5.1-codex","created_at":1700000000,"output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello world","annotations":[]}]}],"usage":{"input_tokens":12,"output_tokens":5,"total_tokens":17,"input_tokens_details":{"cached_tokens":4},"output_tokens_details":{"reasoning_tokens":2}}}}`, + ) +} + +func readChatViaResponsesStream(t *testing.T, stream string, includeUsage bool) ([]testSSEEvent, string, error) { + t.Helper() + reader := io.NopCloser(strings.NewReader(stream)) + converter := NewOpenAIChatStreamConverter(reader, "fallback-model", "test-provider", includeUsage) + defer func() { _ = converter.Close() }() + + raw, err := io.ReadAll(converter) + return parseTestSSEEvents(t, string(raw)), string(raw), err +} + +// chatChunkDelta returns the delta object of a chunk's first choice. +func chatChunkDelta(t *testing.T, payload map[string]any) map[string]any { + t.Helper() + choices, ok := payload["choices"].([]any) + require.True(t, ok, "chunk has no choices array: %v", payload) + require.NotEmpty(t, choices, "chunk has empty choices: %v", payload) + choice, ok := choices[0].(map[string]any) + require.True(t, ok) + delta, ok := choice["delta"].(map[string]any) + require.True(t, ok, "choice has no delta object: %v", choice) + return delta +} + +// chatChunkFinishReason returns the finish_reason of a chunk's first choice; +// nil means JSON null. +func chatChunkFinishReason(t *testing.T, payload map[string]any) any { + t.Helper() + choices, ok := payload["choices"].([]any) + require.True(t, ok, "chunk has no choices array: %v", payload) + require.NotEmpty(t, choices, "chunk has empty choices: %v", payload) + choice, ok := choices[0].(map[string]any) + require.True(t, ok) + return choice["finish_reason"] +} + +// chatChunkToolCalls returns the tool_calls array of a chunk's first delta. +func chatChunkToolCalls(t *testing.T, payload map[string]any) []any { + t.Helper() + calls, ok := chatChunkDelta(t, payload)["tool_calls"].([]any) + require.True(t, ok, "delta has no tool_calls array: %v", payload) + return calls +} + +func assertStableChatChunkEnvelope(t *testing.T, events []testSSEEvent, raw string) { + t.Helper() + require.NotEmpty(t, events) + id, ok := events[0].Payload["id"].(string) + require.True(t, ok, "first chunk has no id") + assert.True(t, strings.HasPrefix(id, "chatcmpl-"), "id %q lacks chatcmpl- prefix", id) + created := events[0].Payload["created"] + for _, event := range events { + if event.Done { + continue + } + assert.Equal(t, id, event.Payload["id"], "chunk id changed mid-stream") + assert.Equal(t, created, event.Payload["created"], "created changed mid-stream") + assert.Equal(t, "chat.completion.chunk", event.Payload["object"]) + } + assert.NotContains(t, raw, "resp_abc123", "upstream resp_ id leaked into chat chunks") +} + +func TestOpenAIChatStreamConverter_TextDeltas(t *testing.T) { + events, raw, err := readChatViaResponsesStream(t, chatViaResponsesTextStream(), false) + require.NoError(t, err) + // role chunk + two content chunks + finish chunk + [DONE]; the terminal + // event's aggregated output must not be re-emitted as a third content + // chunk. + require.Len(t, events, 5) + + role := chatChunkDelta(t, events[0].Payload) + assert.Equal(t, "assistant", role["role"]) + assert.Len(t, role, 1) + assert.Nil(t, chatChunkFinishReason(t, events[0].Payload)) + + assert.Equal(t, "Hello", chatChunkDelta(t, events[1].Payload)["content"]) + assert.Equal(t, " world", chatChunkDelta(t, events[2].Payload)["content"]) + + finish := events[3].Payload + assert.Equal(t, "stop", chatChunkFinishReason(t, finish)) + assert.Empty(t, chatChunkDelta(t, finish)) + + assert.True(t, events[4].Done) + + assertStableChatChunkEnvelope(t, events, raw) + // model and created came from the response.created payload, not the + // constructor fallback. + assert.Equal(t, "gpt-5.1-codex", events[0].Payload["model"]) + assert.Equal(t, float64(1700000000), events[0].Payload["created"]) + assert.Equal(t, "test-provider", events[0].Payload["provider"]) + // includeUsage was false: no chunk carries a usage object. + for _, event := range events { + assert.Nil(t, event.Payload["usage"]) + } +} + +func TestOpenAIChatStreamConverter_SingleToolCall(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_1","output_index":0,"delta":"{\"city\":\"War"}`, + `{"type":"response.function_call_arguments.delta","sequence_number":4,"item_id":"fc_1","output_index":0,"delta":"saw\"}"}`, + `{"type":"response.function_call_arguments.done","sequence_number":5,"item_id":"fc_1","output_index":0,"arguments":"{\"city\":\"Warsaw\"}"}`, + `{"type":"response.output_item.done","sequence_number":6,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"Warsaw\"}"}}`, + `{"type":"response.completed","sequence_number":7,"response":{"id":"resp_abc123","object":"response","status":"completed","model":"gpt-5.1-codex","created_at":1700000000,"output":[{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"Warsaw\"}"}],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk + two argument deltas + finish + [DONE] + require.Len(t, events, 6) + + start := chatChunkToolCalls(t, events[1].Payload) + require.Len(t, start, 1) + call, ok := start[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(0), call["index"]) + assert.Equal(t, "call_abc", call["id"]) + assert.Equal(t, "function", call["type"]) + function, ok := call["function"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "get_weather", function["name"]) + assert.Equal(t, "", function["arguments"]) + + first := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), first["index"]) + assert.Nil(t, first["id"], "argument deltas must not repeat the id") + assert.Equal(t, "{\"city\":\"War", first["function"].(map[string]any)["arguments"]) + + second := chatChunkToolCalls(t, events[3].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), second["index"]) + assert.Equal(t, "saw\"}", second["function"].(map[string]any)["arguments"]) + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[4].Payload)) + assert.True(t, events[5].Done) +} + +func TestOpenAIChatStreamConverter_ParallelToolCalls(t *testing.T) { + // Two function_call items whose argument deltas interleave; each delta + // carries its item_id, and the converter must route them to the dense + // chat indices assigned in arrival order. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":""}}`, + `{"type":"response.output_item.added","sequence_number":3,"output_index":1,"item":{"id":"fc_2","type":"function_call","call_id":"call_b","name":"fn_b","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":4,"item_id":"fc_2","output_index":1,"delta":"{\"b\":"}`, + `{"type":"response.function_call_arguments.delta","sequence_number":5,"item_id":"fc_1","output_index":0,"delta":"{\"a\":"}`, + `{"type":"response.function_call_arguments.delta","sequence_number":6,"item_id":"fc_2","output_index":1,"delta":"1}"}`, + `{"type":"response.function_call_arguments.delta","sequence_number":7,"item_id":"fc_1","output_index":0,"delta":"2}"}`, + `{"type":"response.completed","sequence_number":8,"response":{"id":"resp_abc123","object":"response","status":"completed","model":"gpt-5.1-codex","created_at":1700000000,"output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":2}"},{"id":"fc_2","type":"function_call","call_id":"call_b","name":"fn_b","arguments":"{\"b\":1}"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + two start chunks + four argument deltas + finish + [DONE] + require.Len(t, events, 9) + + startA := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), startA["index"]) + assert.Equal(t, "call_a", startA["id"]) + assert.Equal(t, "fn_a", startA["function"].(map[string]any)["name"]) + + startB := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(1), startB["index"]) + assert.Equal(t, "call_b", startB["id"]) + assert.Equal(t, "fn_b", startB["function"].(map[string]any)["name"]) + + wantIndices := []float64{1, 0, 1, 0} + wantArgs := []string{"{\"b\":", "{\"a\":", "1}", "2}"} + for i := range wantIndices { + call := chatChunkToolCalls(t, events[3+i].Payload)[0].(map[string]any) + assert.Equal(t, wantIndices[i], call["index"], "argument delta %d routed to the wrong tool call", i) + assert.Equal(t, wantArgs[i], call["function"].(map[string]any)["arguments"]) + } + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[7].Payload)) + assert.True(t, events[8].Done) +} + +func TestOpenAIChatStreamConverter_ReasoningThenText(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}`, + `{"type":"response.reasoning_summary_text.delta","sequence_number":3,"item_id":"rs_1","output_index":0,"summary_index":0,"delta":"thinking "}`, + `{"type":"response.reasoning_text.delta","sequence_number":4,"item_id":"rs_1","output_index":0,"content_index":0,"delta":"hard"}`, + `{"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking hard"}]}}`, + `{"type":"response.output_item.added","sequence_number":6,"output_index":1,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}`, + `{"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_1","output_index":1,"content_index":0,"delta":"Answer"}`, + `{"type":"response.completed","sequence_number":8,"response":{"id":"resp_abc123","object":"response","status":"completed","model":"gpt-5.1-codex","created_at":1700000000,"output":[{"id":"rs_1","type":"reasoning"},{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Answer","annotations":[]}]}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + two reasoning deltas + one content delta + finish + [DONE] + require.Len(t, events, 6) + + assert.Equal(t, "thinking ", chatChunkDelta(t, events[1].Payload)["reasoning_content"]) + assert.Equal(t, "hard", chatChunkDelta(t, events[2].Payload)["reasoning_content"]) + assert.Equal(t, "Answer", chatChunkDelta(t, events[3].Payload)["content"]) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[4].Payload)) + assert.True(t, events[5].Done) +} + +func TestOpenAIChatStreamConverter_RefusalDeltas(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + chatViaResponsesMessage, + `{"type":"response.content_part.added","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"refusal","refusal":""}}`, + `{"type":"response.refusal.delta","sequence_number":4,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"I can't"}`, + `{"type":"response.refusal.delta","sequence_number":5,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" help with that"}`, + `{"type":"response.completed","sequence_number":6,"response":{"id":"resp_abc123","object":"response","status":"completed","model":"gpt-5.1-codex","created_at":1700000000,"output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"refusal","refusal":"I can't help with that"}]}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + require.Len(t, events, 5) + + assert.Equal(t, "I can't", chatChunkDelta(t, events[1].Payload)["refusal"]) + assert.Equal(t, " help with that", chatChunkDelta(t, events[2].Payload)["refusal"]) + // A refusal is not content. + assert.Nil(t, chatChunkDelta(t, events[1].Payload)["content"]) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) +} + +func TestOpenAIChatStreamConverter_FinishReasons(t *testing.T) { + tests := []struct { + name string + terminal string + want any // expected finish_reason; nil means JSON null + }{ + { + name: "completed text", + terminal: `{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message","content":[{"type":"output_text","text":"hi"}]}]}}`, + want: "stop", + }, + { + name: "completed with function call", + terminal: `{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{}"}]}}`, + want: "tool_calls", + }, + { + name: "incomplete max_output_tokens", + terminal: `{"type":"response.incomplete","response":{"id":"resp_abc123","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"id":"msg_1","type":"message"}]}}`, + want: "length", + }, + { + name: "incomplete content_filter", + terminal: `{"type":"response.incomplete","response":{"id":"resp_abc123","status":"incomplete","incomplete_details":{"reason":"content_filter"},"output":[]}}`, + want: "content_filter", + }, + { + name: "incomplete reason without chat equivalent", + terminal: `{"type":"response.incomplete","response":{"id":"resp_abc123","status":"incomplete","incomplete_details":{"reason":"max_messages"},"output":[]}}`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := chatViaResponsesStreamOf(chatViaResponsesCreated, tt.terminal) + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role chunk + finish chunk + [DONE] + require.Len(t, events, 3) + if tt.want == nil { + assert.Nil(t, chatChunkFinishReason(t, events[1].Payload)) + } else { + assert.Equal(t, tt.want, chatChunkFinishReason(t, events[1].Payload)) + } + assert.True(t, events[2].Done) + }) + } +} + +func TestOpenAIChatStreamConverter_RoleChunkWithoutCreated(t *testing.T) { + // No response.created and no deltas: the terminal event alone must still + // emit the role chunk ahead of the finish chunk. + stream := chatViaResponsesStreamOf( + `{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role chunk + finish chunk + [DONE] + require.Len(t, events, 3) + assert.Equal(t, "assistant", chatChunkDelta(t, events[0].Payload)["role"]) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[1].Payload)) + assert.True(t, events[2].Done) +} + +func TestOpenAIChatStreamConverter_UsageChunk(t *testing.T) { + t.Run("emitted when includeUsage", func(t *testing.T) { + events, _, err := readChatViaResponsesStream(t, chatViaResponsesTextStream(), true) + require.NoError(t, err) + // role + two content chunks + finish + usage chunk + [DONE] + require.Len(t, events, 6) + + usageChunk := events[4].Payload + choices, ok := usageChunk["choices"].([]any) + require.True(t, ok) + assert.Empty(t, choices, "usage chunk must carry an empty choices array") + + usage, ok := usageChunk["usage"].(map[string]any) + require.True(t, ok, "usage chunk carries no usage object") + assert.Equal(t, float64(12), usage["prompt_tokens"]) + assert.Equal(t, float64(5), usage["completion_tokens"]) + assert.Equal(t, float64(17), usage["total_tokens"]) + assert.Nil(t, usage["input_tokens"], "Responses field names must not leak") + assert.Nil(t, usage["output_tokens"]) + promptDetails, ok := usage["prompt_tokens_details"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(4), promptDetails["cached_tokens"]) + completionDetails, ok := usage["completion_tokens_details"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(2), completionDetails["reasoning_tokens"]) + + assert.True(t, events[5].Done) + }) + + t.Run("omitted without includeUsage", func(t *testing.T) { + events, _, err := readChatViaResponsesStream(t, chatViaResponsesTextStream(), false) + require.NoError(t, err) + for _, event := range events { + assert.Nil(t, event.Payload["usage"]) + } + }) +} + +func TestOpenAIChatStreamConverter_FailedMidStream(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + chatViaResponsesMessage, + `{"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"partial"}`, + `{"type":"response.failed","sequence_number":4,"response":{"id":"resp_abc123","object":"response","status":"failed","model":"gpt-5.1-codex","created_at":1700000000,"output":[],"error":{"code":"server_error","message":"boom"}}}`, + ) + + events, raw, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + assert.True(t, errors.Is(err, streaming.ErrStreamIncomplete), "err = %v", err) + + // role + content delta + in-band error; no finish chunk, no [DONE]. + require.Len(t, events, 3) + assert.Equal(t, "partial", chatChunkDelta(t, events[1].Payload)["content"]) + + errorPayload, ok := events[2].Payload["error"].(map[string]any) + require.True(t, ok, "expected an in-band error event, got %v", events[2].Payload) + assert.Equal(t, "boom", errorPayload["message"]) + assert.Equal(t, "server_error", errorPayload["code"]) + + for _, event := range events { + assert.False(t, event.Done, "a failed stream must not end with [DONE]") + } + assert.NotContains(t, raw, `"finish_reason":"stop"`) +} + +func TestOpenAIChatStreamConverter_TopLevelErrorEvent(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"error","sequence_number":1,"code":"rate_limit_error","message":"slow down","param":null}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + assert.True(t, errors.Is(err, streaming.ErrStreamIncomplete), "err = %v", err) + + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "slow down", errorPayload["message"]) + assert.Equal(t, "rate_limit_error", errorPayload["code"]) + assert.False(t, events[1].Done) +} + +func TestOpenAIChatStreamConverter_EndsWithoutTerminalEvent(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + chatViaResponsesMessage, + `{"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"truncated"}`, + ) + + events, raw, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + assert.True(t, errors.Is(err, streaming.ErrStreamIncomplete), "err = %v", err) + + // role + content delta + in-band truncation error; never a stop finish. + require.Len(t, events, 3) + assert.Equal(t, "truncated", chatChunkDelta(t, events[1].Payload)["content"]) + + errorPayload, ok := events[2].Payload["error"].(map[string]any) + require.True(t, ok, "expected an in-band error event, got %v", events[2].Payload) + assert.Equal(t, "stream_incomplete", errorPayload["code"]) + assert.Equal(t, streaming.ErrStreamIncomplete.Error(), errorPayload["message"]) + + assert.NotContains(t, raw, `"finish_reason":"stop"`) + assert.NotContains(t, raw, "[DONE]") +} + +// TestOpenAIChatStreamConverter_DataOnlyParsing feeds CRLF framing and SSE +// event: lines; classification must come from the payload's type field, so +// the output matches the plain-LF, data-only stream exactly. +func TestOpenAIChatStreamConverter_DataOnlyParsing(t *testing.T) { + plain := chatViaResponsesTextStream() + var decorated strings.Builder + for _, block := range strings.Split(strings.TrimRight(plain, "\n"), "\n\n") { + payload := strings.TrimPrefix(block, "data: ") + var head struct { + Type string `json:"type"` + } + require.NoError(t, json.Unmarshal([]byte(payload), &head)) + decorated.WriteString("event: " + head.Type + "\r\n" + block + "\r\n\r\n") + } + + plainEvents, _, err := readChatViaResponsesStream(t, plain, false) + require.NoError(t, err) + crlfEvents, _, err := readChatViaResponsesStream(t, decorated.String(), false) + require.NoError(t, err) + + require.Equal(t, len(plainEvents), len(crlfEvents)) + for i := range plainEvents { + assert.Equal(t, plainEvents[i].Done, crlfEvents[i].Done) + if plainEvents[i].Done { + continue + } + assert.Equal(t, plainEvents[i].Payload["choices"], crlfEvents[i].Payload["choices"], "chunk %d differs under CRLF framing", i) + } +} diff --git a/internal/providers/chat_via_responses_test.go b/internal/providers/chat_via_responses_test.go new file mode 100644 index 000000000..d64a1619c --- /dev/null +++ b/internal/providers/chat_via_responses_test.go @@ -0,0 +1,598 @@ +package providers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubChatViaResponsesProvider struct { + capturedReq *core.ResponsesRequest + resp *core.ResponsesResponse + respErr error + streamData string + streamErr error +} + +func (p *stubChatViaResponsesProvider) Responses(_ context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + p.capturedReq = req + return p.resp, p.respErr +} + +func (p *stubChatViaResponsesProvider) StreamResponses(_ context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + p.capturedReq = req + if p.streamErr != nil { + return nil, p.streamErr + } + return io.NopCloser(strings.NewReader(p.streamData)), nil +} + +func chatViaResponsesExtras(fields map[string]string) core.UnknownJSONFields { + raw := make(map[string]json.RawMessage, len(fields)) + for key, value := range fields { + raw[key] = json.RawMessage(value) + } + return core.UnknownJSONFieldsFromMap(raw) +} + +func TestConvertChatRequestToResponses(t *testing.T) { + temperature := 0.7 + topP := 0.9 + maxTokens := 1024 + parallelToolCalls := false + req := &core.ChatRequest{ + Model: "gpt-5.1-codex", + Messages: []core.Message{{Role: "user", Content: "hello"}}, + Temperature: &temperature, + TopP: &topP, + MaxTokens: &maxTokens, + ParallelToolCalls: ¶llelToolCalls, + Stream: true, + StreamOptions: &core.StreamOptions{IncludeUsage: true}, + Reasoning: &core.Reasoning{Effort: "high"}, + User: "user-1", + ServiceTier: "flex", + ExtraFields: chatViaResponsesExtras(map[string]string{ + "metadata": `{"session":"abc"}`, + "x_trace_token": `"keep-me"`, + }), + } + + responsesReq, err := ConvertChatRequestToResponses(req) + require.NoError(t, err) + + assert.Equal(t, "gpt-5.1-codex", responsesReq.Model) + assert.Equal(t, &temperature, responsesReq.Temperature) + assert.Equal(t, &topP, responsesReq.TopP) + assert.Equal(t, ¶llelToolCalls, responsesReq.ParallelToolCalls) + assert.Equal(t, &core.Reasoning{Effort: "high"}, responsesReq.Reasoning) + assert.Equal(t, "user-1", responsesReq.User) + assert.Equal(t, "flex", responsesReq.ServiceTier) + assert.True(t, responsesReq.Stream) + + require.NotNil(t, responsesReq.MaxOutputTokens) + assert.Equal(t, 1024, *responsesReq.MaxOutputTokens) + + assert.Equal(t, map[string]string{"session": "abc"}, responsesReq.Metadata) + + // stream_options is never forwarded: Responses stream options differ. + assert.Nil(t, responsesReq.StreamOptions) + + // Unknown extras survive; mapped extras move onto typed fields. + assert.Equal(t, json.RawMessage(`"keep-me"`), responsesReq.ExtraFields.Lookup("x_trace_token")) + assert.Nil(t, responsesReq.ExtraFields.Lookup("metadata")) + + require.NotNil(t, responsesReq.Input) + assert.Empty(t, responsesReq.Instructions) +} + +func TestConvertChatRequestToResponses_RequiresRequest(t *testing.T) { + _, err := ConvertChatRequestToResponses(nil) + require.Error(t, err) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) +} + +func TestConvertChatRequestToResponses_MaxTokensMapping(t *testing.T) { + maxTokens := 512 + + t.Run("max_tokens maps to max_output_tokens", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + MaxTokens: &maxTokens, + }) + require.NoError(t, err) + require.NotNil(t, responsesReq.MaxOutputTokens) + assert.Equal(t, 512, *responsesReq.MaxOutputTokens) + }) + + t.Run("explicit max_completion_tokens wins over max_tokens", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + MaxTokens: &maxTokens, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "max_completion_tokens": `2048`, + }), + }) + require.NoError(t, err) + require.NotNil(t, responsesReq.MaxOutputTokens) + assert.Equal(t, 2048, *responsesReq.MaxOutputTokens) + // Lifted onto the typed field, so it must not double-emit as an extra. + assert.Nil(t, responsesReq.ExtraFields.Lookup("max_completion_tokens")) + }) + + t.Run("unset when neither is present", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + require.NoError(t, err) + assert.Nil(t, responsesReq.MaxOutputTokens) + }) +} + +func TestConvertChatRequestToResponses_MessagesAndInstructions(t *testing.T) { + req := &core.ChatRequest{ + Model: "m", + Messages: []core.Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "hello"}, + }, + } + + responsesReq, err := ConvertChatRequestToResponses(req) + require.NoError(t, err) + assert.Equal(t, "You are helpful.", responsesReq.Instructions) + require.NotNil(t, responsesReq.Input) +} + +func TestConvertChatRequestToResponses_FlattensTools(t *testing.T) { + parameters := map[string]any{"type": "object", "properties": map[string]any{}} + + t.Run("nested chat function tool flattens", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Tools: []map[string]any{ + { + "type": "function", + "function": map[string]any{ + "name": "exec_command", + "description": "Run a command.", + "parameters": parameters, + "strict": true, + }, + }, + }, + }) + require.NoError(t, err) + require.Len(t, responsesReq.Tools, 1) + assert.Equal(t, map[string]any{ + "type": "function", + "name": "exec_command", + "description": "Run a command.", + "parameters": parameters, + "strict": true, + }, responsesReq.Tools[0]) + }) + + t.Run("already flat tool passes through", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Tools: []map[string]any{ + {"type": "function", "name": "exec_command", "parameters": parameters}, + }, + }) + require.NoError(t, err) + require.Len(t, responsesReq.Tools, 1) + assert.Equal(t, map[string]any{ + "type": "function", + "name": "exec_command", + "parameters": parameters, + }, responsesReq.Tools[0]) + }) + + t.Run("non-function tool rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Tools: []map[string]any{{"type": "custom", "name": "exec_command"}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "tools") + }) +} + +func TestConvertChatRequestToResponses_ToolChoice(t *testing.T) { + tests := []struct { + name string + choice any + want any + }{ + {name: "auto string passes", choice: "auto", want: "auto"}, + {name: "required string passes", choice: "required", want: "required"}, + {name: "none string passes", choice: "none", want: "none"}, + { + name: "nested function choice flattens", + choice: map[string]any{"type": "function", "function": map[string]any{"name": "exec_command"}}, + want: map[string]any{"type": "function", "name": "exec_command"}, + }, + { + name: "already flat function choice passes through", + choice: map[string]any{"type": "function", "name": "exec_command"}, + want: map[string]any{"type": "function", "name": "exec_command"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ToolChoice: tt.choice, + }) + require.NoError(t, err) + assert.Equal(t, tt.want, responsesReq.ToolChoice) + }) + } + + t.Run("unknown object type rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ToolChoice: map[string]any{"type": "allowed_tools", "tools": []any{}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "tool_choice") + }) + + t.Run("non-string non-object rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ToolChoice: 42, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "tool_choice") + }) +} + +func TestConvertChatRequestToResponses_ResponseFormat(t *testing.T) { + newRequest := func(responseFormat string) *core.ChatRequest { + return &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "response_format": responseFormat, + }), + } + } + + t.Run("text yields no text settings", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(newRequest(`{"type":"text"}`)) + require.NoError(t, err) + assert.Nil(t, responsesReq.Text) + assert.Nil(t, responsesReq.ExtraFields.Lookup("response_format")) + }) + + t.Run("json_object maps to text.format", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(newRequest(`{"type":"json_object"}`)) + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "format": map[string]any{"type": "json_object"}, + }, responsesReq.Text) + assert.Nil(t, responsesReq.ExtraFields.Lookup("response_format")) + }) + + t.Run("json_schema flattens one level", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(newRequest( + `{"type":"json_schema","json_schema":{"name":"out","schema":{"type":"object"},"strict":true}}`, + )) + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "out", + "schema": map[string]any{"type": "object"}, + "strict": true, + }, + }, responsesReq.Text) + }) + + t.Run("unknown format type rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(newRequest(`{"type":"grammar"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "response_format") + }) + + t.Run("malformed value rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(newRequest(`"json_object"`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "response_format") + }) +} + +func TestConvertChatRequestToResponses_RejectsUnsupportedFields(t *testing.T) { + tests := []struct { + name string + extra map[string]string + want string + }{ + {name: "n greater than one", extra: map[string]string{"n": `2`}, want: "n"}, + {name: "logit_bias", extra: map[string]string{"logit_bias": `{"123":-100}`}, want: "logit_bias"}, + {name: "stop", extra: map[string]string{"stop": `["END"]`}, want: "stop"}, + {name: "seed", extra: map[string]string{"seed": `42`}, want: "seed"}, + {name: "frequency_penalty", extra: map[string]string{"frequency_penalty": `0.5`}, want: "frequency_penalty"}, + {name: "presence_penalty", extra: map[string]string{"presence_penalty": `0.5`}, want: "presence_penalty"}, + {name: "logprobs", extra: map[string]string{"logprobs": `true`}, want: "logprobs"}, + {name: "top_logprobs", extra: map[string]string{"top_logprobs": `3`}, want: "top_logprobs"}, + {name: "modalities", extra: map[string]string{"modalities": `["text","audio"]`}, want: "modalities"}, + {name: "audio", extra: map[string]string{"audio": `{"voice":"alloy","format":"wav"}`}, want: "audio"}, + {name: "web_search_options", extra: map[string]string{"web_search_options": `{}`}, want: "web_search_options"}, + {name: "function_call", extra: map[string]string{"function_call": `{"name":"exec_command"}`}, want: "function_call"}, + {name: "functions", extra: map[string]string{"functions": `[{"name":"exec_command"}]`}, want: "functions"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(tt.extra), + } + + _, err := ConvertChatRequestToResponses(req) + require.Error(t, err) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, tt.want) + }) + } +} + +func TestConvertChatRequestToResponses_AllowsSingleChoiceN(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{"n": `1`}), + }) + require.NoError(t, err) + // n=1 is a validated no-op; Responses has no n field, so it is + // stripped from the translated request rather than passed through. + assert.Nil(t, responsesReq.ExtraFields.Lookup("n")) +} + +func TestConvertChatRequestToResponses_StripsPrediction(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "prediction": `{"type":"content","content":"draft"}`, + "x_trace_token": `"keep-me"`, + }), + }) + require.NoError(t, err) + // prediction is a pure speed hint with no Responses equivalent: it is + // tolerated but stripped, while unknown extras survive. + assert.Nil(t, responsesReq.ExtraFields.Lookup("prediction")) + assert.Equal(t, json.RawMessage(`"keep-me"`), responsesReq.ExtraFields.Lookup("x_trace_token")) +} + +func TestConvertChatRequestToResponses_ToleratesExplicitNulls(t *testing.T) { + // An explicit JSON null spells "not set" on the wire; it must not trip + // the unsupported-field rejection. + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "n": `null`, + "stop": `null`, + "seed": `null`, + }), + }) + require.NoError(t, err) + assert.Nil(t, responsesReq.ExtraFields.Lookup("n")) +} + +func chatViaResponsesCompletedResponse() *core.ResponsesResponse { + return &core.ResponsesResponse{ + ID: "resp_123", + Object: "response", + Status: "completed", + Model: "gpt-5.1-codex", + Output: []core.ResponsesOutputItem{ + { + Type: "message", + Role: "assistant", + Content: []core.ResponsesContentItem{{Type: "output_text", Text: "hello"}}, + }, + }, + } +} + +func TestChatViaResponses(t *testing.T) { + t.Run("converts request and response", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{resp: chatViaResponsesCompletedResponse()} + maxTokens := 64 + + chatResp, err := ChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "gpt-5.1-codex", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + MaxTokens: &maxTokens, + }, "chatgpt") + require.NoError(t, err) + require.NotNil(t, chatResp) + assert.NotEmpty(t, chatResp.Choices) + + require.NotNil(t, provider.capturedReq) + assert.Equal(t, "gpt-5.1-codex", provider.capturedReq.Model) + require.NotNil(t, provider.capturedReq.MaxOutputTokens) + assert.Equal(t, 64, *provider.capturedReq.MaxOutputTokens) + assert.False(t, provider.capturedReq.Stream) + }) + + t.Run("provider error passes through", func(t *testing.T) { + wantErr := errors.New("upstream exploded") + provider := &stubChatViaResponsesProvider{respErr: wantErr} + + chatResp, err := ChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }, "chatgpt") + require.Nil(t, chatResp) + require.ErrorIs(t, err, wantErr) + }) + + t.Run("nil response rejected", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{} + + chatResp, err := ChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }, "chatgpt") + require.Nil(t, chatResp) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadGateway, gatewayErr.HTTPStatusCode()) + assert.Equal(t, "provider returned empty response", gatewayErr.Message) + assert.Equal(t, "chatgpt", gatewayErr.Provider) + }) + + t.Run("failed response is a provider error", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{resp: &core.ResponsesResponse{ + ID: "resp_123", + Status: "failed", + Error: &core.ResponsesError{Code: "server_error", Message: "boom upstream"}, + }} + + chatResp, err := ChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }, "chatgpt") + require.Nil(t, chatResp) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadGateway, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, "boom upstream") + assert.Equal(t, "chatgpt", gatewayErr.Provider) + require.NotNil(t, gatewayErr.Code) + assert.Equal(t, "server_error", *gatewayErr.Code) + }) + + t.Run("failed response without upstream error has generic message", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{resp: &core.ResponsesResponse{ + ID: "resp_123", + Status: "failed", + }} + + chatResp, err := ChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }, "chatgpt") + require.Nil(t, chatResp) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadGateway, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, "failed") + }) + + t.Run("unsupported field fails before upstream call", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{resp: chatViaResponsesCompletedResponse()} + + chatResp, err := ChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{"seed": `42`}), + }, "chatgpt") + require.Nil(t, chatResp) + require.Error(t, err) + assert.Contains(t, err.Error(), "seed") + assert.Nil(t, provider.capturedReq) + }) +} + +func TestStreamChatViaResponses(t *testing.T) { + t.Run("forces stream and wraps converter", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{streamData: "data: [DONE]\n\n"} + + stream, err := StreamChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "gpt-5.1-codex", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Stream: true, + StreamOptions: &core.StreamOptions{IncludeUsage: true}, + }, "chatgpt") + require.NoError(t, err) + require.NotNil(t, stream) + defer func() { + _ = stream.Close() + }() + + require.NotNil(t, provider.capturedReq) + assert.True(t, provider.capturedReq.Stream, "upstream Responses call must stream") + assert.Nil(t, provider.capturedReq.StreamOptions, "chat stream_options must not be forwarded") + }) + + t.Run("stream error passes through", func(t *testing.T) { + wantErr := errors.New("stream failed") + provider := &stubChatViaResponsesProvider{streamErr: wantErr} + + stream, err := StreamChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }, "chatgpt") + require.Nil(t, stream) + require.ErrorIs(t, err, wantErr) + }) + + t.Run("usage policy forces the converter usage chunk", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{streamData: chatViaResponsesTextStream()} + ctx := core.WithEnforceReturningUsageData(context.Background(), true) + + stream, err := StreamChatViaResponses(ctx, provider, &core.ChatRequest{ + Model: "gpt-5.1-codex", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Stream: true, + }, "chatgpt") + require.NoError(t, err) + defer func() { + _ = stream.Close() + }() + + raw, err := io.ReadAll(stream) + require.NoError(t, err) + assert.Contains(t, string(raw), `"usage"`) + assert.Nil(t, provider.capturedReq.StreamOptions, "chat stream_options must not be forwarded") + }) + + t.Run("unsupported field fails before upstream call", func(t *testing.T) { + provider := &stubChatViaResponsesProvider{} + + stream, err := StreamChatViaResponses(context.Background(), provider, &core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{"logprobs": `true`}), + }, "chatgpt") + require.Nil(t, stream) + require.Error(t, err) + assert.Contains(t, err.Error(), "logprobs") + assert.Nil(t, provider.capturedReq) + }) +} diff --git a/internal/providers/chatgpt/chatgpt.go b/internal/providers/chatgpt/chatgpt.go index ab121e8af..cae976ccb 100644 --- a/internal/providers/chatgpt/chatgpt.go +++ b/internal/providers/chatgpt/chatgpt.go @@ -43,9 +43,9 @@ var Registration = providers.Registration{ } // Provider implements the core.Provider interface for the ChatGPT Codex -// backend. Only the Responses surface is served: the upstream has no chat -// completions, embeddings, or models endpoint, and advertising them would -// route traffic that can only fail. +// backend. The upstream speaks only the Responses API: chat completions are +// translated onto it, while embeddings and the models endpoint have no +// upstream equivalent. type Provider struct { client *llmclient.Client keys *providers.Keyring @@ -139,15 +139,17 @@ func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesReque return providers.EnsureResponsesDone(stream), nil } -// ChatCompletion is unsupported: the ChatGPT Codex backend serves only the -// Responses API. Clients reach these models through /v1/responses. -func (p *Provider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { - return nil, unsupported("chat completions") +// ChatCompletion translates the chat request onto the Responses API: the Codex +// backend serves only Responses, so the request is converted, executed against +// p.Responses, and the response is converted back to a chat completion. +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return providers.ChatViaResponses(ctx, p, req, "chatgpt") } -// StreamChatCompletion is unsupported for the same reason as ChatCompletion. -func (p *Provider) StreamChatCompletion(_ context.Context, _ *core.ChatRequest) (io.ReadCloser, error) { - return nil, unsupported("chat completions") +// StreamChatCompletion is the streaming counterpart of ChatCompletion: the +// upstream Responses SSE stream is converted to chat completion chunks. +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + return providers.StreamChatViaResponses(ctx, p, req, "chatgpt") } // Embeddings is unsupported: the Codex backend exposes no embeddings endpoint. @@ -161,10 +163,12 @@ func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*cor // do that" apart from "your request was malformed". const unsupportedOperationCode = "unsupported_provider_operation" -// unsupported reports a surface the Codex backend does not serve. +// unsupported reports a surface the Codex backend does not serve. Chat +// completions are translated onto the Responses API, so only surfaces with +// no upstream endpoint at all (embeddings) reach this. func unsupported(surface string) error { return core.NewInvalidRequestErrorWithStatus(http.StatusNotImplemented, - "chatgpt serves only the Responses API; "+surface+" are not available on a ChatGPT subscription", + "chatgpt serves chat completions and the Responses API; "+surface+" are not available on a ChatGPT subscription", nil).WithCode(unsupportedOperationCode) } diff --git a/internal/providers/chatgpt/chatgpt_test.go b/internal/providers/chatgpt/chatgpt_test.go index fb6af00c9..68259e9ef 100644 --- a/internal/providers/chatgpt/chatgpt_test.go +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -233,19 +233,49 @@ func TestListModels(t *testing.T) { } } +// TestChatCompletionsDelegateToResponsesAdapter locks the wiring: chat +// requests run through the Chat-to-Responses translation, which rejects +// parameters with no Responses equivalent before any upstream call. A +// revert to unsupported() would fail this test. +func TestChatCompletionsDelegateToResponsesAdapter(t *testing.T) { + srv, capture := providertest.SSEServer(t, codexSSE) + provider := newTestProvider("token", srv.URL, srv.Client(), llmclient.Hooks{}) + req := &core.ChatRequest{ + Model: "gpt-5.6-terra", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "seed": json.RawMessage(`42`), + }), + } + + t.Run("ChatCompletion", func(t *testing.T) { + resp, err := provider.ChatCompletion(context.Background(), req) + require.Nil(t, resp) + require.Error(t, err) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.StatusCode) + assert.Contains(t, gatewayErr.Message, `"seed"`) + }) + + t.Run("StreamChatCompletion", func(t *testing.T) { + stream, err := provider.StreamChatCompletion(context.Background(), req) + require.Nil(t, stream) + require.Error(t, err) + assert.Contains(t, err.Error(), `"seed"`) + }) + + assert.Zero(t, capture.Count(), "rejected chat requests must not reach the upstream") +} + // TestUnsupportedSurfaces checks that surfaces the Codex backend does not // implement report a capability gap (501) rather than a malformed request. +// Chat completions are translated onto the Responses API, so only embeddings +// remain unsupported. func TestUnsupportedSurfaces(t *testing.T) { provider := New(providers.ProviderConfig{APIKey: "token"}, providers.ProviderOptions{}) calls := map[string]func() error{ - "ChatCompletion": func() error { - _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}) - return err - }, - "StreamChatCompletion": func() error { - _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}) - return err - }, "Embeddings": func() error { _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "gpt-5.6-terra"}) return err From e3b78c05d4fc561e224d1aefd15b7bce3b8ce340 Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 23:54:32 +0000 Subject: [PATCH 2/6] test(providers): cover chat-via-responses edge paths and fix testifylint findings --- internal/providers/chat_via_responses.go | 4 + .../providers/chat_via_responses_input.go | 2 + .../chat_via_responses_input_test.go | 72 ++++- .../providers/chat_via_responses_output.go | 1 + .../chat_via_responses_output_test.go | 43 ++- .../providers/chat_via_responses_stream.go | 9 + .../chat_via_responses_stream_test.go | 277 +++++++++++++++++- internal/providers/chat_via_responses_test.go | 21 ++ 8 files changed, 420 insertions(+), 9 deletions(-) diff --git a/internal/providers/chat_via_responses.go b/internal/providers/chat_via_responses.go index c63377be2..8330cb1ad 100644 --- a/internal/providers/chat_via_responses.go +++ b/internal/providers/chat_via_responses.go @@ -215,6 +215,8 @@ func flattenChatToolsForResponses(tools []map[string]any) ([]map[string]any, err func flattenChatToolForResponses(tool map[string]any) map[string]any { if len(tool) == 0 { + // Unreachable: flattenChatToolsForResponses only forwards tools whose + // type is "function", so the map always has at least that member. return tool } @@ -307,6 +309,8 @@ func ChatViaResponses(ctx context.Context, p ResponsesProvider, req *core.ChatRe chatResp := ConvertResponsesResponseToChat(resp) if chatResp == nil || len(chatResp.Choices) == 0 { + // Defensive: ConvertResponsesResponseToChat always returns a response + // with exactly one choice. return nil, core.NewNoChoicesProviderError(providerName) } return chatResp, nil diff --git a/internal/providers/chat_via_responses_input.go b/internal/providers/chat_via_responses_input.go index e06a3e4c2..09f5b490a 100644 --- a/internal/providers/chat_via_responses_input.go +++ b/internal/providers/chat_via_responses_input.go @@ -208,6 +208,8 @@ func chatContentToResponsesBlocks(content any, textType string) ([]any, error) { if text == "" { return nil, nil } + // Unreachable: ExtractTextContent only reads string and content-part + // content, so every type landing in this branch yields "". return []any{map[string]any{"type": textType, "text": text}}, nil } } diff --git a/internal/providers/chat_via_responses_input_test.go b/internal/providers/chat_via_responses_input_test.go index ecdd6ef3a..0d05adbe9 100644 --- a/internal/providers/chat_via_responses_input_test.go +++ b/internal/providers/chat_via_responses_input_test.go @@ -117,7 +117,7 @@ func TestConvertMessagesToResponsesInput_MultimodalParts(t *testing.T) { input, instructions, err := ConvertMessagesToResponsesInput(messages) require.NoError(t, err) - assert.Equal(t, "", instructions) + assert.Empty(t, instructions) items := responsesInputItems(t, input) require.Len(t, items, 1) @@ -368,3 +368,73 @@ func TestConvertMessagesToResponsesInput_RejectsUnknownPartType(t *testing.T) { }) } } + +func TestConvertMessagesToResponsesInput_ContentEdgeCases(t *testing.T) { + tests := []struct { + name string + content core.MessageContent + wantContent any + }{ + {name: "nil content becomes an empty string", content: nil, wantContent: ""}, + {name: "empty string content stays empty", content: "", wantContent: ""}, + {name: "empty parts array becomes an empty string", content: []any{}, wantContent: ""}, + {name: "unnormalizable parts become an empty string", content: []any{"nope"}, wantContent: ""}, + {name: "non-text scalar content becomes an empty string", content: 42, wantContent: ""}, + { + name: "dynamic JSON parts convert", + content: []any{map[string]any{"type": "text", "text": "hi"}}, + wantContent: []any{map[string]any{"type": "input_text", "text": "hi"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "user", Content: tt.content}, + }) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Equal(t, tt.wantContent, items[0].Content) + }) + } +} + +func TestConvertMessagesToResponsesInput_MalformedPartsSkipped(t *testing.T) { + // Malformed parts of a known type are skipped, matching + // buildResponsesContentItemsFromParts on the inbound path. + messages := []core.Message{ + {Role: "user", Content: []core.ContentPart{ + {Type: "text", Text: ""}, + {Type: "image_url"}, + {Type: "image_url", ImageURL: &core.ImageURLContent{URL: " "}}, + {Type: "input_audio"}, + {Type: "input_audio", InputAudio: &core.InputAudioContent{Data: "aGk="}}, + {Type: "file", File: &core.FileContent{}}, + {Type: "file", File: &core.FileContent{FileData: "aGk=", Filename: "a.pdf"}}, + }}, + } + + input, _, err := ConvertMessagesToResponsesInput(messages) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + blocks, ok := items[0].Content.([]any) + require.True(t, ok, "content must be []any blocks, got %T", items[0].Content) + require.Len(t, blocks, 1) + assert.Equal(t, map[string]any{"type": "input_file", "file_data": "aGk=", "filename": "a.pdf"}, blocks[0]) +} + +func TestConvertMessagesToResponsesInput_ToolOutputUnserializable(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "tool", ToolCallID: "call_1", Content: map[string]any{"callback": func() {}}}, + }) + require.Error(t, err) + assert.Nil(t, input) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, "function_call_output") +} diff --git a/internal/providers/chat_via_responses_output.go b/internal/providers/chat_via_responses_output.go index b784ef2ac..1c0fd4588 100644 --- a/internal/providers/chat_via_responses_output.go +++ b/internal/providers/chat_via_responses_output.go @@ -157,6 +157,7 @@ func responsesOutputReasoningText(item core.ResponsesOutputItem) string { func chatViaResponsesJSONString(s string) json.RawMessage { raw, err := json.Marshal(s) if err != nil { + // Unreachable: marshaling a plain string cannot fail. return nil } return raw diff --git a/internal/providers/chat_via_responses_output_test.go b/internal/providers/chat_via_responses_output_test.go index cf64c52d6..3b0e6c83a 100644 --- a/internal/providers/chat_via_responses_output_test.go +++ b/internal/providers/chat_via_responses_output_test.go @@ -112,7 +112,7 @@ func TestConvertResponsesResponseToChat_ToolCalls(t *testing.T) { choice := chat.Choices[0] assert.Equal(t, "tool_calls", choice.FinishReason) assert.Equal(t, tt.want, choice.Message.ToolCalls) - assert.Equal(t, "", choice.Message.Content) + assert.Empty(t, choice.Message.Content) }) } } @@ -240,7 +240,7 @@ func TestConvertResponsesResponseToChat_Refusal(t *testing.T) { require.Len(t, chat.Choices, 1) message := chat.Choices[0].Message - assert.Equal(t, "", message.Content) + assert.Empty(t, message.Content) raw := message.ExtraFields.Lookup("refusal") require.NotEmpty(t, raw, "refusal part must surface as the message refusal member") var refusal string @@ -351,3 +351,42 @@ func TestConvertResponsesResponseToChat_MintsClientFacingID(t *testing.T) { assert.NotContains(t, chat.ID, resp.ID, "upstream resp_ ID must not leak onto the chat surface") assert.NotEqual(t, chat.ID, ConvertResponsesResponseToChat(resp).ID, "each conversion mints a fresh ID") } + +func TestConvertResponsesResponseToChat_ReasoningWithoutReadableText(t *testing.T) { + tests := []struct { + name string + item core.ResponsesOutputItem + }{ + { + name: "encrypted-only reasoning has no readable text", + item: core.ResponsesOutputItem{ + ID: "rs_1", + Type: "reasoning", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + core.ExtraContentField: json.RawMessage(`{"openai":{"encrypted_content":"abc"}}`), + }), + }, + }, + { + name: "malformed summary is ignored", + item: core.ResponsesOutputItem{ + ID: "rs_1", + Type: "reasoning", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "summary": json.RawMessage(`"not-an-array"`), + }), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chat := ConvertResponsesResponseToChat(&core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: []core.ResponsesOutputItem{tt.item}, + }) + require.Len(t, chat.Choices, 1) + assert.Nil(t, chat.Choices[0].Message.ExtraFields.Lookup("reasoning_content")) + }) + } +} diff --git a/internal/providers/chat_via_responses_stream.go b/internal/providers/chat_via_responses_stream.go index fca46e091..895d74bd4 100644 --- a/internal/providers/chat_via_responses_stream.go +++ b/internal/providers/chat_via_responses_stream.go @@ -444,6 +444,8 @@ func (sc *OpenAIChatStreamConverter) failTerminalError(upstreamError *responsesS // names no code gets "provider_error". func (sc *OpenAIChatStreamConverter) failUpstream(code, message string) { if sc.finished || sc.failed { + // Unreachable: the only callers (processEvent's "error" case and + // failTerminalError) run after processEvent's finished/failed guard. return } sc.failed = true @@ -462,6 +464,8 @@ func (sc *OpenAIChatStreamConverter) failUpstream(code, message string) { // Read returns the read failure wrapped in streaming.ErrStreamIncomplete. func (sc *OpenAIChatStreamConverter) failTruncated(err error) { if sc.finished || sc.failed { + // Unreachable: Read, the only caller, invokes failTruncated only when + // neither flag is set. return } sc.failed = true @@ -482,6 +486,7 @@ func (sc *OpenAIChatStreamConverter) emitError(code, message string) { func (sc *OpenAIChatStreamConverter) emitChunk(delta map[string]any, finishReason *string) { if delta == nil { + // Defensive: every caller passes a map literal. delta = map[string]any{} } sc.emitPayload(chatCompletionStreamChunk{ @@ -501,6 +506,8 @@ func (sc *OpenAIChatStreamConverter) emitChunk(delta map[string]any, finishReaso func (sc *OpenAIChatStreamConverter) emitPayload(payload any) { data, err := json.Marshal(payload) if err != nil { + // Unreachable: payloads are fixed chunk and error shapes built from + // marshalable values only. return } event := streaming.Event{Data: data} @@ -540,6 +547,8 @@ func (sc *OpenAIChatStreamConverter) Read(p []byte) (int, error) { if sc.buffer.Len() > 0 { return sc.buffer.Read(p), nil } + // Defensive: every path that sets finished or failed appends bytes to + // the buffer, so it is never empty at this point. if sc.endErr != nil { return sc.closeRead(sc.endErr) } diff --git a/internal/providers/chat_via_responses_stream_test.go b/internal/providers/chat_via_responses_stream_test.go index a1a63cdfb..da44f6a4c 100644 --- a/internal/providers/chat_via_responses_stream_test.go +++ b/internal/providers/chat_via_responses_stream_test.go @@ -1,7 +1,6 @@ package providers import ( - "errors" "io" "strings" "testing" @@ -168,7 +167,7 @@ func TestOpenAIChatStreamConverter_SingleToolCall(t *testing.T) { function, ok := call["function"].(map[string]any) require.True(t, ok) assert.Equal(t, "get_weather", function["name"]) - assert.Equal(t, "", function["arguments"]) + assert.Empty(t, function["arguments"]) first := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) assert.Equal(t, float64(0), first["index"]) @@ -385,7 +384,7 @@ func TestOpenAIChatStreamConverter_FailedMidStream(t *testing.T) { events, raw, err := readChatViaResponsesStream(t, stream, false) require.Error(t, err) - assert.True(t, errors.Is(err, streaming.ErrStreamIncomplete), "err = %v", err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) // role + content delta + in-band error; no finish chunk, no [DONE]. require.Len(t, events, 3) @@ -410,7 +409,7 @@ func TestOpenAIChatStreamConverter_TopLevelErrorEvent(t *testing.T) { events, _, err := readChatViaResponsesStream(t, stream, false) require.Error(t, err) - assert.True(t, errors.Is(err, streaming.ErrStreamIncomplete), "err = %v", err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) require.Len(t, events, 2) errorPayload, ok := events[1].Payload["error"].(map[string]any) @@ -429,7 +428,7 @@ func TestOpenAIChatStreamConverter_EndsWithoutTerminalEvent(t *testing.T) { events, raw, err := readChatViaResponsesStream(t, stream, false) require.Error(t, err) - assert.True(t, errors.Is(err, streaming.ErrStreamIncomplete), "err = %v", err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) // role + content delta + in-band truncation error; never a stop finish. require.Len(t, events, 3) @@ -464,7 +463,7 @@ func TestOpenAIChatStreamConverter_DataOnlyParsing(t *testing.T) { crlfEvents, _, err := readChatViaResponsesStream(t, decorated.String(), false) require.NoError(t, err) - require.Equal(t, len(plainEvents), len(crlfEvents)) + require.Len(t, crlfEvents, len(plainEvents)) for i := range plainEvents { assert.Equal(t, plainEvents[i].Done, crlfEvents[i].Done) if plainEvents[i].Done { @@ -473,3 +472,269 @@ func TestOpenAIChatStreamConverter_DataOnlyParsing(t *testing.T) { assert.Equal(t, plainEvents[i].Payload["choices"], crlfEvents[i].Payload["choices"], "chunk %d differs under CRLF framing", i) } } + +func TestOpenAIChatStreamConverter_IgnoresNoiseAndMalformedEvents(t *testing.T) { + // SSE comment lines, non-JSON data (a stray [DONE]), and malformed JSON + // payloads are skipped; classification happens on well-formed events only. + stream := ": keep-alive\n\n" + + "data: [DONE]\n\n" + + "data: {not-json\n\n" + + chatViaResponsesTextStream() + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // Same output as the plain text stream: role + 2 content + finish + [DONE]. + require.Len(t, events, 5) + assert.Equal(t, "Hello", chatChunkDelta(t, events[1].Payload)["content"]) + assert.True(t, events[4].Done) +} + +func TestOpenAIChatStreamConverter_MalformedOutputItemAdded(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":"bogus"}`, + `{"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`, + `{"type":"response.completed","sequence_number":4,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + content + finish + [DONE]; the malformed item event is skipped. + require.Len(t, events, 4) + assert.Equal(t, "hi", chatChunkDelta(t, events[1].Payload)["content"]) + assert.True(t, events[3].Done) +} + +func TestOpenAIChatStreamConverter_ArgumentsDeltaForUnannouncedItem(t *testing.T) { + // A delta for an item the stream never announced still lands under a + // stable dense index (Postel's law). + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"city\":\"Warsaw\"}"}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"Warsaw\"}"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + synthesized start chunk + arguments delta + finish + [DONE]. + require.Len(t, events, 5) + + start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), start["index"]) + assert.Nil(t, start["id"], "an unannounced item has no call id") + function, ok := start["function"].(map[string]any) + require.True(t, ok) + assert.Empty(t, function["arguments"]) + + delta := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), delta["index"]) + assert.Equal(t, `{"city":"Warsaw"}`, delta["function"].(map[string]any)["arguments"]) + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) +} + +func TestOpenAIChatStreamConverter_EmptyArgumentsDelta(t *testing.T) { + // An empty arguments delta still registers the item and emits its start + // chunk, but no arguments chunk. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":""}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + synthesized start chunk + finish + [DONE]. + require.Len(t, events, 4) + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[2].Payload)) + assert.True(t, events[3].Done) +} + +func TestOpenAIChatStreamConverter_ArgumentsDeltaFallsBackToOutputIndex(t *testing.T) { + // A delta carrying an item_id the stream never announced but a known + // output_index routes to the item registered under that index. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_stale","output_index":0,"delta":"{\"city\":\"Warsaw\"}"}`, + `{"type":"response.completed","sequence_number":4,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"Warsaw\"}"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk + one arguments delta routed by output_index + + // finish + [DONE]. + require.Len(t, events, 5) + delta := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), delta["index"]) + assert.Equal(t, `{"city":"Warsaw"}`, delta["function"].(map[string]any)["arguments"]) +} + +func TestOpenAIChatStreamConverter_ArgumentsDeltaForMessageItemIgnored(t *testing.T) { + // An arguments delta naming an item announced as a message has no dense + // tool-call index and is dropped. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_1","type":"message","role":"assistant"}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"delta":"{}"}`, + `{"type":"response.completed","sequence_number":4,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + finish + [DONE]. + require.Len(t, events, 3) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[1].Payload)) + assert.True(t, events[2].Done) +} + +func TestOpenAIChatStreamConverter_MalformedTerminalPayload(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.completed","sequence_number":2,"response":"oops"}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + // role + in-band truncation error: an unreadable terminal payload is no + // terminal event. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "stream_incomplete", errorPayload["code"]) +} + +func TestOpenAIChatStreamConverter_TerminalStatusFromEventType(t *testing.T) { + // The event type carries the status when the terminal payload omits it. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.incomplete","sequence_number":2,"response":{"id":"resp_abc123","incomplete_details":{"reason":"max_output_tokens"},"output":[]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + finish + [DONE]. + require.Len(t, events, 3) + assert.Equal(t, "length", chatChunkFinishReason(t, events[1].Payload)) + assert.True(t, events[2].Done) +} + +func TestOpenAIChatStreamConverter_CompletedEventCarryingFailedStatus(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.completed","sequence_number":2,"response":{"id":"resp_abc123","status":"failed","error":{"code":"server_error","message":"boom"}}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + + // role + in-band error; the payload status wins over the event type. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "boom", errorPayload["message"]) + assert.Equal(t, "server_error", errorPayload["code"]) +} + +func TestOpenAIChatStreamConverter_IncompleteWithoutDetails(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.incomplete","sequence_number":2,"response":{"id":"resp_abc123","status":"incomplete","output":[]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + finish with a null finish_reason + [DONE]: no honest mapping + // exists without incomplete_details. + require.Len(t, events, 3) + assert.Nil(t, chatChunkFinishReason(t, events[1].Payload)) + assert.True(t, events[2].Done) +} + +func TestOpenAIChatStreamConverter_ErrorEventWithoutCodeOrMessage(t *testing.T) { + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"error","sequence_number":1}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + + // role + in-band error with the repo's default code and message. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "provider_error", errorPayload["code"]) + assert.Equal(t, "provider stream failed", errorPayload["message"]) +} + +func TestOpenAIChatStreamConverter_UnterminatedTrailingEvent(t *testing.T) { + // The final event lacks its closing blank line; the scanner flush on EOF + // must still deliver it. + stream := "data: " + chatViaResponsesCreated + "\n\n" + + `data: {"type":"response.completed","sequence_number":2,"response":{"id":"resp_abc123","status":"completed","output":[]}}` + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + finish + [DONE]. + require.Len(t, events, 3) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[1].Payload)) + assert.True(t, events[2].Done) +} + +func TestOpenAIChatStreamConverter_ReadAfterClose(t *testing.T) { + converter := NewOpenAIChatStreamConverter( + io.NopCloser(strings.NewReader(chatViaResponsesTextStream())), + "gpt-5.1-codex", "test-provider", false, + ) + require.NoError(t, converter.Close()) + + n, err := converter.Read(make([]byte, 4096)) + assert.Zero(t, n) + require.ErrorIs(t, err, io.EOF) +} + +// stubZeroThenDataReader returns a zero-byte read once (a legal io.Reader +// result) before serving the stream data. +type stubZeroThenDataReader struct { + data []byte + stalled bool +} + +func (r *stubZeroThenDataReader) Read(p []byte) (int, error) { + if !r.stalled { + r.stalled = true + return 0, nil + } + if len(r.data) == 0 { + return 0, io.EOF + } + n := copy(p, r.data) + r.data = r.data[n:] + return n, nil +} + +func (r *stubZeroThenDataReader) Close() error { return nil } + +func TestOpenAIChatStreamConverter_ZeroByteReadRetried(t *testing.T) { + converter := NewOpenAIChatStreamConverter( + &stubZeroThenDataReader{data: []byte(chatViaResponsesTextStream())}, + "gpt-5.1-codex", "test-provider", false, + ) + defer func() { + _ = converter.Close() + }() + + n, err := converter.Read(make([]byte, 4096)) + require.NoError(t, err) + assert.Zero(t, n, "a zero-byte upstream read must surface as (0, nil)") + + raw, err := io.ReadAll(converter) + require.NoError(t, err) + assert.Contains(t, string(raw), "chat.completion.chunk") + assert.Contains(t, string(raw), "data: [DONE]") +} diff --git a/internal/providers/chat_via_responses_test.go b/internal/providers/chat_via_responses_test.go index d64a1619c..c879080d3 100644 --- a/internal/providers/chat_via_responses_test.go +++ b/internal/providers/chat_via_responses_test.go @@ -143,6 +143,21 @@ func TestConvertChatRequestToResponses_MaxTokensMapping(t *testing.T) { }) } +func TestConvertChatRequestToResponses_MessageConversionError(t *testing.T) { + // A message content part with no Responses equivalent fails the whole + // translation, and the message error reaches the caller unchanged. + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{ + {Role: "user", Content: []core.ContentPart{ + {Type: "video_url", VideoURL: &core.VideoURLContent{URL: "https://example.com/v.mp4"}}, + }}, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "video_url") +} + func TestConvertChatRequestToResponses_MessagesAndInstructions(t *testing.T) { req := &core.ChatRequest{ Model: "m", @@ -312,6 +327,12 @@ func TestConvertChatRequestToResponses_ResponseFormat(t *testing.T) { }, responsesReq.Text) }) + t.Run("json_schema without schema object rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(newRequest(`{"type":"json_schema"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "response_format") + }) + t.Run("unknown format type rejected", func(t *testing.T) { _, err := ConvertChatRequestToResponses(newRequest(`{"type":"grammar"}`)) require.Error(t, err) From 4458c21992d2a48ba72732f2277c7c25b192344c Mon Sep 17 00:00:00 2001 From: weselben Date: Thu, 24 Sep 2026 00:32:46 +0000 Subject: [PATCH 3/6] fix(providers): address chat-via-responses review findings --- docs/providers/chatgpt.mdx | 16 +- docs/providers/overview.mdx | 5 +- internal/providers/chat_via_responses.go | 95 +++++++-- .../providers/chat_via_responses_input.go | 89 +++++++-- .../chat_via_responses_input_test.go | 117 ++++++++++- .../providers/chat_via_responses_output.go | 6 +- .../chat_via_responses_output_test.go | 24 +++ .../providers/chat_via_responses_stream.go | 132 ++++++++++--- .../chat_via_responses_stream_test.go | 181 ++++++++++++++++++ internal/providers/chat_via_responses_test.go | 102 +++++++++- 10 files changed, 692 insertions(+), 75 deletions(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index 30a4a1ede..282f75bb1 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -1,6 +1,6 @@ --- title: "ChatGPT subscription" -description: "Route Responses API traffic through a ChatGPT subscription instead of an OpenAI Platform API key." +description: "Route Responses API traffic — and chat completions translated onto it — through a ChatGPT subscription instead of an OpenAI Platform API key." icon: "message-circle" keywords: ["ChatGPT", "Codex", "subscription", "OAuth", "Responses API", "provider setup"] --- @@ -71,16 +71,18 @@ embeddings endpoint. Chat requests are converted to Responses requests, executed upstream, and converted back to chat completions. The returned `chatcmpl-` IDs are labels, -not resource handles: the backend pins `store: false` and rejects -`previous_response_id`, so no chaining is possible and chat clients resend -full history as usual. +not resource handles: GoModel pins `store: false` and drops +`previous_response_id` (the backend allows neither), so no chaining is +possible and chat clients resend full history as usual. Chat parameters with no Responses equivalent are rejected with a `400` that -names the field, before any upstream call: `n` greater than 1, `logit_bias`, +names the field, before any upstream call: `n` other than 1, `logit_bias`, `stop`, `seed`, `frequency_penalty`, `presence_penalty`, `logprobs`, `top_logprobs`, `modalities`, `audio`, `web_search_options`, and -the deprecated `functions` / `function_call` pair. `prediction` is dropped -instead: it is a speed hint and never changes the answer. +the deprecated `functions` / `function_call` pair. Zero spellings of +`logprobs`, `top_logprobs`, and the penalties (`false` / `0`) are tolerated: +they change nothing. `prediction` is dropped instead: it is a speed hint and +never changes the answer. The backend also validates against a strict parameter allowlist. GoModel adapts requests rather than failing them, so callers keep using the standard diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 3d0f3f69b..8e262d115 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -147,8 +147,9 @@ support, not every individual model capability exposed by an upstream provider. reject requests that omit it (override with `OPENCODE_GO_DEFAULT_REASONING_EFFORT`). Set `OPENCODE_GO_API_KEY`; the base URL defaults to `https://opencode.ai/zen/go/v1`. -- **ChatGPT subscription** — serves `/v1/responses` only, billed against the - ChatGPT plan's quota rather than API credit. The upstream accepts a strict +- **ChatGPT subscription** — serves `/v1/responses` and `/v1/chat/completions` + (translated onto Responses), billed against the ChatGPT plan's quota rather + than API credit. The upstream accepts a strict parameter allowlist and streams only; GoModel adapts requests and collapses the stream for non-streaming callers. Set `CHATGPT_API_KEY` to the access token from `codex login`. Reported cost is not real spend: these model IDs diff --git a/internal/providers/chat_via_responses.go b/internal/providers/chat_via_responses.go index 8330cb1ad..547201f11 100644 --- a/internal/providers/chat_via_responses.go +++ b/internal/providers/chat_via_responses.go @@ -61,18 +61,24 @@ func ConvertChatRequestToResponses(req *core.ChatRequest) (*core.ResponsesReques // prediction and n have no Responses equivalent: prediction is a pure // speed hint and a validated n=1 is a no-op, so neither is rejected — // but both are stripped from the translated request rather than leaking - // upstream as unknown fields. - responsesReq.ExtraFields = responsesReq.ExtraFields.Without("prediction", "n") + // upstream as unknown fields. The tolerated zero-value extras + // (validation accepts only null/zero for them) are stripped likewise. + responsesReq.ExtraFields = responsesReq.ExtraFields.Without("prediction", "n", + "logprobs", "top_logprobs", "frequency_penalty", "presence_penalty") // An explicit max_completion_tokens extra wins over the mapped max_tokens, // mirroring the documented Bailian quirk. Both map to max_output_tokens. - if raw := responsesReq.ExtraFields.Lookup("max_completion_tokens"); raw != nil { + // The extra never travels upstream: an explicit null spells "not set" (the + // max_tokens fallback stays effective), and a non-integer value is a 400 + // rather than an unknown field the upstream rejects less clearly. + if raw := responsesReq.ExtraFields.Lookup("max_completion_tokens"); !core.IsJSONNull(raw) { var maxCompletionTokens int - if err := json.Unmarshal(raw, &maxCompletionTokens); err == nil { - responsesReq.MaxOutputTokens = &maxCompletionTokens - responsesReq.ExtraFields = responsesReq.ExtraFields.Without("max_completion_tokens") + if err := json.Unmarshal(raw, &maxCompletionTokens); err != nil { + return nil, unsupportedChatResponsesTranslationField("max_completion_tokens") } + responsesReq.MaxOutputTokens = &maxCompletionTokens } + responsesReq.ExtraFields = responsesReq.ExtraFields.Without("max_completion_tokens") if responsesReq.MaxOutputTokens == nil && req.MaxTokens != nil { responsesReq.MaxOutputTokens = req.MaxTokens } @@ -135,6 +141,17 @@ var unsupportedChatResponsesTranslationExtraFields = []string{ "functions", } +// zeroValueToleratedChatExtraFields lists the unsupported fields whose zero +// value spells "not set": logprobs:false, top_logprobs:0, and the penalties +// at 0 change nothing, so clients that send them unconditionally must not be +// rejected. A non-zero value still is. +var zeroValueToleratedChatExtraFields = map[string]bool{ + "logprobs": true, + "top_logprobs": true, + "frequency_penalty": true, + "presence_penalty": true, +} + func validateChatRequestForResponsesTranslation(req *core.ChatRequest) error { if raw := req.ExtraFields.Lookup("n"); !core.IsJSONNull(raw) { var n float64 @@ -145,15 +162,35 @@ func validateChatRequestForResponsesTranslation(req *core.ChatRequest) error { } } for _, field := range unsupportedChatResponsesTranslationExtraFields { + raw := req.ExtraFields.Lookup(field) // An explicit JSON null spells "not set" on the wire, so it is // tolerated like an absent field rather than rejected. - if raw := req.ExtraFields.Lookup(field); !core.IsJSONNull(raw) { - return unsupportedChatResponsesTranslationField(field) + if core.IsJSONNull(raw) { + continue + } + if zeroValueToleratedChatExtraFields[field] && isZeroJSONValue(raw) { + continue } + return unsupportedChatResponsesTranslationField(field) } return nil } +// isZeroJSONValue reports whether raw decodes to a JSON false or 0. +func isZeroJSONValue(raw json.RawMessage) bool { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return false + } + switch v := value.(type) { + case bool: + return !v + case float64: + return v == 0 + } + return false +} + func unsupportedChatResponsesTranslationField(field string) error { return core.NewInvalidRequestError( fmt.Sprintf("chat field %q is only supported by native Chat Completions providers; use an OpenAI-compatible provider or passthrough for this request", field), @@ -194,9 +231,10 @@ func chatResponseFormatToResponsesText(raw json.RawMessage) (any, error) { } // flattenChatToolsForResponses flattens chat function tools -// ({type:"function", function:{...}}) into the Responses shape -// ({type:"function", name, ...}), the inverse of normalizeResponsesToolForChat. -// Non-function tools have no meaning on a chat request and are rejected. +// ({type:"function", function:{...}}) and custom tools +// ({type:"custom", custom:{...}}) into the flat Responses shape, the inverse +// of normalizeResponsesToolForChat. Other tool types have no meaning on a +// chat request and are rejected. func flattenChatToolsForResponses(tools []map[string]any) ([]map[string]any, error) { if len(tools) == 0 { return nil, nil @@ -205,7 +243,9 @@ func flattenChatToolsForResponses(tools []map[string]any) ([]map[string]any, err flattened := make([]map[string]any, 0, len(tools)) for _, tool := range tools { toolType, _ := tool["type"].(string) - if strings.TrimSpace(toolType) != "function" { + switch strings.TrimSpace(toolType) { + case "function", "custom": + default: return nil, unsupportedChatResponsesTranslationField("tools") } flattened = append(flattened, flattenChatToolForResponses(tool)) @@ -213,24 +253,32 @@ func flattenChatToolsForResponses(tools []map[string]any) ([]map[string]any, err return flattened, nil } +// flattenChatToolForResponses flattens one chat tool: its nested member +// ("function" or "custom", keyed by the type) dissolves into the flat +// Responses shape. A tool that is already flat passes through unchanged. func flattenChatToolForResponses(tool map[string]any) map[string]any { if len(tool) == 0 { // Unreachable: flattenChatToolsForResponses only forwards tools whose - // type is "function", so the map always has at least that member. + // type is "function" or "custom", so the map always has that member. return tool } - function, ok := tool["function"].(map[string]any) + nestedKey, payloadKeys := "function", []string{"name", "description", "parameters", "strict"} + if toolType, _ := tool["type"].(string); strings.TrimSpace(toolType) == "custom" { + nestedKey, payloadKeys = "custom", []string{"name", "description", "format"} + } + + nested, ok := tool[nestedKey].(map[string]any) if !ok { // Already flat (Responses-shaped); pass through unchanged. return cloneStringAnyMap(tool) } flattened := cloneStringAnyMap(tool) - delete(flattened, "function") - for _, key := range []string{"name", "description", "parameters", "strict"} { + delete(flattened, nestedKey) + for _, key := range payloadKeys { delete(flattened, key) - if value, ok := function[key]; ok { + if value, ok := nested[key]; ok { flattened[key] = value } } @@ -245,7 +293,14 @@ func flattenChatToolChoiceForResponses(choice any) (any, error) { return nil, nil } if choiceString, ok := choice.(string); ok { - return choiceString, nil + switch strings.TrimSpace(choiceString) { + case "auto", "required", "none": + return choiceString, nil + } + // Only the mode strings translate; anything else (a hosted-tool name, + // a vendor mode) has no Responses equivalent, mirroring the + // restriction normalizeResponsesToolChoiceForChat applies inbound. + return nil, unsupportedChatResponsesTranslationField("tool_choice") } choiceMap, ok := choice.(map[string]any) @@ -307,6 +362,10 @@ func ChatViaResponses(ctx context.Context, p ResponsesProvider, req *core.ChatRe return nil, providerErr } + // resp.Status is terminal here: the only ResponsesProvider in use + // (chatgpt) answers a non-streaming Responses call with + // collapseResponsesStream, which returns only the terminal event's + // response object or a 502 — in_progress/queued never reach this point. chatResp := ConvertResponsesResponseToChat(resp) if chatResp == nil || len(chatResp.Choices) == 0 { // Defensive: ConvertResponsesResponseToChat always returns a response diff --git a/internal/providers/chat_via_responses_input.go b/internal/providers/chat_via_responses_input.go index 09f5b490a..74284bd2c 100644 --- a/internal/providers/chat_via_responses_input.go +++ b/internal/providers/chat_via_responses_input.go @@ -11,10 +11,12 @@ import ( // ConvertMessagesToResponsesInput converts Chat Completions messages into a // Responses API input array plus a top-level instructions string. It is the -// inverse of ConvertResponsesInputToMessages: system and developer messages -// become instructions (joined with a blank line), user and assistant messages -// become message items, assistant tool calls become function_call items, and -// tool results become function_call_output items. +// inverse of ConvertResponsesInputToMessages: leading system and developer +// messages become instructions (joined with a blank line), while one +// appearing after any other role stays in place as a message item, keeping +// the conversation's order semantics; user and assistant messages become +// message items, assistant tool calls become function_call items, and tool +// results become function_call_output items. // // Message content parts are emitted with Responses spellings as generic // blocks rather than core.ContentPart values: ContentPart marshals to the @@ -23,26 +25,47 @@ import ( func ConvertMessagesToResponsesInput(messages []core.Message) (input any, instructions string, err error) { items := make([]core.ResponsesInputElement, 0, len(messages)) instructionParts := make([]string, 0, 1) + leading := true for i := range messages { msg := messages[i] + if strings.TrimSpace(msg.Role) == "" { + return nil, "", core.NewInvalidRequestError( + "chat message role is required to translate messages onto Responses input", + nil, + ) + } switch msg.Role { case "system", "developer": - if text := core.ExtractTextContent(msg.Content); strings.TrimSpace(text) != "" { - instructionParts = append(instructionParts, text) + if leading { + if text := core.ExtractTextContent(msg.Content); strings.TrimSpace(text) != "" { + instructionParts = append(instructionParts, text) + } + continue } + // A mid-conversation system/developer message is no global + // instruction: Responses accepts these roles in input, so the + // message stays where the caller put it. + item, convErr := chatMessageToResponsesItem(msg, "input_text") + if convErr != nil { + return nil, "", convErr + } + items = append(items, item) case "assistant": + leading = false assistantItems, convErr := chatAssistantMessageToResponsesItems(msg) if convErr != nil { return nil, "", convErr } items = append(items, assistantItems...) case "tool": + leading = false item, convErr := chatToolMessageToResponsesItem(msg) if convErr != nil { return nil, "", convErr } items = append(items, item) default: + leading = false // "user" and any other role travel as a plain message item. item, convErr := chatMessageToResponsesItem(msg, "input_text") if convErr != nil { @@ -51,10 +74,19 @@ func ConvertMessagesToResponsesInput(messages []core.Message) (input any, instru items = append(items, item) } } + instructions = strings.Join(instructionParts, "\n\n") if len(items) == 0 { - return nil, strings.Join(instructionParts, "\n\n"), nil + if strings.TrimSpace(instructions) == "" { + // An empty input marshals as "input":null upstream, which the + // Responses API rejects with a less clear error. + return nil, "", core.NewInvalidRequestError( + "chat messages must yield at least one Responses input item or a non-empty instructions string", + nil, + ) + } + return nil, instructions, nil } - return items, strings.Join(instructionParts, "\n\n"), nil + return items, instructions, nil } // chatMessageToResponsesItem converts a plain (non-tool) chat message into a @@ -104,17 +136,33 @@ func chatAssistantMessageToResponsesItems(msg core.Message) ([]core.ResponsesInp Type: "function_call", CallID: callID, Name: call.Function.Name, - Arguments: call.Function.Arguments, + Arguments: normalizeChatToolCallArguments(call.Function.Arguments), ExtraFields: toolCallExtraContent(call.ExtraFields), }) } return items, nil } +// normalizeChatToolCallArguments turns empty or whitespace-only tool-call +// arguments into "{}", the smallest valid JSON object: providers reject an +// empty arguments string on a function_call item. +func normalizeChatToolCallArguments(arguments string) string { + if strings.TrimSpace(arguments) == "" { + return "{}" + } + return arguments +} + // chatToolMessageToResponsesItem converts a tool-role message into a // function_call_output item. Non-string content is stringified via JSON, // mirroring stringifyResponsesInputValueWithError on the inbound path. func chatToolMessageToResponsesItem(msg core.Message) (core.ResponsesInputElement, error) { + if strings.TrimSpace(msg.ToolCallID) == "" { + return core.ResponsesInputElement{}, core.NewInvalidRequestError( + "chat tool message requires tool_call_id to translate onto a Responses function_call_output item", + nil, + ) + } output, err := stringifyResponsesInputValueWithError(msg.Content) if err != nil { return core.ResponsesInputElement{}, core.NewInvalidRequestError( @@ -123,10 +171,12 @@ func chatToolMessageToResponsesItem(msg core.Message) (core.ResponsesInputElemen ) } return core.ResponsesInputElement{ - Type: "function_call_output", - CallID: msg.ToolCallID, - Output: output, - ExtraFields: core.CloneUnknownJSONFields(msg.ExtraFields), + Type: "function_call_output", + CallID: msg.ToolCallID, + Output: output, + // The chat-only "name" member must not travel onto the item: strict + // upstream allowlists (Codex) reject unknown members. + ExtraFields: core.CloneUnknownJSONFields(msg.ExtraFields).Without("name"), }, nil } @@ -175,11 +225,16 @@ func chatMessageReasoningText(msg core.Message) string { return "" } -// chatMessageExtraFieldsForResponses strips the chat-only reasoning members -// consumed by the reasoning replay from a message's unknown fields; every -// other extension travels onto the Responses item unchanged. +// chatMessageExtraFieldsForResponses strips the chat-only members consumed +// by the translation from a message's unknown fields; every other extension +// travels onto the Responses item unchanged. Stripped are the reasoning +// members (the reasoning replay item carries them), extra_content (same), +// "refusal" (an assistant refusal is content on a Responses item, not a +// member — replaying the chat member would leak a wrong-shape field), and +// the chat-only "name" (strict upstream allowlists such as Codex reject +// unknown members). func chatMessageExtraFieldsForResponses(fields core.UnknownJSONFields) core.UnknownJSONFields { - return fields.Without("reasoning_content", "reasoning", core.ExtraContentField) + return fields.Without("reasoning_content", "reasoning", "refusal", "name", core.ExtraContentField) } // chatContentToResponsesBlocks converts chat message content into Responses diff --git a/internal/providers/chat_via_responses_input_test.go b/internal/providers/chat_via_responses_input_test.go index 0d05adbe9..dfd1b8208 100644 --- a/internal/providers/chat_via_responses_input_test.go +++ b/internal/providers/chat_via_responses_input_test.go @@ -85,9 +85,17 @@ func TestConvertMessagesToResponsesInput_RoleMapping(t *testing.T) { }, }, { - name: "empty message list", - messages: nil, - wantInstructions: "", + name: "system message stays in place after another role", + messages: []core.Message{ + {Role: "system", Content: "be terse"}, + {Role: "user", Content: "hi"}, + {Role: "system", Content: "now be loud"}, + }, + wantInstructions: "be terse", + wantItems: []core.ResponsesInputElement{ + {Type: "message", Role: "user", Content: []any{map[string]any{"type": "input_text", "text": "hi"}}}, + {Type: "message", Role: "system", Content: []any{map[string]any{"type": "input_text", "text": "now be loud"}}}, + }, }, } for _, tt := range tests { @@ -438,3 +446,106 @@ func TestConvertMessagesToResponsesInput_ToolOutputUnserializable(t *testing.T) assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) assert.Contains(t, gatewayErr.Message, "function_call_output") } + +func TestConvertMessagesToResponsesInput_ToolMessageRequiresToolCallID(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "tool", Content: "done"}, + }) + require.Error(t, err) + assert.Nil(t, input) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, "tool_call_id") +} + +func TestConvertMessagesToResponsesInput_MessageRequiresRole(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Content: "hi"}, + }) + require.Error(t, err) + assert.Nil(t, input) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, "role") +} + +func TestConvertMessagesToResponsesInput_RequiresInputOrInstructions(t *testing.T) { + // An empty conversation would marshal as "input":null upstream; the + // gateway rejects it with a 400 naming messages instead. + for _, messages := range [][]core.Message{nil, {}} { + input, _, err := ConvertMessagesToResponsesInput(messages) + require.Error(t, err) + assert.Nil(t, input) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, http.StatusBadRequest, gatewayErr.HTTPStatusCode()) + assert.Contains(t, gatewayErr.Message, "messages") + } +} + +func TestConvertMessagesToResponsesInput_StripsChatOnlyExtras(t *testing.T) { + extras := func() core.UnknownJSONFields { + return core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "name": json.RawMessage(`"alice"`), + "refusal": json.RawMessage(`"no"`), + "x_vendor": json.RawMessage(`"keep-me"`), + }) + } + + t.Run("message items drop name and refusal", func(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "assistant", Content: "answer", ExtraFields: extras()}, + }) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Empty(t, items[0].ExtraFields.Lookup("name")) + assert.Empty(t, items[0].ExtraFields.Lookup("refusal")) + assert.JSONEq(t, `"keep-me"`, string(items[0].ExtraFields.Lookup("x_vendor"))) + }) + + t.Run("function_call_output drops name", func(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "tool", ToolCallID: "call_1", Content: "done", ExtraFields: extras()}, + }) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Empty(t, items[0].ExtraFields.Lookup("name")) + assert.JSONEq(t, `"keep-me"`, string(items[0].ExtraFields.Lookup("x_vendor"))) + }) +} + +func TestConvertMessagesToResponsesInput_NormalizesEmptyToolCallArguments(t *testing.T) { + tests := []struct { + name string + arguments string + }{ + {name: "empty", arguments: ""}, + {name: "whitespace", arguments: " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, _, err := ConvertMessagesToResponsesInput([]core.Message{ + { + Role: "assistant", + ToolCalls: []core.ToolCall{ + {ID: "call_1", Type: "function", Function: core.FunctionCall{Name: "ping", Arguments: tt.arguments}}, + }, + }, + }) + require.NoError(t, err) + + items := responsesInputItems(t, input) + require.Len(t, items, 1) + assert.Equal(t, `{}`, items[0].Arguments) + }) + } +} diff --git a/internal/providers/chat_via_responses_output.go b/internal/providers/chat_via_responses_output.go index 1c0fd4588..60ff0cff7 100644 --- a/internal/providers/chat_via_responses_output.go +++ b/internal/providers/chat_via_responses_output.go @@ -49,7 +49,7 @@ func ConvertResponsesResponseToChat(resp *core.ResponsesResponse) *core.ChatResp Type: "function", Function: core.FunctionCall{ Name: item.Name, - Arguments: item.Arguments, + Arguments: normalizeChatToolCallArguments(item.Arguments), }, // extra_content replay state rides along so the next // translated request can echo it back. @@ -60,6 +60,10 @@ func ConvertResponsesResponseToChat(resp *core.ResponsesResponse) *core.ChatResp reasoning = append(reasoning, text) } if replay := item.ExtraFields.Lookup(core.ExtraContentField); !core.IsJSONNull(replay) { + // Several reasoning items collapse onto one message, so the + // last item's replay state wins. That is deliberate: the + // final reasoning item is the state the next translated + // request must echo back. extra[core.ExtraContentField] = replay } } diff --git a/internal/providers/chat_via_responses_output_test.go b/internal/providers/chat_via_responses_output_test.go index 3b0e6c83a..038366199 100644 --- a/internal/providers/chat_via_responses_output_test.go +++ b/internal/providers/chat_via_responses_output_test.go @@ -330,6 +330,30 @@ func TestConvertResponsesResponseToChat_Usage(t *testing.T) { assert.Equal(t, 3, chat.Usage.CompletionTokensDetails.ReasoningTokens) } +func TestConvertResponsesResponseToChat_NormalizesEmptyToolCallArguments(t *testing.T) { + tests := []struct { + name string + arguments string + }{ + {name: "empty", arguments: ""}, + {name: "whitespace", arguments: " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chat := ConvertResponsesResponseToChat(&core.ResponsesResponse{ + ID: "resp_upstream", + Status: "completed", + Output: []core.ResponsesOutputItem{ + {ID: "fc_1", Type: "function_call", Status: "completed", CallID: "call_1", Name: "ping", Arguments: tt.arguments}, + }, + }) + require.Len(t, chat.Choices, 1) + require.Len(t, chat.Choices[0].Message.ToolCalls, 1) + assert.Equal(t, `{}`, chat.Choices[0].Message.ToolCalls[0].Function.Arguments) + }) + } +} + func TestConvertResponsesResponseToChat_WithoutUsage(t *testing.T) { resp := &core.ResponsesResponse{ID: "resp_upstream", Status: "completed"} diff --git a/internal/providers/chat_via_responses_stream.go b/internal/providers/chat_via_responses_stream.go index 895d74bd4..793c89df9 100644 --- a/internal/providers/chat_via_responses_stream.go +++ b/internal/providers/chat_via_responses_stream.go @@ -64,10 +64,13 @@ type OpenAIChatStreamConverter struct { items map[string]*chatStreamItemState itemsByIndex map[int]*chatStreamItemState nextToolCallIdx int - finished bool // terminal success events (finish chunk, usage, [DONE]) emitted - failed bool // in-band error emitted - closed bool - endErr error // returned by Read once the error bytes are drained + // extraContentSent marks the items whose replay state chunk already went + // out, so an output_item.done and the terminal event do not emit it twice. + extraContentSent map[string]bool + finished bool // terminal success events (finish chunk, usage, [DONE]) emitted + failed bool // in-band error emitted + closed bool + endErr error // returned by Read once the error bytes are drained } // NewOpenAIChatStreamConverter creates a converter that transforms a @@ -75,17 +78,18 @@ type OpenAIChatStreamConverter struct { // returned reader owns reader and closes it on Close. func NewOpenAIChatStreamConverter(reader io.ReadCloser, model, provider string, includeUsage bool) io.ReadCloser { return &OpenAIChatStreamConverter{ - reader: reader, - model: model, - provider: provider, - includeUsage: includeUsage, - chatID: "chatcmpl-" + uuid.New().String(), - created: time.Now().Unix(), - scanner: streaming.EventScanner{MaxEventBytes: maxResponsesStreamEventBytes}, - buffer: streaming.NewStreamBuffer(4096), - readBuf: make([]byte, 4096), - items: make(map[string]*chatStreamItemState), - itemsByIndex: make(map[int]*chatStreamItemState), + reader: reader, + model: model, + provider: provider, + includeUsage: includeUsage, + chatID: "chatcmpl-" + uuid.New().String(), + created: time.Now().Unix(), + scanner: streaming.EventScanner{MaxEventBytes: maxResponsesStreamEventBytes}, + buffer: streaming.NewStreamBuffer(4096), + readBuf: make([]byte, 4096), + items: make(map[string]*chatStreamItemState), + itemsByIndex: make(map[int]*chatStreamItemState), + extraContentSent: make(map[string]bool), } } @@ -114,12 +118,14 @@ type responsesStreamEventView struct { } // responsesStreamItemView decodes the item of a response.output_item.added -// event. +// or response.output_item.done event. type responsesStreamItemView struct { ID string `json:"id"` Type string `json:"type"` CallID string `json:"call_id"` Name string `json:"name"` + // ExtraContent is the item's replay state, relayed on completion. + ExtraContent json.RawMessage `json:"extra_content"` } // responsesStreamErrorView decodes the error member of a failed terminal @@ -136,7 +142,9 @@ type responsesStreamErrorView struct { type responsesTerminalResponseView struct { Status string `json:"status"` Output []struct { - Type string `json:"type"` + ID string `json:"id"` + Type string `json:"type"` + ExtraContent json.RawMessage `json:"extra_content"` } `json:"output"` IncompleteDetails *struct { Reason string `json:"reason"` @@ -187,7 +195,15 @@ type chatCompletionStreamChoice struct { // processEvent translates one upstream SSE event into chat chunks appended // to the output buffer. func (sc *OpenAIChatStreamConverter) processEvent(raw streaming.RawEvent) { - if sc.finished || sc.failed || raw.Comment || raw.Oversized { + if sc.finished || sc.failed || raw.Comment { + return + } + if raw.Oversized { + // An oversized event was never parsed, so the deltas it carried are + // gone and the stream can no longer be trusted. Fail closed with + // ErrEventTooLarge, mirroring NewTransformedSSEStream, instead of + // silently dropping it and finishing as if complete. + sc.failTruncated(streaming.ErrEventTooLarge) return } data := bytes.TrimSpace(raw.Data) @@ -213,6 +229,8 @@ func (sc *OpenAIChatStreamConverter) processEvent(raw streaming.RawEvent) { sc.emitDelta(map[string]any{"reasoning_content": event.Delta}) case "response.function_call_arguments.delta": sc.handleArgumentsDelta(event.ItemID, event.OutputIndex, event.Delta) + case "response.output_item.done": + sc.handleItemDone(event.OutputIndex, event.Item) case "response.completed", "response.incomplete": sc.handleTerminal(event.Type, event.Response) case "response.failed": @@ -220,9 +238,9 @@ func (sc *OpenAIChatStreamConverter) processEvent(raw streaming.RawEvent) { case "error": sc.failUpstream(event.Code, event.Message) } - // Everything else (content_part.*, *.done, output_item.done, annotation - // events, hosted-tool items) carries nothing the deltas did not already - // deliver. + // Everything else (content_part.*, the remaining *.done events, + // annotation events, hosted-tool items) carries nothing the deltas did + // not already deliver. } // handleCreated takes model and created from the response.created payload @@ -277,8 +295,15 @@ func (sc *OpenAIChatStreamConverter) handleItemAdded(outputIndex int, raw json.R } // registerItem records an output item, claiming a dense tool-call index for -// function_call items. +// function_call items. An item a delta already registered keeps its state: +// claiming a second dense index would emit a duplicate start chunk. func (sc *OpenAIChatStreamConverter) registerItem(id string, outputIndex int, itemType string) *chatStreamItemState { + if id != "" { + if state := sc.items[id]; state != nil { + sc.itemsByIndex[outputIndex] = state + return state + } + } state := &chatStreamItemState{toolIndex: -1} if itemType == "function_call" { state.toolIndex = sc.nextToolCallIdx @@ -291,6 +316,46 @@ func (sc *OpenAIChatStreamConverter) registerItem(id string, outputIndex int, it return state } +// handleItemDone relays a completed item's replay state (extra_content) to +// the chat client, so a streamed turn keeps the state the next translated +// request needs to continue reasoning or tool use. +func (sc *OpenAIChatStreamConverter) handleItemDone(outputIndex int, raw json.RawMessage) { + var item responsesStreamItemView + if err := json.Unmarshal(raw, &item); err != nil { + return + } + sc.emitItemExtraContent(item.ID, outputIndex, item.Type, item.ExtraContent) +} + +// emitItemExtraContent emits one chunk carrying the item's extra_content, +// once per item. Reasoning and message state rides the delta's extra_content +// member, function_call state the tool call's extra_content — the same +// convention the inverse converter (OpenAIResponsesStreamConverter) reads. +func (sc *OpenAIChatStreamConverter) emitItemExtraContent(itemID string, outputIndex int, itemType string, extra json.RawMessage) { + if core.IsJSONNull(extra) || (itemID != "" && sc.extraContentSent[itemID]) { + return + } + switch itemType { + case "function_call": + state := sc.items[itemID] + if state == nil { + state = sc.itemsByIndex[outputIndex] + } + if state == nil || state.toolIndex < 0 { + return + } + sc.emitDelta(map[string]any{"tool_calls": []any{map[string]any{ + "index": state.toolIndex, + core.ExtraContentField: extra, + }}}) + default: + sc.emitDelta(map[string]any{core.ExtraContentField: extra}) + } + if itemID != "" { + sc.extraContentSent[itemID] = true + } +} + // handleArgumentsDelta emits one tool-call delta carrying the arguments // fragment under the item's dense chat index. Deltas of parallel calls may // interleave; each carries its item_id, so they never share an index. @@ -300,6 +365,11 @@ func (sc *OpenAIChatStreamConverter) handleArgumentsDelta(itemID string, outputI state = sc.itemsByIndex[outputIndex] } if state == nil { + if itemID == "" { + // No item_id and no known output_index: the fragment has nothing + // to attach to. + return + } // A delta for an item the stream never announced: register it so the // arguments still land under a stable dense index (Postel's law). state = sc.registerItem(itemID, outputIndex, "function_call") @@ -364,8 +434,13 @@ func (sc *OpenAIChatStreamConverter) handleTerminal(eventType string, raw json.R return } sc.ensureRoleChunk() + // The terminal response's output still owes the client any replay state + // (extra_content) its items carry when no output_item.done delivered it. + for outputIndex, item := range response.Output { + sc.emitItemExtraContent(item.ID, outputIndex, item.Type, item.ExtraContent) + } sc.finished = true - sc.emitChunk(map[string]any{}, terminalFinishReason(&response)) + sc.emitChunk(map[string]any{}, terminalFinishReason(&response, sc.nextToolCallIdx > 0)) if sc.includeUsage && response.Usage != nil { sc.emitUsage(response.Usage) } @@ -373,10 +448,17 @@ func (sc *OpenAIChatStreamConverter) handleTerminal(eventType string, raw json.R } // terminalFinishReason maps the terminal response's status onto a chat -// finish reason, returning nil when no honest mapping exists. -func terminalFinishReason(response *responsesTerminalResponseView) *string { +// finish reason, returning nil when no honest mapping exists. A completed +// response yields "tool_calls" when its output holds a function_call item +// or the stream already emitted tool-call chunks (emittedToolCalls), even +// when the terminal event's output omits them. +func terminalFinishReason(response *responsesTerminalResponseView, emittedToolCalls bool) *string { switch response.Status { case "completed": + if emittedToolCalls { + reason := "tool_calls" + return &reason + } for _, item := range response.Output { if item.Type == "function_call" { reason := "tool_calls" diff --git a/internal/providers/chat_via_responses_stream_test.go b/internal/providers/chat_via_responses_stream_test.go index da44f6a4c..297fab000 100644 --- a/internal/providers/chat_via_responses_stream_test.go +++ b/internal/providers/chat_via_responses_stream_test.go @@ -738,3 +738,184 @@ func TestOpenAIChatStreamConverter_ZeroByteReadRetried(t *testing.T) { assert.Contains(t, string(raw), "chat.completion.chunk") assert.Contains(t, string(raw), "data: [DONE]") } + +func TestOpenAIChatStreamConverter_OversizedEventFailsClosed(t *testing.T) { + // An oversized event was never parsed, so the deltas it carried are + // gone: the converter fails closed instead of finishing as if complete. + big := `{"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"delta":"` + strings.Repeat("x", 300) + `"}` + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + big, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[]}}`, + ) + + converted := NewOpenAIChatStreamConverter( + io.NopCloser(strings.NewReader(stream)), "m", "test-provider", false, + ) + converter, ok := converted.(*OpenAIChatStreamConverter) + require.True(t, ok) + converter.scanner = streaming.EventScanner{MaxEventBytes: 128} + defer func() { _ = converter.Close() }() + + raw, err := io.ReadAll(converter) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + require.ErrorIs(t, err, streaming.ErrEventTooLarge) + + out := string(raw) + assert.NotContains(t, out, strings.Repeat("x", 300), "uninspected content leaked") + assert.Contains(t, out, `"stream_incomplete"`) + assert.NotContains(t, out, "[DONE]") + assert.NotContains(t, out, `"finish_reason":"stop"`) +} + +func TestOpenAIChatStreamConverter_ItemDoneRelaysExtraContent(t *testing.T) { + // A completed reasoning item's extra_content rides one chunk's delta so + // the next translated request can echo the replay state back; the + // terminal event carrying the same state must not emit it twice. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}`, + `{"type":"response.reasoning_summary_text.delta","sequence_number":3,"item_id":"rs_1","output_index":0,"summary_index":0,"delta":"thinking"}`, + `{"type":"response.output_item.done","sequence_number":4,"output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[],"extra_content":{"openai":{"encrypted_content":"abc"}}}}`, + `{"type":"response.output_item.added","sequence_number":5,"output_index":1,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}`, + `{"type":"response.output_text.delta","sequence_number":6,"item_id":"msg_1","output_index":1,"content_index":0,"delta":"Answer"}`, + `{"type":"response.completed","sequence_number":7,"response":{"id":"resp_abc123","object":"response","status":"completed","model":"gpt-5.1-codex","created_at":1700000000,"output":[{"id":"rs_1","type":"reasoning","extra_content":{"openai":{"encrypted_content":"abc"}}},{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Answer","annotations":[]}]}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + reasoning delta + extra_content chunk + content + finish + [DONE] + require.Len(t, events, 6) + + assert.Equal(t, "thinking", chatChunkDelta(t, events[1].Payload)["reasoning_content"]) + extra := chatChunkDelta(t, events[2].Payload) + assert.Equal(t, map[string]any{"openai": map[string]any{"encrypted_content": "abc"}}, extra["extra_content"]) + assert.Nil(t, extra["content"], "the replay chunk carries no text") + assert.Equal(t, "Answer", chatChunkDelta(t, events[3].Payload)["content"]) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[4].Payload)) + assert.True(t, events[5].Done) + + sent := 0 + for _, event := range events { + if event.Done { + continue + } + if chatChunkDelta(t, event.Payload)["extra_content"] != nil { + sent++ + } + } + assert.Equal(t, 1, sent, "replay state must be emitted exactly once") +} + +func TestOpenAIChatStreamConverter_FunctionCallDoneRelaysExtraContent(t *testing.T) { + // A completed function_call item's extra_content rides the tool call's + // extra_content member, under the item's dense chat index. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_1","output_index":0,"delta":"{}"}`, + `{"type":"response.output_item.done","sequence_number":4,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{}","extra_content":{"openai":{"item_reference":"fc_1"}}}}`, + `{"type":"response.completed","sequence_number":5,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{}","extra_content":{"openai":{"item_reference":"fc_1"}}}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk + arguments delta + extra_content chunk + finish + [DONE] + require.Len(t, events, 6) + + call := chatChunkToolCalls(t, events[3].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), call["index"]) + assert.Equal(t, map[string]any{"openai": map[string]any{"item_reference": "fc_1"}}, call["extra_content"]) + assert.Nil(t, call["function"], "the replay chunk carries no arguments") + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[4].Payload)) + assert.True(t, events[5].Done) +} + +func TestOpenAIChatStreamConverter_TerminalOutputRelaysExtraContent(t *testing.T) { + // No output_item.done arrived: the terminal event's output still owes + // the client the replay state, ahead of the finish chunk. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + chatViaResponsesMessage, + `{"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`, + `{"type":"response.completed","sequence_number":4,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"hi","annotations":[]}],"extra_content":{"google":{"thought_signature":"sig"}}}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + content + extra_content chunk + finish + [DONE] + require.Len(t, events, 5) + + assert.Equal(t, "hi", chatChunkDelta(t, events[1].Payload)["content"]) + assert.Equal(t, map[string]any{"google": map[string]any{"thought_signature": "sig"}}, + chatChunkDelta(t, events[2].Payload)["extra_content"]) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) +} + +func TestOpenAIChatStreamConverter_FinishReasonFallsBackToStreamedToolCalls(t *testing.T) { + // The stream emitted tool-call chunks, but the terminal event's output + // omits the function_call item: finish_reason still reports tool_calls. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_1","output_index":0,"delta":"{}"}`, + `{"type":"response.completed","sequence_number":4,"response":{"id":"resp_abc123","status":"completed","output":[]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk + arguments delta + finish + [DONE] + require.Len(t, events, 5) + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) +} + +func TestOpenAIChatStreamConverter_ItemAddedAfterDeltaReusesState(t *testing.T) { + // The arguments delta arrives before output_item.added: the added event + // reuses the registered state instead of claiming a second dense index + // and emitting a duplicate start chunk. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"a\":"}`, + `{"type":"response.output_item.added","sequence_number":3,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":4,"item_id":"fc_1","output_index":0,"delta":"1}"}`, + `{"type":"response.completed","sequence_number":5,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":1}"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + one start chunk + two arguments deltas + finish + [DONE] + require.Len(t, events, 6) + + start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), start["index"]) + assert.Equal(t, "function", start["type"]) + assert.Nil(t, start["id"], "the item's call_id was unknown when the start chunk went out") + + second := chatChunkToolCalls(t, events[3].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), second["index"], "the added event must not claim a second dense index") + assert.Equal(t, "1}", second["function"].(map[string]any)["arguments"]) + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[4].Payload)) + assert.True(t, events[5].Done) +} + +func TestOpenAIChatStreamConverter_ArgumentsDeltaWithoutItemIDSkipped(t *testing.T) { + // An arguments delta with an empty item_id and an unknown output_index + // attaches to nothing and is skipped rather than minting a tool call. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"","output_index":7,"delta":"{}"}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + finish + [DONE]; no tool-call chunk was minted. + require.Len(t, events, 3) + assert.Equal(t, "stop", chatChunkFinishReason(t, events[1].Payload)) + assert.True(t, events[2].Done) +} diff --git a/internal/providers/chat_via_responses_test.go b/internal/providers/chat_via_responses_test.go index c879080d3..898c6b948 100644 --- a/internal/providers/chat_via_responses_test.go +++ b/internal/providers/chat_via_responses_test.go @@ -133,6 +133,35 @@ func TestConvertChatRequestToResponses_MaxTokensMapping(t *testing.T) { assert.Nil(t, responsesReq.ExtraFields.Lookup("max_completion_tokens")) }) + t.Run("null max_completion_tokens strips the extra and keeps max_tokens fallback", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + MaxTokens: &maxTokens, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "max_completion_tokens": `null`, + }), + }) + require.NoError(t, err) + require.NotNil(t, responsesReq.MaxOutputTokens) + assert.Equal(t, 512, *responsesReq.MaxOutputTokens) + assert.Nil(t, responsesReq.ExtraFields.Lookup("max_completion_tokens")) + }) + + t.Run("malformed max_completion_tokens rejected", func(t *testing.T) { + for _, raw := range []string{`2048.5`, `true`} { + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "max_completion_tokens": raw, + }), + }) + require.Error(t, err, "value %s", raw) + assert.Contains(t, err.Error(), "max_completion_tokens") + } + }) + t.Run("unset when neither is present", func(t *testing.T) { responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ Model: "m", @@ -220,12 +249,51 @@ func TestConvertChatRequestToResponses_FlattensTools(t *testing.T) { }, responsesReq.Tools[0]) }) - t.Run("non-function tool rejected", func(t *testing.T) { - _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + t.Run("nested chat custom tool flattens", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Tools: []map[string]any{ + { + "type": "custom", + "custom": map[string]any{ + "name": "exec_command", + "description": "Run a command.", + "format": map[string]any{"type": "grammar", "syntax": "lark"}, + }, + }, + }, + }) + require.NoError(t, err) + require.Len(t, responsesReq.Tools, 1) + assert.Equal(t, map[string]any{ + "type": "custom", + "name": "exec_command", + "description": "Run a command.", + "format": map[string]any{"type": "grammar", "syntax": "lark"}, + }, responsesReq.Tools[0]) + }) + + t.Run("already flat custom tool passes through", func(t *testing.T) { + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ Model: "m", Messages: []core.Message{{Role: "user", Content: "hi"}}, Tools: []map[string]any{{"type": "custom", "name": "exec_command"}}, }) + require.NoError(t, err) + require.Len(t, responsesReq.Tools, 1) + assert.Equal(t, map[string]any{ + "type": "custom", + "name": "exec_command", + }, responsesReq.Tools[0]) + }) + + t.Run("unknown tool type rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Tools: []map[string]any{{"type": "web_search"}}, + }) require.Error(t, err) assert.Contains(t, err.Error(), "tools") }) @@ -274,6 +342,16 @@ func TestConvertChatRequestToResponses_ToolChoice(t *testing.T) { assert.Contains(t, err.Error(), "tool_choice") }) + t.Run("unknown string rejected", func(t *testing.T) { + _, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ToolChoice: "web_search", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "tool_choice") + }) + t.Run("non-string non-object rejected", func(t *testing.T) { _, err := ConvertChatRequestToResponses(&core.ChatRequest{ Model: "m", @@ -430,6 +508,26 @@ func TestConvertChatRequestToResponses_ToleratesExplicitNulls(t *testing.T) { assert.Nil(t, responsesReq.ExtraFields.Lookup("n")) } +func TestConvertChatRequestToResponses_ToleratesZeroValues(t *testing.T) { + // A zero value spells the default too: logprobs:false, top_logprobs:0, + // and the penalties at 0 change nothing, so clients sending them + // unconditionally must not be rejected. + responsesReq, err := ConvertChatRequestToResponses(&core.ChatRequest{ + Model: "m", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + ExtraFields: chatViaResponsesExtras(map[string]string{ + "logprobs": `false`, + "top_logprobs": `0`, + "frequency_penalty": `0`, + "presence_penalty": `0.0`, + }), + }) + require.NoError(t, err) + for _, field := range []string{"logprobs", "top_logprobs", "frequency_penalty", "presence_penalty"} { + assert.Nil(t, responsesReq.ExtraFields.Lookup(field), "%s must not leak upstream", field) + } +} + func chatViaResponsesCompletedResponse() *core.ResponsesResponse { return &core.ResponsesResponse{ ID: "resp_123", From c2d771ccfe3fe7ce10071727c597cde0ce11d5ed Mon Sep 17 00:00:00 2001 From: weselben Date: Sat, 26 Sep 2026 20:30:00 +0000 Subject: [PATCH 4/6] fix(providers): buffer tool-call arguments until the call identity arrives --- .../providers/chat_via_responses_stream.go | 58 ++++++++- .../chat_via_responses_stream_test.go | 118 +++++++++++++----- 2 files changed, 141 insertions(+), 35 deletions(-) diff --git a/internal/providers/chat_via_responses_stream.go b/internal/providers/chat_via_responses_stream.go index 793c89df9..fb6e87e8d 100644 --- a/internal/providers/chat_via_responses_stream.go +++ b/internal/providers/chat_via_responses_stream.go @@ -34,7 +34,10 @@ const maxResponsesStreamEventBytes = 8 << 20 // - output_text / refusal / reasoning deltas map to delta.content, // delta.refusal, and the reasoning_content extension. // - function_call_arguments.delta maps to a tool_calls delta carrying only -// the arguments fragment. +// the arguments fragment. A delta arriving before the item's +// output_item.added is buffered until added delivers the call id and +// name; a stream that ends with such an item still pending fails as +// truncated. // - response.completed / response.incomplete emit the finish chunk // (finish_reason from the terminal response's status and output, never // re-emitting that output as content), an optional usage chunk when the @@ -101,6 +104,12 @@ type chatStreamItemState struct { callID string name string started bool + // identityPending marks a function_call item a delta registered before + // its output_item.added event: the call id and name are still unknown, + // so no start chunk goes out and argument fragments accumulate in + // pendingArgs until added delivers the identity. + identityPending bool + pendingArgs strings.Builder } // responsesStreamEventView decodes the members of a Responses API stream @@ -292,6 +301,19 @@ func (sc *OpenAIChatStreamConverter) handleItemAdded(outputIndex int, raw json.R state.callID = item.CallID state.name = item.Name sc.emitToolCallStart(state) + if !state.identityPending { + return + } + // A delta registered this item before the added event delivered its + // identity: the start chunk just went out with the real call id and + // name, so flush the buffered argument fragments under the same index. + state.identityPending = false + if args := state.pendingArgs.String(); args != "" { + sc.emitDelta(map[string]any{"tool_calls": []any{map[string]any{ + "index": state.toolIndex, + "function": map[string]any{"arguments": args}, + }}}) + } } // registerItem records an output item, claiming a dense tool-call index for @@ -318,12 +340,22 @@ func (sc *OpenAIChatStreamConverter) registerItem(id string, outputIndex int, it // handleItemDone relays a completed item's replay state (extra_content) to // the chat client, so a streamed turn keeps the state the next translated -// request needs to continue reasoning or tool use. +// request needs to continue reasoning or tool use. A done event for an +// item whose added event never arrived ends the stream: the tool call's +// identity is unrecoverable, so the stream is unusable. func (sc *OpenAIChatStreamConverter) handleItemDone(outputIndex int, raw json.RawMessage) { var item responsesStreamItemView if err := json.Unmarshal(raw, &item); err != nil { return } + state := sc.items[item.ID] + if state == nil { + state = sc.itemsByIndex[outputIndex] + } + if state != nil && state.identityPending { + sc.failTruncated(errors.New("tool call stream ended before output_item.added delivered the call identity")) + return + } sc.emitItemExtraContent(item.ID, outputIndex, item.Type, item.ExtraContent) } @@ -358,7 +390,11 @@ func (sc *OpenAIChatStreamConverter) emitItemExtraContent(itemID string, outputI // handleArgumentsDelta emits one tool-call delta carrying the arguments // fragment under the item's dense chat index. Deltas of parallel calls may -// interleave; each carries its item_id, so they never share an index. +// interleave; each carries its item_id, so they never share an index. A +// delta for an item the stream never announced registers it so the dense +// index stays stable (Postel's law), but the start chunk and the fragment +// wait for output_item.added to deliver the call id and name — a start +// chunk without identity is unusable to chat clients. func (sc *OpenAIChatStreamConverter) handleArgumentsDelta(itemID string, outputIndex int, delta string) { state := sc.items[itemID] if state == nil { @@ -370,13 +406,16 @@ func (sc *OpenAIChatStreamConverter) handleArgumentsDelta(itemID string, outputI // to attach to. return } - // A delta for an item the stream never announced: register it so the - // arguments still land under a stable dense index (Postel's law). state = sc.registerItem(itemID, outputIndex, "function_call") + state.identityPending = true } if state.toolIndex < 0 { return } + if state.identityPending { + state.pendingArgs.WriteString(delta) + return + } sc.emitToolCallStart(state) if delta == "" { return @@ -433,6 +472,15 @@ func (sc *OpenAIChatStreamConverter) handleTerminal(eventType string, raw json.R sc.failTerminalError(response.Error) return } + // A tool call whose output_item.added never arrived has no call id or + // name; its arguments are undeliverable, so the stream is unusable. + // Fail closed rather than reporting a successful finish. + for _, state := range sc.items { + if state.identityPending { + sc.failTruncated(errors.New("stream ended before output_item.added delivered the tool call identity")) + return + } + } sc.ensureRoleChunk() // The terminal response's output still owes the client any replay state // (extra_content) its items carry when no output_item.done delivered it. diff --git a/internal/providers/chat_via_responses_stream_test.go b/internal/providers/chat_via_responses_stream_test.go index 297fab000..72a97d4c1 100644 --- a/internal/providers/chat_via_responses_stream_test.go +++ b/internal/providers/chat_via_responses_stream_test.go @@ -506,8 +506,10 @@ func TestOpenAIChatStreamConverter_MalformedOutputItemAdded(t *testing.T) { } func TestOpenAIChatStreamConverter_ArgumentsDeltaForUnannouncedItem(t *testing.T) { - // A delta for an item the stream never announced still lands under a - // stable dense index (Postel's law). + // A delta for an item the stream never announced registers it so the + // dense index stays stable, but without output_item.added the call id + // and name stay unknown: the terminal event fails the stream closed + // instead of finishing as if the tool call were usable. stream := chatViaResponsesStreamOf( chatViaResponsesCreated, `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"city\":\"Warsaw\"}"}`, @@ -515,28 +517,20 @@ func TestOpenAIChatStreamConverter_ArgumentsDeltaForUnannouncedItem(t *testing.T ) events, _, err := readChatViaResponsesStream(t, stream, false) - require.NoError(t, err) - // role + synthesized start chunk + arguments delta + finish + [DONE]. - require.Len(t, events, 5) - - start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) - assert.Equal(t, float64(0), start["index"]) - assert.Nil(t, start["id"], "an unannounced item has no call id") - function, ok := start["function"].(map[string]any) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + // role + in-band truncation error: no start chunk without identity, no + // finish chunk, no [DONE]. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) require.True(t, ok) - assert.Empty(t, function["arguments"]) - - delta := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) - assert.Equal(t, float64(0), delta["index"]) - assert.Equal(t, `{"city":"Warsaw"}`, delta["function"].(map[string]any)["arguments"]) - - assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[3].Payload)) - assert.True(t, events[4].Done) + assert.Equal(t, "stream_incomplete", errorPayload["code"]) } func TestOpenAIChatStreamConverter_EmptyArgumentsDelta(t *testing.T) { - // An empty arguments delta still registers the item and emits its start - // chunk, but no arguments chunk. + // An empty arguments delta still registers an unannounced item, so the + // stream ends with the tool call's identity pending: the terminal event + // fails closed. stream := chatViaResponsesStreamOf( chatViaResponsesCreated, `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":""}`, @@ -544,11 +538,13 @@ func TestOpenAIChatStreamConverter_EmptyArgumentsDelta(t *testing.T) { ) events, _, err := readChatViaResponsesStream(t, stream, false) - require.NoError(t, err) - // role + synthesized start chunk + finish + [DONE]. - require.Len(t, events, 4) - assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[2].Payload)) - assert.True(t, events[3].Done) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + // role + in-band truncation error; no tool-call chunk was emitted. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "stream_incomplete", errorPayload["code"]) } func TestOpenAIChatStreamConverter_ArgumentsDeltaFallsBackToOutputIndex(t *testing.T) { @@ -874,9 +870,10 @@ func TestOpenAIChatStreamConverter_FinishReasonFallsBackToStreamedToolCalls(t *t } func TestOpenAIChatStreamConverter_ItemAddedAfterDeltaReusesState(t *testing.T) { - // The arguments delta arrives before output_item.added: the added event - // reuses the registered state instead of claiming a second dense index - // and emitting a duplicate start chunk. + // The arguments delta arrives before output_item.added: the delta is + // buffered, the added event emits the start chunk with the real call id + // and name under the dense index the first delta claimed, and the + // buffered fragment flushes after it. stream := chatViaResponsesStreamOf( chatViaResponsesCreated, `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"a\":"}`, @@ -887,13 +884,20 @@ func TestOpenAIChatStreamConverter_ItemAddedAfterDeltaReusesState(t *testing.T) events, _, err := readChatViaResponsesStream(t, stream, false) require.NoError(t, err) - // role + one start chunk + two arguments deltas + finish + [DONE] + // role + start chunk with identity + buffered fragment + one arguments + // delta + finish + [DONE] require.Len(t, events, 6) start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) assert.Equal(t, float64(0), start["index"]) assert.Equal(t, "function", start["type"]) - assert.Nil(t, start["id"], "the item's call_id was unknown when the start chunk went out") + assert.Equal(t, "call_a", start["id"], "the start chunk must wait for the added event's call id") + assert.Equal(t, "fn_a", start["function"].(map[string]any)["name"]) + assert.Empty(t, start["function"].(map[string]any)["arguments"]) + + buffered := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), buffered["index"], "the buffered fragment keeps the dense index") + assert.Equal(t, `{"a":`, buffered["function"].(map[string]any)["arguments"]) second := chatChunkToolCalls(t, events[3].Payload)[0].(map[string]any) assert.Equal(t, float64(0), second["index"], "the added event must not claim a second dense index") @@ -903,6 +907,60 @@ func TestOpenAIChatStreamConverter_ItemAddedAfterDeltaReusesState(t *testing.T) assert.True(t, events[5].Done) } +func TestOpenAIChatStreamConverter_DeltaFirstFlushesBufferedArgumentsInOrder(t *testing.T) { + // Multiple deltas arrive before output_item.added: they flush as one + // chunk, in arrival order, after the start chunk that carries the + // identity. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"a\":"}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_1","output_index":0,"delta":"1,\"b\":"}`, + `{"type":"response.output_item.added","sequence_number":4,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","sequence_number":5,"item_id":"fc_1","output_index":0,"delta":"2}"}`, + `{"type":"response.completed","sequence_number":6,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":1,\"b\":2}"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk + buffered flush + trailing delta + finish + [DONE] + require.Len(t, events, 6) + + start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, "call_a", start["id"]) + assert.Equal(t, "fn_a", start["function"].(map[string]any)["name"]) + + flushed := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), flushed["index"]) + assert.Equal(t, `{"a":1,"b":`, flushed["function"].(map[string]any)["arguments"], "buffered fragments flush in arrival order") + + trailing := chatChunkToolCalls(t, events[3].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), trailing["index"]) + assert.Equal(t, "2}", trailing["function"].(map[string]any)["arguments"]) +} + +func TestOpenAIChatStreamConverter_DeltaFirstNeverAddedFailsClosed(t *testing.T) { + // A delta arrives but output_item.added never does: the tool call's + // identity is unrecoverable, so the stream fails truncated instead of + // reporting a successful finish. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"a\":1}"}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":1}"}]}}`, + ) + + events, raw, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + // role + in-band truncation error: no finish chunk, no [DONE], no + // arguments emitted for a call without identity. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "stream_incomplete", errorPayload["code"]) + assert.NotContains(t, raw, "tool_calls") + assert.NotContains(t, raw, "[DONE]") +} + func TestOpenAIChatStreamConverter_ArgumentsDeltaWithoutItemIDSkipped(t *testing.T) { // An arguments delta with an empty item_id and an unknown output_index // attaches to nothing and is skipped rather than minting a tool call. From f9bd575b71e77512604bf06ee70540d63867303d Mon Sep 17 00:00:00 2001 From: weselben Date: Sat, 26 Sep 2026 20:46:37 +0000 Subject: [PATCH 5/6] fix(providers): recover delta-first tool calls from terminal output and cap pending arguments --- .../providers/chat_via_responses_stream.go | 142 ++++++++++++++---- .../chat_via_responses_stream_test.go | 141 ++++++++++++++--- 2 files changed, 238 insertions(+), 45 deletions(-) diff --git a/internal/providers/chat_via_responses_stream.go b/internal/providers/chat_via_responses_stream.go index fb6e87e8d..4abde2800 100644 --- a/internal/providers/chat_via_responses_stream.go +++ b/internal/providers/chat_via_responses_stream.go @@ -20,6 +20,14 @@ import ( // scanner default is too small; this mirrors the chatgpt provider's line cap. const maxResponsesStreamEventBytes = 8 << 20 +// maxPendingArgumentsBytes caps the argument fragments buffered across all +// delta-first tool calls whose output_item.added has not arrived yet. The +// buffer is a bridge for a handful of events, not a second copy of the +// stream, so 1 MiB is generous while staying far below the 8 MiB a single +// well-formed event may already occupy; past it the stream fails closed +// with ErrEventTooLarge, like the oversized-event path. +const maxPendingArgumentsBytes = 1 << 20 + // OpenAIChatStreamConverter wraps a Responses API SSE stream and converts it // to OpenAI chat.completion.chunk SSE. It serves providers whose upstream // speaks only the Responses API (the ChatGPT Codex backend). @@ -36,8 +44,10 @@ const maxResponsesStreamEventBytes = 8 << 20 // - function_call_arguments.delta maps to a tool_calls delta carrying only // the arguments fragment. A delta arriving before the item's // output_item.added is buffered until added delivers the call id and -// name; a stream that ends with such an item still pending fails as -// truncated. +// name; an output_item.done or the terminal response's output carrying +// the identity recovers it the same way. A stream that still cannot +// supply the identity fails as truncated, and the buffered fragments +// are capped at maxPendingArgumentsBytes per converter. // - response.completed / response.incomplete emit the finish chunk // (finish_reason from the terminal response's status and output, never // re-emitting that output as content), an optional usage chunk when the @@ -67,6 +77,9 @@ type OpenAIChatStreamConverter struct { items map[string]*chatStreamItemState itemsByIndex map[int]*chatStreamItemState nextToolCallIdx int + // pendingArgsBytes counts the argument fragments currently buffered + // across all identity-pending items, capped at maxPendingArgumentsBytes. + pendingArgsBytes int // extraContentSent marks the items whose replay state chunk already went // out, so an output_item.done and the terminal event do not emit it twice. extraContentSent map[string]bool @@ -104,10 +117,14 @@ type chatStreamItemState struct { callID string name string started bool + // outputIndex is the upstream output_index the item registered under, + // the fallback handle for matching it in a terminal response's output. + outputIndex int // identityPending marks a function_call item a delta registered before // its output_item.added event: the call id and name are still unknown, // so no start chunk goes out and argument fragments accumulate in - // pendingArgs until added delivers the identity. + // pendingArgs until added, done, or the terminal output delivers the + // identity. identityPending bool pendingArgs strings.Builder } @@ -149,12 +166,8 @@ type responsesStreamErrorView struct { // events. Only the members deciding the chat finish_reason and usage chunk // are read; the full output is never re-emitted after its deltas. type responsesTerminalResponseView struct { - Status string `json:"status"` - Output []struct { - ID string `json:"id"` - Type string `json:"type"` - ExtraContent json.RawMessage `json:"extra_content"` - } `json:"output"` + Status string `json:"status"` + Output []responsesTerminalOutputItem `json:"output"` IncompleteDetails *struct { Reason string `json:"reason"` } `json:"incomplete_details"` @@ -162,6 +175,18 @@ type responsesTerminalResponseView struct { Usage *responsesTerminalUsageView `json:"usage"` } +// responsesTerminalOutputItem decodes one item of a terminal response's +// output: enough to recover a delta-first tool call's identity and to relay +// replay state the stream never delivered. +type responsesTerminalOutputItem struct { + ID string `json:"id"` + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + ExtraContent json.RawMessage `json:"extra_content"` +} + // responsesTerminalUsageView decodes the usage object of a terminal // Responses event, with the Responses API field names. type responsesTerminalUsageView struct { @@ -298,17 +323,30 @@ func (sc *OpenAIChatStreamConverter) handleItemAdded(outputIndex int, raw json.R if item.Type != "function_call" { return } + if state.identityPending { + // A delta registered this item before the added event delivered its + // identity: emit the start chunk with the real call id and name and + // flush the buffered argument fragments under the same index. + sc.deliverPendingIdentity(state, item.CallID, item.Name) + return + } state.callID = item.CallID state.name = item.Name sc.emitToolCallStart(state) - if !state.identityPending { - return - } - // A delta registered this item before the added event delivered its - // identity: the start chunk just went out with the real call id and - // name, so flush the buffered argument fragments under the same index. +} + +// deliverPendingIdentity gives a delta-first tool call its identity: the +// start chunk with the call id and name goes out, then the buffered +// argument fragments flush under the same dense index. The caller supplies +// the identity only when the source actually carries it. +func (sc *OpenAIChatStreamConverter) deliverPendingIdentity(state *chatStreamItemState, callID, name string) { state.identityPending = false + state.callID = callID + state.name = name + sc.emitToolCallStart(state) if args := state.pendingArgs.String(); args != "" { + sc.pendingArgsBytes -= state.pendingArgs.Len() + state.pendingArgs.Reset() sc.emitDelta(map[string]any{"tool_calls": []any{map[string]any{ "index": state.toolIndex, "function": map[string]any{"arguments": args}, @@ -322,11 +360,12 @@ func (sc *OpenAIChatStreamConverter) handleItemAdded(outputIndex int, raw json.R func (sc *OpenAIChatStreamConverter) registerItem(id string, outputIndex int, itemType string) *chatStreamItemState { if id != "" { if state := sc.items[id]; state != nil { + state.outputIndex = outputIndex sc.itemsByIndex[outputIndex] = state return state } } - state := &chatStreamItemState{toolIndex: -1} + state := &chatStreamItemState{toolIndex: -1, outputIndex: outputIndex} if itemType == "function_call" { state.toolIndex = sc.nextToolCallIdx sc.nextToolCallIdx++ @@ -340,9 +379,11 @@ func (sc *OpenAIChatStreamConverter) registerItem(id string, outputIndex int, it // handleItemDone relays a completed item's replay state (extra_content) to // the chat client, so a streamed turn keeps the state the next translated -// request needs to continue reasoning or tool use. A done event for an -// item whose added event never arrived ends the stream: the tool call's -// identity is unrecoverable, so the stream is unusable. +// request needs to continue reasoning or tool use. When the done item is a +// delta-first function_call whose added event never arrived, its payload +// still carries the call id and name, so the identity recovers here exactly +// like the added path; a done payload without them ends the stream, as the +// tool call's identity is unrecoverable and the stream is unusable. func (sc *OpenAIChatStreamConverter) handleItemDone(outputIndex int, raw json.RawMessage) { var item responsesStreamItemView if err := json.Unmarshal(raw, &item); err != nil { @@ -353,8 +394,11 @@ func (sc *OpenAIChatStreamConverter) handleItemDone(outputIndex int, raw json.Ra state = sc.itemsByIndex[outputIndex] } if state != nil && state.identityPending { - sc.failTruncated(errors.New("tool call stream ended before output_item.added delivered the call identity")) - return + if item.CallID == "" || item.Name == "" { + sc.failTruncated(errors.New("tool call stream ended before its identity was delivered")) + return + } + sc.deliverPendingIdentity(state, item.CallID, item.Name) } sc.emitItemExtraContent(item.ID, outputIndex, item.Type, item.ExtraContent) } @@ -393,8 +437,9 @@ func (sc *OpenAIChatStreamConverter) emitItemExtraContent(itemID string, outputI // interleave; each carries its item_id, so they never share an index. A // delta for an item the stream never announced registers it so the dense // index stays stable (Postel's law), but the start chunk and the fragment -// wait for output_item.added to deliver the call id and name — a start -// chunk without identity is unusable to chat clients. +// wait for the call id and name — a start chunk without identity is +// unusable to chat clients. Buffered fragments count toward +// maxPendingArgumentsBytes. func (sc *OpenAIChatStreamConverter) handleArgumentsDelta(itemID string, outputIndex int, delta string) { state := sc.items[itemID] if state == nil { @@ -413,7 +458,16 @@ func (sc *OpenAIChatStreamConverter) handleArgumentsDelta(itemID string, outputI return } if state.identityPending { + if sc.pendingArgsBytes+len(delta) > maxPendingArgumentsBytes { + // The buffer is a bridge until the added event, not a second + // copy of the stream: past the cap the deltas are gone, so fail + // closed with ErrEventTooLarge like the oversized-event path + // instead of buffering unboundedly. + sc.failTruncated(streaming.ErrEventTooLarge) + return + } state.pendingArgs.WriteString(delta) + sc.pendingArgsBytes += len(delta) return } sc.emitToolCallStart(state) @@ -473,13 +527,31 @@ func (sc *OpenAIChatStreamConverter) handleTerminal(eventType string, raw json.R return } // A tool call whose output_item.added never arrived has no call id or - // name; its arguments are undeliverable, so the stream is unusable. - // Fail closed rather than reporting a successful finish. - for _, state := range sc.items { - if state.identityPending { - sc.failTruncated(errors.New("stream ended before output_item.added delivered the tool call identity")) + // name yet; the terminal response's output carries the completed items, + // so recover the identity from it (match by item_id, output_index as + // fallback) and deliver the start chunk and buffered arguments. Only an + // item absent from the output or still lacking identity is + // unrecoverable: fail closed rather than reporting a successful finish. + for itemID, state := range sc.items { + if !state.identityPending { + continue + } + item := terminalOutputItem(response.Output, itemID, state.outputIndex) + if item == nil || item.CallID == "" || item.Name == "" { + sc.failTruncated(errors.New("stream ended before the tool call identity was delivered")) return } + bufferEmpty := state.pendingArgs.Len() == 0 + sc.deliverPendingIdentity(state, item.CallID, item.Name) + if bufferEmpty && item.Arguments != "" { + // No fragments buffered: the terminal item's full arguments are + // the only source. Buffered fragments win when present, so the + // arguments never emit twice. + sc.emitDelta(map[string]any{"tool_calls": []any{map[string]any{ + "index": state.toolIndex, + "function": map[string]any{"arguments": item.Arguments}, + }}}) + } } sc.ensureRoleChunk() // The terminal response's output still owes the client any replay state @@ -495,6 +567,20 @@ func (sc *OpenAIChatStreamConverter) handleTerminal(eventType string, raw json.R sc.buffer.AppendString("data: [DONE]\n\n") } +// terminalOutputItem finds itemID in a terminal response's output, falling +// back to the item at outputIndex when no id matches. +func terminalOutputItem(output []responsesTerminalOutputItem, itemID string, outputIndex int) *responsesTerminalOutputItem { + for i := range output { + if output[i].ID == itemID { + return &output[i] + } + } + if outputIndex >= 0 && outputIndex < len(output) { + return &output[outputIndex] + } + return nil +} + // terminalFinishReason maps the terminal response's status onto a chat // finish reason, returning nil when no honest mapping exists. A completed // response yields "tool_calls" when its output holds a function_call item diff --git a/internal/providers/chat_via_responses_stream_test.go b/internal/providers/chat_via_responses_stream_test.go index 72a97d4c1..3dd234706 100644 --- a/internal/providers/chat_via_responses_stream_test.go +++ b/internal/providers/chat_via_responses_stream_test.go @@ -505,26 +505,68 @@ func TestOpenAIChatStreamConverter_MalformedOutputItemAdded(t *testing.T) { assert.True(t, events[3].Done) } -func TestOpenAIChatStreamConverter_ArgumentsDeltaForUnannouncedItem(t *testing.T) { - // A delta for an item the stream never announced registers it so the - // dense index stays stable, but without output_item.added the call id - // and name stay unknown: the terminal event fails the stream closed - // instead of finishing as if the tool call were usable. +func TestOpenAIChatStreamConverter_DeltaFirstRecoveredFromTerminalOutput(t *testing.T) { + // A delta arrives but output_item.added never does: the terminal + // response's output carries the item's call id and name, so the start + // chunk and the buffered arguments still go out and the stream finishes + // as tool_calls. stream := chatViaResponsesStreamOf( chatViaResponsesCreated, `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"city\":\"Warsaw\"}"}`, `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"Warsaw\"}"}]}}`, ) + events, raw, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk with identity + buffered arguments + finish + + // [DONE]; the terminal item's full arguments must not emit a second + // time when the buffer already carried them. + require.Len(t, events, 5) + + start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), start["index"]) + assert.Equal(t, "call_abc", start["id"]) + assert.Equal(t, "function", start["type"]) + assert.Equal(t, "get_weather", start["function"].(map[string]any)["name"]) + assert.Empty(t, start["function"].(map[string]any)["arguments"]) + + args := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), args["index"]) + assert.Nil(t, args["id"], "argument chunks must not repeat the id") + assert.Equal(t, `{"city":"Warsaw"}`, args["function"].(map[string]any)["arguments"]) + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) + + assert.Equal(t, 1, strings.Count(raw, "Warsaw"), "the arguments must be emitted exactly once") +} + +func TestOpenAIChatStreamConverter_DeltaFirstTerminalArgumentsWhenBufferEmpty(t *testing.T) { + // The identity-pending item buffered no fragments (an empty delta + // registered it): the terminal item's full arguments are the only + // source, so they ride one chunk after the start chunk. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":""}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"Warsaw\"}"}]}}`, + ) + events, _, err := readChatViaResponsesStream(t, stream, false) - require.Error(t, err) - require.ErrorIs(t, err, streaming.ErrStreamIncomplete) - // role + in-band truncation error: no start chunk without identity, no - // finish chunk, no [DONE]. - require.Len(t, events, 2) - errorPayload, ok := events[1].Payload["error"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "stream_incomplete", errorPayload["code"]) + require.NoError(t, err) + // role + start chunk with identity + terminal arguments + finish + + // [DONE]. + require.Len(t, events, 5) + + start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, "call_abc", start["id"]) + assert.Equal(t, "get_weather", start["function"].(map[string]any)["name"]) + + args := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), args["index"]) + assert.Equal(t, `{"city":"Warsaw"}`, args["function"].(map[string]any)["arguments"]) + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) } func TestOpenAIChatStreamConverter_EmptyArgumentsDelta(t *testing.T) { @@ -938,14 +980,79 @@ func TestOpenAIChatStreamConverter_DeltaFirstFlushesBufferedArgumentsInOrder(t * assert.Equal(t, "2}", trailing["function"].(map[string]any)["arguments"]) } +func TestOpenAIChatStreamConverter_DeltaFirstItemDoneSuppliesIdentity(t *testing.T) { + // output_item.added never arrives but output_item.done carries the + // item's call id and name: the done event recovers the identity the + // same way the added event would have, buffered fragments flushing + // after the start chunk. + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"a\":"}`, + `{"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_1","output_index":0,"delta":"1}"}`, + `{"type":"response.output_item.done","sequence_number":4,"output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":1}"}}`, + `{"type":"response.completed","sequence_number":5,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":1}"}]}}`, + ) + + events, _, err := readChatViaResponsesStream(t, stream, false) + require.NoError(t, err) + // role + start chunk with identity + buffered fragments + finish + + // [DONE]; the terminal output's duplicate arguments must not emit + // again after the recovery delivered them. + require.Len(t, events, 5) + + start := chatChunkToolCalls(t, events[1].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), start["index"]) + assert.Equal(t, "call_a", start["id"]) + assert.Equal(t, "fn_a", start["function"].(map[string]any)["name"]) + assert.Empty(t, start["function"].(map[string]any)["arguments"]) + + flushed := chatChunkToolCalls(t, events[2].Payload)[0].(map[string]any) + assert.Equal(t, float64(0), flushed["index"]) + assert.Equal(t, `{"a":1}`, flushed["function"].(map[string]any)["arguments"]) + + assert.Equal(t, "tool_calls", chatChunkFinishReason(t, events[3].Payload)) + assert.True(t, events[4].Done) +} + +func TestOpenAIChatStreamConverter_PendingArgumentsCapFailsClosed(t *testing.T) { + // Fragments buffered for a tool call whose identity never arrives are + // capped per converter: past the cap the deltas are unrecoverable, so + // the stream fails closed with ErrEventTooLarge like the + // oversized-event path. + fragment := strings.Repeat("x", maxPendingArgumentsBytes/2+1) + event := `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"` + fragment + `"}` + stream := chatViaResponsesStreamOf( + chatViaResponsesCreated, + event, + event, + event, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[]}}`, + ) + + events, raw, err := readChatViaResponsesStream(t, stream, false) + require.Error(t, err) + require.ErrorIs(t, err, streaming.ErrStreamIncomplete) + require.ErrorIs(t, err, streaming.ErrEventTooLarge) + + // role + in-band error; no start chunk without identity, no finish + // chunk, no [DONE]. + require.Len(t, events, 2) + errorPayload, ok := events[1].Payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "stream_incomplete", errorPayload["code"]) + assert.NotContains(t, raw, "[DONE]") + assert.NotContains(t, raw, fragment) +} + func TestOpenAIChatStreamConverter_DeltaFirstNeverAddedFailsClosed(t *testing.T) { - // A delta arrives but output_item.added never does: the tool call's - // identity is unrecoverable, so the stream fails truncated instead of - // reporting a successful finish. + // A delta arrives but output_item.added never does and the terminal + // response's output omits the item: the tool call's identity is + // unrecoverable, so the stream fails truncated instead of reporting a + // successful finish. stream := chatViaResponsesStreamOf( chatViaResponsesCreated, `{"type":"response.function_call_arguments.delta","sequence_number":2,"item_id":"fc_1","output_index":0,"delta":"{\"a\":1}"}`, - `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_a","name":"fn_a","arguments":"{\"a\":1}"}]}}`, + `{"type":"response.completed","sequence_number":3,"response":{"id":"resp_abc123","status":"completed","output":[{"id":"msg_1","type":"message"}]}}`, ) events, raw, err := readChatViaResponsesStream(t, stream, false) From c72ac355c7695440bbee0f4656034fb9fdf1b34b Mon Sep 17 00:00:00 2001 From: weselben Date: Sat, 26 Sep 2026 20:51:17 +0000 Subject: [PATCH 6/6] fix(providers): keep instruction-only requests on a non-nil empty input --- internal/providers/chat_via_responses_input.go | 5 ++++- .../providers/chat_via_responses_input_test.go | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/providers/chat_via_responses_input.go b/internal/providers/chat_via_responses_input.go index 74284bd2c..e43f08f81 100644 --- a/internal/providers/chat_via_responses_input.go +++ b/internal/providers/chat_via_responses_input.go @@ -84,7 +84,10 @@ func ConvertMessagesToResponsesInput(messages []core.Message) (input any, instru nil, ) } - return nil, instructions, nil + // Instruction-only requests still need a non-nil empty input: the + // chatgpt provider's normalizeInput rejects a nil input ("responses + // input is required") even when instructions carry the prompt. + return []core.ResponsesInputElement{}, instructions, nil } return items, instructions, nil } diff --git a/internal/providers/chat_via_responses_input_test.go b/internal/providers/chat_via_responses_input_test.go index dfd1b8208..1661172e4 100644 --- a/internal/providers/chat_via_responses_input_test.go +++ b/internal/providers/chat_via_responses_input_test.go @@ -104,7 +104,10 @@ func TestConvertMessagesToResponsesInput_RoleMapping(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantInstructions, instructions) if len(tt.wantItems) == 0 { - assert.Nil(t, input) + // Instruction-only rows now return a non-nil empty slice + // (providers reject a nil input); the content stays empty. + require.NotNil(t, input) + assert.Empty(t, input) return } assert.Equal(t, tt.wantItems, responsesInputItems(t, input)) @@ -488,6 +491,19 @@ func TestConvertMessagesToResponsesInput_RequiresInputOrInstructions(t *testing. } } +func TestConvertMessagesToResponsesInput_InstructionOnlyHasEmptyNonNilInput(t *testing.T) { + // A system-only conversation yields instructions but no input items. The + // input must stay a non-nil empty slice: providers such as chatgpt reject + // a nil input even when instructions carry the prompt. + input, instructions, err := ConvertMessagesToResponsesInput([]core.Message{ + {Role: "system", Content: "you are terse"}, + }) + require.NoError(t, err) + assert.Equal(t, "you are terse", instructions) + require.NotNil(t, input) + assert.Empty(t, input) +} + func TestConvertMessagesToResponsesInput_StripsChatOnlyExtras(t *testing.T) { extras := func() core.UnknownJSONFields { return core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{