From 068006566025e347b575941e3d27701b18d79af4 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 31 Jul 2026 17:22:02 +0200 Subject: [PATCH 1/5] fix(responses): tolerate unsupported Codex tools --- docs/advanced/responses-compatibility.mdx | 54 ++++---- .../anthropic_responses_probe.py | 12 +- internal/providers/responses_adapter.go | 95 +++++++------- internal/providers/responses_adapter_test.go | 119 ++++++++++-------- 4 files changed, 142 insertions(+), 138 deletions(-) diff --git a/docs/advanced/responses-compatibility.mdx b/docs/advanced/responses-compatibility.mdx index 5c0fa0f86..73efbe593 100644 --- a/docs/advanced/responses-compatibility.mdx +++ b/docs/advanced/responses-compatibility.mdx @@ -10,9 +10,10 @@ the selected model provider. Some providers expose a native Responses-compatible surface. Others expose chat completions or provider-native chat APIs, so GoModel translates the request. -The compatibility rule is conservative: GoModel translates portable model -features and rejects provider-hosted features when it cannot preserve their -meaning. +The compatibility rule follows Postel's Law: GoModel translates portable model +features and accepts unfamiliar tool declarations without forwarding them to a +provider that cannot execute them. Fields that would change the meaning of the +model output are still rejected when they cannot be translated safely. ## Routing modes @@ -34,9 +35,7 @@ function tool loops. They cannot safely execute OpenAI-hosted tools. | Function tools | Forwarded | Converted to provider function/tool declarations | | Function call output items | Forwarded | Converted to chat tool-result messages | | `text.format` structured output | Forwarded | Converted to `response_format` when the provider supports it | -| OpenAI-hosted web search | Provider decides | Rejected | -| OpenAI-hosted file search | Provider decides | Rejected | -| OpenAI-hosted computer use | Provider decides | Rejected | +| Responses-only tools (`web_search`, `file_search`, computer use, and `namespace`) | Provider decides | Accepted and omitted | | `previous_response_id` and `conversation` | Forwarded | Rejected | | `include` annotations | Forwarded | Accepted and ignored, except `message.output_text.logprobs` | | Unknown Responses input item types | Preserved | Rejected | @@ -73,23 +72,21 @@ runtime state: - `computer_use_preview` depends on a provider-managed computer session, display environment, and safety model. -GoModel does not translate these into Anthropic or Gemini tool calls. A fake -translation would make the request appear supported while changing where the -tool runs, how state is stored, and which security controls apply. - -When a chat-translated provider receives a hosted tool request, GoModel returns -an OpenAI-compatible invalid request error: - -```json -{ - "error": { - "type": "invalid_request_error", - "message": "responses tool type \"web_search_preview\" is only supported by native Responses providers; chat-translated providers only support function tools", - "param": null, - "code": null - } -} -``` +GoModel does not translate these into Anthropic, Gemini, or other chat-provider +tool calls. A fake translation would change where the tool runs, how state is +stored, and which security controls apply. Chat-translated providers therefore +accept the request but omit every non-function tool before provider dispatch. +Supported function tools in the same request continue to work normally. + +`namespace` tools are omitted as a unit rather than flattened. Flattening would +change the tool names and break the client's routing contract. A `tool_choice` +that selects an omitted tool is omitted too. If every tool is omitted, +`parallel_tool_calls` is also removed from the translated request. + +This is graceful capability degradation, not hosted-tool emulation. In +particular, a response from a chat-translated provider does not imply that a web +search, file search, or computer action occurred. Use a native Responses +provider when the result depends on one of those tools running. ## Agent SDKs @@ -101,8 +98,9 @@ GoModel for portable flows: - local function tools - SDK-managed local history replay -Provider-hosted tools, server-managed conversation state, and websocket -Responses transport still depend on provider-specific support. +Provider-hosted tools are ignored by chat-translated providers. Server-managed +conversation state and websocket Responses transport still depend on +provider-specific support. For Python Agents SDK clients, namespaced GoModel model IDs such as `anthropic/claude-sonnet-4-20250514` and `gemini/gemini-2.0-flash` need model ID @@ -145,9 +143,9 @@ python3 docs/examples/openai-agents-sdk/anthropic_agents_probe.py The Responses probe verifies both supported and unsupported paths: plain Responses calls, function tools, structured-output rejection, stateful-field -rejection, unknown input-item rejection, and hosted-tool rejection. The Agents -probe verifies basic runs, function tool loops, and streamed function tool -loops. +rejection, unknown input-item rejection, and graceful hosted-tool omission. The +Agents probe verifies basic runs, function tool loops, and streamed function +tool loops. ## Roadmap diff --git a/docs/examples/openai-agents-sdk/anthropic_responses_probe.py b/docs/examples/openai-agents-sdk/anthropic_responses_probe.py index 7e3957155..16c04099f 100644 --- a/docs/examples/openai-agents-sdk/anthropic_responses_probe.py +++ b/docs/examples/openai-agents-sdk/anthropic_responses_probe.py @@ -153,25 +153,25 @@ async def main() -> int: "unsupported input item type", ), ( - "hosted web search gap", + "hosted web search is accepted and omitted", lambda: client.responses.create( model=MODEL, input="Search the web for the latest Go release.", tools=[{"type": "web_search_preview"}], ), - "web_search_preview", + None, ), ( - "hosted file search gap", + "hosted file search is accepted and omitted", lambda: client.responses.create( model=MODEL, input="Search the attached vector store.", tools=[{"type": "file_search", "vector_store_ids": ["vs_probe"]}], ), - "file_search", + None, ), ( - "hosted computer use gap", + "hosted computer use is accepted and omitted", lambda: client.responses.create( model=MODEL, input="Use the computer to inspect the page.", @@ -184,7 +184,7 @@ async def main() -> int: } ], ), - "computer_use_preview", + None, ), ] diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index 48019a78c..e74200a25 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -30,14 +30,21 @@ func ConvertResponsesRequestToChat(req *core.ResponsesRequest) (*core.ChatReques if err := validateResponsesRequestForChatTranslation(req); err != nil { return nil, err } + tools := normalizeResponsesToolsForChat(req.Tools) + toolChoice := normalizeResponsesToolChoiceForChat(req.ToolChoice) + parallelToolCalls := req.ParallelToolCalls + if len(req.Tools) > 0 && len(tools) == 0 { + toolChoice = nil + parallelToolCalls = nil + } chatReq := &core.ChatRequest{ Model: req.Model, Provider: req.Provider, Messages: make([]core.Message, 0), - Tools: normalizeResponsesToolsForChat(req.Tools), - ToolChoice: normalizeResponsesToolChoiceForChat(req.ToolChoice), - ParallelToolCalls: req.ParallelToolCalls, + Tools: tools, + ToolChoice: toolChoice, + ParallelToolCalls: parallelToolCalls, Temperature: req.Temperature, TopP: req.TopP, Stream: req.Stream, @@ -104,12 +111,6 @@ func validateResponsesRequestForChatTranslation(req *core.ResponsesRequest) erro if strings.TrimSpace(req.SafetyIdentifier) != "" { return unsupportedResponsesChatTranslationField("safety_identifier") } - if err := validateResponsesToolsForChatTranslation(req.Tools); err != nil { - return err - } - if err := validateResponsesToolChoiceForChatTranslation(req.ToolChoice); err != nil { - return err - } return nil } @@ -122,12 +123,12 @@ const responsesIncludeOutputLogprobs = "message.output_text.logprobs" // validateResponsesIncludeForChatTranslation accepts the Responses "include" // field for chat-translated providers. include only asks for extra annotations // on response items; it never changes what the model does. Chat translation -// cannot produce those items — hosted-tool items are rejected at the tools -// check, and encrypted reasoning has no chat equivalent — so the annotations -// are simply absent, exactly as they are for a native provider that does not -// support them. Unrecognized values are dropped too: a stricter allowlist would -// break clients again every time OpenAI adds a value, and no include value can -// make a response wrong. +// cannot produce those items — unsupported hosted tools are omitted during +// translation, and encrypted reasoning has no chat equivalent — so the +// annotations are simply absent, exactly as they are for a native provider +// that does not support them. Unrecognized values are dropped too: a stricter +// allowlist would break clients again every time OpenAI adds a value, and no +// include value can make a response wrong. func validateResponsesIncludeForChatTranslation(include []string) error { for _, value := range include { if strings.TrimSpace(value) == responsesIncludeOutputLogprobs { @@ -137,31 +138,6 @@ func validateResponsesIncludeForChatTranslation(include []string) error { return nil } -func validateResponsesToolsForChatTranslation(tools []map[string]any) error { - for _, tool := range tools { - toolType, _ := tool["type"].(string) - if strings.TrimSpace(toolType) != "function" { - return unsupportedResponsesChatTranslationTool(toolType) - } - } - return nil -} - -func validateResponsesToolChoiceForChatTranslation(choice any) error { - choiceMap, ok := choice.(map[string]any) - if !ok { - return nil - } - - choiceType, _ := choiceMap["type"].(string) - switch strings.TrimSpace(choiceType) { - case "function", "auto", "required", "none": - return nil - default: - return unsupportedResponsesChatTranslationTool(choiceType) - } -} - // responsesTextToChatExtraFields maps the Responses "text" settings onto the // equivalent Chat Completions fields. text.format becomes response_format and // text.verbosity passes through unchanged; both are emitted as passthrough @@ -248,17 +224,6 @@ func unsupportedResponsesChatTranslationField(field string) error { ) } -func unsupportedResponsesChatTranslationTool(toolType string) error { - toolType = strings.TrimSpace(toolType) - if toolType == "" { - toolType = "unknown" - } - return core.NewInvalidRequestError( - fmt.Sprintf("responses tool type %q is only supported by native Responses providers; chat-translated providers only support function tools", toolType), - nil, - ) -} - func cloneStreamOptions(src *core.StreamOptions) *core.StreamOptions { if src == nil { return nil @@ -274,8 +239,20 @@ func normalizeResponsesToolsForChat(tools []map[string]any) []map[string]any { normalized := make([]map[string]any, 0, len(tools)) for _, tool := range tools { + toolType, _ := tool["type"].(string) + if strings.TrimSpace(toolType) != "function" { + // Responses-only tools such as web_search and namespace have no + // Chat Completions equivalent. Ignore them for chat-backed providers + // instead of rejecting an otherwise usable request. In particular, + // namespace tools must not be flattened because changing their names + // would break the caller's tool routing contract. + continue + } normalized = append(normalized, normalizeResponsesToolForChat(tool)) } + if len(normalized) == 0 { + return nil + } return normalized } @@ -309,9 +286,18 @@ func normalizeResponsesToolForChat(tool map[string]any) map[string]any { } func normalizeResponsesToolChoiceForChat(choice any) any { + if choiceString, ok := choice.(string); ok { + switch choiceString := strings.TrimSpace(choiceString); choiceString { + case "auto", "required", "none": + return choiceString + default: + return nil + } + } + choiceMap, ok := choice.(map[string]any) if !ok { - return choice + return nil } choiceType, _ := choiceMap["type"].(string) @@ -321,7 +307,10 @@ func normalizeResponsesToolChoiceForChat(choice any) any { case "function": // Function choices stay object-shaped, with legacy name-form normalized below. default: - return choice + // A hosted-tool choice cannot be honored by Chat Completions. Dropping + // the choice lets the downstream model use any translatable function + // tools without forwarding an invalid provider-specific object. + return nil } if _, ok := choiceMap["function"].(map[string]any); ok { return cloneStringAnyMap(choiceMap) diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index c3a1c0229..8b14ccd77 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -578,57 +578,6 @@ func TestConvertResponsesRequestToChat_RejectsStatefulAgentsSDKFields(t *testing req: &core.ResponsesRequest{Model: "test-model", Input: "Hello", Text: map[string]any{"format": map[string]any{"type": "grammar"}}}, want: "text", }, - { - name: "hosted web search tool", - req: &core.ResponsesRequest{ - Model: "test-model", - Input: "Hello", - Tools: []map[string]any{ - {"type": "web_search_preview"}, - }, - }, - want: "web_search_preview", - }, - { - name: "hosted file search tool", - req: &core.ResponsesRequest{ - Model: "test-model", - Input: "Hello", - Tools: []map[string]any{ - {"type": "file_search", "vector_store_ids": []string{"vs_123"}}, - }, - }, - want: "file_search", - }, - { - name: "hosted computer use tool", - req: &core.ResponsesRequest{ - Model: "test-model", - Input: "Hello", - Tools: []map[string]any{ - {"type": "computer_use_preview", "display_width": 1024, "display_height": 768}, - }, - }, - want: "computer_use_preview", - }, - { - name: "hosted file search tool choice", - req: &core.ResponsesRequest{ - Model: "test-model", - Input: "Hello", - ToolChoice: map[string]any{"type": "file_search"}, - }, - want: "file_search", - }, - { - name: "hosted web search tool choice", - req: &core.ResponsesRequest{ - Model: "test-model", - Input: "Hello", - ToolChoice: map[string]any{"type": "web_search_preview"}, - }, - want: "web_search_preview", - }, } for _, tt := range tests { @@ -644,6 +593,74 @@ func TestConvertResponsesRequestToChat_RejectsStatefulAgentsSDKFields(t *testing } } +func TestConvertResponsesRequestToChat_IgnoresUnsupportedTools(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Tools: []map[string]any{ + { + "type": "namespace", + "name": "multi_agent_v1", + "tools": []any{ + map[string]any{"type": "function", "name": "spawn_agent"}, + }, + }, + {"type": "web_search"}, + {"type": "file_search", "vector_store_ids": []string{"vs_123"}}, + { + "type": "function", + "name": "exec_command", + "description": "Run a command.", + "parameters": map[string]any{"type": "object"}, + }, + }, + ToolChoice: map[string]any{"type": "auto"}, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if len(chatReq.Tools) != 1 { + t.Fatalf("Tools = %#v, want only the function tool", chatReq.Tools) + } + function, ok := chatReq.Tools[0]["function"].(map[string]any) + if !ok || function["name"] != "exec_command" { + t.Fatalf("Tools[0] = %#v, want exec_command function", chatReq.Tools[0]) + } + if chatReq.ToolChoice != "auto" { + t.Fatalf("ToolChoice = %#v, want auto", chatReq.ToolChoice) + } +} + +func TestConvertResponsesRequestToChat_IgnoresOnlyUnsupportedToolsAndChoice(t *testing.T) { + parallelToolCalls := true + req := &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Tools: []map[string]any{ + {"type": "namespace", "name": "multi_agent_v1", "tools": []any{}}, + {"type": "web_search"}, + }, + ToolChoice: map[string]any{"type": "web_search"}, + ParallelToolCalls: ¶llelToolCalls, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if chatReq.Tools != nil { + t.Fatalf("Tools = %#v, want nil", chatReq.Tools) + } + if chatReq.ToolChoice != nil { + t.Fatalf("ToolChoice = %#v, want nil", chatReq.ToolChoice) + } + if chatReq.ParallelToolCalls != nil { + t.Fatalf("ParallelToolCalls = %#v, want nil", chatReq.ParallelToolCalls) + } +} + func TestConvertResponsesRequestToChat_MapsTextFormatToResponseFormat(t *testing.T) { t.Run("json_schema nests schema fields", func(t *testing.T) { req := &core.ResponsesRequest{ From 5ed8c96ed133b2abddd62944e3cb282b1ef3f8a6 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 31 Jul 2026 18:23:59 +0200 Subject: [PATCH 2/5] fix(responses): stabilize filtered tools and stream indexes --- internal/providers/responses_adapter.go | 33 +++++++++- internal/providers/responses_adapter_test.go | 62 +++++++++++++++++++ internal/providers/responses_converter.go | 49 +++++---------- .../providers/responses_converter_test.go | 49 +++++++++++++++ 4 files changed, 158 insertions(+), 35 deletions(-) diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index e74200a25..9f0b5cf9c 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -33,9 +33,13 @@ func ConvertResponsesRequestToChat(req *core.ResponsesRequest) (*core.ChatReques tools := normalizeResponsesToolsForChat(req.Tools) toolChoice := normalizeResponsesToolChoiceForChat(req.ToolChoice) parallelToolCalls := req.ParallelToolCalls - if len(req.Tools) > 0 && len(tools) == 0 { - toolChoice = nil - parallelToolCalls = nil + if len(req.Tools) > 0 { + if len(tools) == 0 { + toolChoice = nil + parallelToolCalls = nil + } else { + toolChoice = dropUnavailableResponsesToolChoice(toolChoice, tools) + } } chatReq := &core.ChatRequest{ @@ -327,6 +331,29 @@ func normalizeResponsesToolChoiceForChat(choice any) any { return normalized } +func dropUnavailableResponsesToolChoice(choice any, tools []map[string]any) any { + choiceMap, ok := choice.(map[string]any) + if !ok || choiceMap["type"] != "function" { + return choice + } + function, ok := choiceMap["function"].(map[string]any) + if !ok { + return choice + } + name, ok := function["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return choice + } + + for _, tool := range tools { + toolFunction, ok := tool["function"].(map[string]any) + if ok && toolFunction["name"] == name { + return choice + } + } + return nil +} + func cloneStringAnyMap(src map[string]any) map[string]any { return maps.Clone(src) } diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index 8b14ccd77..91313ff2d 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -661,6 +661,68 @@ func TestConvertResponsesRequestToChat_IgnoresOnlyUnsupportedToolsAndChoice(t *t } } +func TestConvertResponsesRequestToChat_DropsChoiceForOmittedNamespaceChild(t *testing.T) { + parallelToolCalls := true + tests := []struct { + name string + toolChoice map[string]any + wantChoice bool + }{ + { + name: "omitted namespace child", + toolChoice: map[string]any{"type": "function", "name": "spawn_agent"}, + }, + { + name: "retained function", + toolChoice: map[string]any{"type": "function", "name": "exec_command"}, + wantChoice: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Tools: []map[string]any{ + { + "type": "namespace", + "name": "multi_agent_v1", + "tools": []any{ + map[string]any{"type": "function", "name": "spawn_agent"}, + }, + }, + { + "type": "function", + "name": "exec_command", + "parameters": map[string]any{"type": "object"}, + }, + }, + ToolChoice: tt.toolChoice, + ParallelToolCalls: ¶llelToolCalls, + } + + chatReq, err := ConvertResponsesRequestToChat(req) + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) + } + if len(chatReq.Tools) != 1 { + t.Fatalf("Tools = %#v, want only exec_command", chatReq.Tools) + } + function, ok := chatReq.Tools[0]["function"].(map[string]any) + if !ok || function["name"] != "exec_command" { + t.Fatalf("Tools[0] = %#v, want exec_command function", chatReq.Tools[0]) + } + if (chatReq.ToolChoice != nil) != tt.wantChoice { + t.Fatalf("ToolChoice = %#v, want present %v", chatReq.ToolChoice, tt.wantChoice) + } + if chatReq.ParallelToolCalls == nil || !*chatReq.ParallelToolCalls { + t.Fatalf("ParallelToolCalls = %#v, want true", chatReq.ParallelToolCalls) + } + }) + } +} + func TestConvertResponsesRequestToChat_MapsTextFormatToResponseFormat(t *testing.T) { t.Run("json_schema nests schema fields", func(t *testing.T) { req := &core.ResponsesRequest{ diff --git a/internal/providers/responses_converter.go b/internal/providers/responses_converter.go index 9a788cea4..9e55f521d 100644 --- a/internal/providers/responses_converter.go +++ b/internal/providers/responses_converter.go @@ -28,6 +28,7 @@ type OpenAIResponsesStreamConverter struct { output *ResponsesOutputEventState toolCalls map[int]*ResponsesOutputToolCallState assistantOutputIndex int + nextOutputIndex int buffer streaming.StreamBuffer lineBuffer streaming.StreamBuffer readBuf []byte @@ -93,14 +94,7 @@ type openAIChunkToolCall struct { func (sc *OpenAIResponsesStreamConverter) ensureToolCallState(index int) *ResponsesOutputToolCallState { state := sc.toolCalls[index] if state == nil { - outputIndex := index - if sc.output.ReasoningReserved() { - outputIndex++ - } - if sc.output.AssistantReserved() { - outputIndex++ - } - state = &ResponsesOutputToolCallState{OutputIndex: outputIndex} + state = &ResponsesOutputToolCallState{OutputIndex: -1} sc.toolCalls[index] = state } return state @@ -116,11 +110,7 @@ func (sc *OpenAIResponsesStreamConverter) reserveReasoningOutput() { return } sc.output.ReserveReasoning() - for _, state := range sc.toolCalls { - if state != nil && !state.Started { - state.OutputIndex++ - } - } + sc.nextOutputIndex++ } func (sc *OpenAIResponsesStreamConverter) outputAlreadyStarted() bool { @@ -140,25 +130,20 @@ func (sc *OpenAIResponsesStreamConverter) reserveAssistantOutput() { return } - // Items that have already been emitted cannot move. Place the assistant - // after them, then shift only pending tool calls that would otherwise - // occupy the same or a later slot. - outputIndex := 0 - if sc.output.ReasoningReserved() { - outputIndex++ - } - for _, state := range sc.toolCalls { - if state != nil && state.Started && state.OutputIndex >= outputIndex { - outputIndex = state.OutputIndex + 1 - } - } - sc.assistantOutputIndex = outputIndex + sc.assistantOutputIndex = sc.nextOutputIndex + sc.nextOutputIndex++ sc.output.ReserveAssistant() - for _, state := range sc.toolCalls { - if state != nil && !state.Started && state.OutputIndex >= outputIndex { - state.OutputIndex++ - } +} + +func (sc *OpenAIResponsesStreamConverter) startToolCall(state *ResponsesOutputToolCallState) string { + if state == nil || state.Started || strings.TrimSpace(state.CallID) == "" || strings.TrimSpace(state.Name) == "" { + return "" + } + if state.OutputIndex < 0 { + state.OutputIndex = sc.nextOutputIndex + sc.nextOutputIndex++ } + return sc.output.StartToolCall(state, false) } func (sc *OpenAIResponsesStreamConverter) forceStartToolCall(state *ResponsesOutputToolCallState) string { @@ -168,7 +153,7 @@ func (sc *OpenAIResponsesStreamConverter) forceStartToolCall(state *ResponsesOut if strings.TrimSpace(state.Name) == "" { state.Name = "unknown" } - return sc.output.StartToolCall(state, false) + return sc.startToolCall(state) } func (sc *OpenAIResponsesStreamConverter) completePendingToolCalls() string { @@ -220,7 +205,7 @@ func (sc *OpenAIResponsesStreamConverter) handleToolCallDeltas(toolCalls []openA if arguments != "" { _, _ = state.Arguments.WriteString(arguments) } - out.WriteString(sc.output.StartToolCall(state, false)) + out.WriteString(sc.startToolCall(state)) if state.Started { delta := "" diff --git a/internal/providers/responses_converter_test.go b/internal/providers/responses_converter_test.go index a0d86d171..e1b601df8 100644 --- a/internal/providers/responses_converter_test.go +++ b/internal/providers/responses_converter_test.go @@ -309,6 +309,55 @@ data: [DONE] } } +func TestOpenAIResponsesStreamConverter_OutOfOrderToolCallsKeepUniqueIndexes(t *testing.T) { + mockStream := `data: {"choices":[{"delta":{"reasoning_content":"Plan."},"finish_reason":null}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_second","type":"function","function":{"name":"second","arguments":"{}"}}]},"finish_reason":null}]} + +data: {"choices":[{"delta":{"content":"Calling tools."},"finish_reason":null}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_first","type":"function","function":{"name":"first","arguments":"{}"}}]},"finish_reason":null}]} + +data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] +` + + converter := NewOpenAIResponsesStreamConverter(io.NopCloser(strings.NewReader(mockStream)), "test-model", "mock") + raw, err := io.ReadAll(converter) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + + indexes := make(map[float64]string) + callIndexes := make(map[string]float64) + for _, event := range parseTestSSEEvents(t, string(raw)) { + if event.Name != "response.output_item.added" { + continue + } + index, ok := event.Payload["output_index"].(float64) + if !ok { + t.Fatalf("output_index = %#v, want number", event.Payload["output_index"]) + } + item, _ := event.Payload["item"].(map[string]any) + itemID, _ := item["id"].(string) + if previous, exists := indexes[index]; exists { + t.Fatalf("output_index %v reused by %q and %q", index, previous, itemID) + } + indexes[index] = itemID + if callID, _ := item["call_id"].(string); callID != "" { + callIndexes[callID] = index + } + } + + if len(indexes) != 4 { + t.Fatalf("output items = %#v, want reasoning, two calls, and message", indexes) + } + if callIndexes["call_second"] != 1 || callIndexes["call_first"] != 3 { + t.Fatalf("function-call indexes = %#v, want emission-order indexes 1 and 3", callIndexes) + } +} + func TestOpenAIResponsesStreamConverter_WithTextBeforeToolCall(t *testing.T) { mockStream := `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"test-model","choices":[{"index":0,"delta":{"content":"I'll check that for you."},"finish_reason":null}]} From c484c323c35d818e193ca82bd8b9c64ace692d4c Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 31 Jul 2026 18:28:44 +0200 Subject: [PATCH 3/5] test(responses): assert streamed output order --- internal/providers/responses_converter_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/providers/responses_converter_test.go b/internal/providers/responses_converter_test.go index e1b601df8..10efab276 100644 --- a/internal/providers/responses_converter_test.go +++ b/internal/providers/responses_converter_test.go @@ -331,6 +331,7 @@ data: [DONE] indexes := make(map[float64]string) callIndexes := make(map[string]float64) + var addedIndexes []float64 for _, event := range parseTestSSEEvents(t, string(raw)) { if event.Name != "response.output_item.added" { continue @@ -344,6 +345,7 @@ data: [DONE] if previous, exists := indexes[index]; exists { t.Fatalf("output_index %v reused by %q and %q", index, previous, itemID) } + addedIndexes = append(addedIndexes, index) indexes[index] = itemID if callID, _ := item["call_id"].(string); callID != "" { callIndexes[callID] = index @@ -353,6 +355,9 @@ data: [DONE] if len(indexes) != 4 { t.Fatalf("output items = %#v, want reasoning, two calls, and message", indexes) } + if !slices.Equal(addedIndexes, []float64{0, 1, 2, 3}) { + t.Fatalf("output indexes = %#v, want [0 1 2 3]", addedIndexes) + } if callIndexes["call_second"] != 1 || callIndexes["call_first"] != 3 { t.Fatalf("function-call indexes = %#v, want emission-order indexes 1 and 3", callIndexes) } From 47d5ce0b8d0bc067dbf6db796edc9b7d52d9204d Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 1 Aug 2026 22:18:08 +0200 Subject: [PATCH 4/5] test(responses): cover tool choice compatibility --- docs/advanced/responses-compatibility.mdx | 2 +- internal/providers/responses_adapter_test.go | 63 +++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/docs/advanced/responses-compatibility.mdx b/docs/advanced/responses-compatibility.mdx index 73efbe593..574c8b835 100644 --- a/docs/advanced/responses-compatibility.mdx +++ b/docs/advanced/responses-compatibility.mdx @@ -35,7 +35,7 @@ function tool loops. They cannot safely execute OpenAI-hosted tools. | Function tools | Forwarded | Converted to provider function/tool declarations | | Function call output items | Forwarded | Converted to chat tool-result messages | | `text.format` structured output | Forwarded | Converted to `response_format` when the provider supports it | -| Responses-only tools (`web_search`, `file_search`, computer use, and `namespace`) | Provider decides | Accepted and omitted | +| Responses-only tools (`web_search`, `file_search`, `computer_use_preview`, and `namespace`) | Provider decides | Accepted and omitted | | `previous_response_id` and `conversation` | Forwarded | Rejected | | `include` annotations | Forwarded | Accepted and ignored, except `message.output_text.logprobs` | | Unknown Responses input item types | Preserved | Rejected | diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go index 91313ff2d..132bafe19 100644 --- a/internal/providers/responses_adapter_test.go +++ b/internal/providers/responses_adapter_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "math" + "reflect" "strings" "testing" @@ -522,10 +523,17 @@ func TestConvertResponsesRequestToChat_RejectsOutputLogprobsInclude(t *testing.T } func TestConvertResponsesRequestToChat_NormalizesToolChoiceAliases(t *testing.T) { + functionTools := []map[string]any{ + { + "type": "function", + "name": "exec_command", + "parameters": map[string]any{"type": "object"}, + }, + } tests := []struct { name string req *core.ResponsesRequest - want string + want any }{ { name: "tool_choice none alias", @@ -542,6 +550,55 @@ func TestConvertResponsesRequestToChat_NormalizesToolChoiceAliases(t *testing.T) req: &core.ResponsesRequest{Model: "test-model", Input: "Hello", ToolChoice: map[string]any{"type": "required"}}, want: "required", }, + { + name: "bare tool_choice none", + req: &core.ResponsesRequest{Model: "test-model", Input: "Hello", ToolChoice: "none"}, + want: "none", + }, + { + name: "bare tool_choice auto", + req: &core.ResponsesRequest{Model: "test-model", Input: "Hello", ToolChoice: "auto"}, + want: "auto", + }, + { + name: "bare tool_choice required", + req: &core.ResponsesRequest{Model: "test-model", Input: "Hello", ToolChoice: "required"}, + want: "required", + }, + { + name: "unsupported bare tool_choice", + req: &core.ResponsesRequest{Model: "test-model", Input: "Hello", ToolChoice: "web_search"}, + }, + { + name: "function choice without function map", + req: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Tools: functionTools, + ToolChoice: map[string]any{"type": "function", "function": "invalid"}, + }, + want: map[string]any{"type": "function", "function": "invalid"}, + }, + { + name: "function choice without name", + req: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Tools: functionTools, + ToolChoice: map[string]any{"type": "function", "function": map[string]any{}}, + }, + want: map[string]any{"type": "function", "function": map[string]any{}}, + }, + { + name: "function choice with empty name", + req: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Tools: functionTools, + ToolChoice: map[string]any{"type": "function", "function": map[string]any{"name": ""}}, + }, + want: map[string]any{"type": "function", "function": map[string]any{"name": ""}}, + }, } for _, tt := range tests { @@ -550,8 +607,8 @@ func TestConvertResponsesRequestToChat_NormalizesToolChoiceAliases(t *testing.T) if err != nil { t.Fatalf("ConvertResponsesRequestToChat() error = %v", err) } - if chatReq.ToolChoice != tt.want { - t.Fatalf("ToolChoice = %#v, want %q", chatReq.ToolChoice, tt.want) + if !reflect.DeepEqual(chatReq.ToolChoice, tt.want) { + t.Fatalf("ToolChoice = %#v, want %#v", chatReq.ToolChoice, tt.want) } }) } From fcc568e773c56194c3d48ddd3929853ceb8224f9 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 1 Aug 2026 22:24:14 +0200 Subject: [PATCH 5/5] docs(responses): align web search tool aliases --- docs/advanced/responses-compatibility.mdx | 4 ++-- docs/examples/openai-agents-sdk/anthropic_responses_probe.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/advanced/responses-compatibility.mdx b/docs/advanced/responses-compatibility.mdx index 574c8b835..11e4a801c 100644 --- a/docs/advanced/responses-compatibility.mdx +++ b/docs/advanced/responses-compatibility.mdx @@ -66,8 +66,8 @@ Hosted tools are executed by the upstream provider, not by the model text completion alone. Their payloads often reference provider-owned resources and runtime state: -- `web_search_preview` depends on the provider's search implementation and - event schema. +- `web_search` and its legacy `web_search_preview` alias depend on the + provider's search implementation and event schema. - `file_search` references provider vector stores such as `vector_store_ids`. - `computer_use_preview` depends on a provider-managed computer session, display environment, and safety model. diff --git a/docs/examples/openai-agents-sdk/anthropic_responses_probe.py b/docs/examples/openai-agents-sdk/anthropic_responses_probe.py index 16c04099f..b1fa77633 100644 --- a/docs/examples/openai-agents-sdk/anthropic_responses_probe.py +++ b/docs/examples/openai-agents-sdk/anthropic_responses_probe.py @@ -157,7 +157,7 @@ async def main() -> int: lambda: client.responses.create( model=MODEL, input="Search the web for the latest Go release.", - tools=[{"type": "web_search_preview"}], + tools=[{"type": "web_search"}], ), None, ),