Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 28 additions & 30 deletions docs/advanced/responses-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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_preview`, and `namespace`) | Provider decides | Accepted and omitted |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `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 |
Expand Down Expand Up @@ -67,29 +66,27 @@ 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.

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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
14 changes: 7 additions & 7 deletions docs/examples/openai-agents-sdk/anthropic_responses_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}],
tools=[{"type": "web_search"}],
),
"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.",
Expand All @@ -184,7 +184,7 @@ async def main() -> int:
}
],
),
"computer_use_preview",
None,
),
]

Expand Down
122 changes: 69 additions & 53 deletions internal/providers/responses_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,25 @@ 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 {
if len(tools) == 0 {
toolChoice = nil
parallelToolCalls = nil
} else {
toolChoice = dropUnavailableResponsesToolChoice(toolChoice, tools)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
Expand Down Expand Up @@ -104,12 +115,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
}

Expand All @@ -122,12 +127,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 {
Expand All @@ -137,31 +142,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
Expand Down Expand Up @@ -248,17 +228,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
Expand All @@ -274,8 +243,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
}

Expand Down Expand Up @@ -309,9 +290,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)
Expand All @@ -321,7 +311,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)
Expand All @@ -338,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)
}
Expand Down
Loading
Loading