diff --git a/internal/api/modules/amp/response_rewriter.go b/internal/api/modules/amp/response_rewriter.go index e6f20c57..e25e7cc9 100644 --- a/internal/api/modules/amp/response_rewriter.go +++ b/internal/api/modules/amp/response_rewriter.go @@ -125,7 +125,32 @@ func (rw *ResponseRewriter) Flush() { var modelFieldPaths = []string{"model", "modelVersion", "response.modelVersion", "message.model"} // rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON +// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte { + // 1. Amp Compatibility: Suppress thinking blocks if tool use is detected + // The Amp client struggles when both thinking and tool_use blocks are present + // 1. Amp Compatibility: Suppress thinking blocks if tool use is detected + // The Amp client struggles when both thinking and tool_use blocks are present + if gjson.GetBytes(data, `content.#(type=="tool_use")`).Exists() { + filtered := gjson.GetBytes(data, `content.#(type!="thinking")#`) + if filtered.Exists() { + originalCount := gjson.GetBytes(data, "content.#").Int() + filteredCount := filtered.Get("#").Int() + + if originalCount > filteredCount { + var err error + data, err = sjson.SetBytes(data, "content", filtered.Value()) + if err != nil { + log.Warnf("Amp ResponseRewriter: failed to suppress thinking blocks: %v", err) + } else { + log.Debugf("Amp ResponseRewriter: Suppressed %d thinking blocks due to tool usage", originalCount-filteredCount) + // Log the result for verification + log.Debugf("Amp ResponseRewriter: Resulting content: %s", gjson.GetBytes(data, "content").String()) + } + } + } + } + if rw.originalModel == "" { return data } diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 44e0d2d8..89bdbe49 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -157,7 +157,13 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) continue } - err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + if httpResp.StatusCode == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + err = sErr return resp, err } @@ -171,7 +177,13 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au switch { case lastStatus != 0: - err = statusErr{code: lastStatus, msg: string(lastBody)} + sErr := statusErr{code: lastStatus, msg: string(lastBody)} + if lastStatus == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + err = sErr case lastErr != nil: err = lastErr default: @@ -261,7 +273,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) continue } - err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + if httpResp.StatusCode == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + err = sErr return resp, err } @@ -326,7 +344,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * switch { case lastStatus != 0: - err = statusErr{code: lastStatus, msg: string(lastBody)} + sErr := statusErr{code: lastStatus, msg: string(lastBody)} + if lastStatus == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + err = sErr case lastErr != nil: err = lastErr default: @@ -602,7 +626,13 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) continue } - err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + if httpResp.StatusCode == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + err = sErr return nil, err } @@ -657,7 +687,13 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya switch { case lastStatus != 0: - err = statusErr{code: lastStatus, msg: string(lastBody)} + sErr := statusErr{code: lastStatus, msg: string(lastBody)} + if lastStatus == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + err = sErr case lastErr != nil: err = lastErr default: @@ -794,12 +830,24 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) continue } - return cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + if httpResp.StatusCode == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + return cliproxyexecutor.Response{}, sErr } switch { case lastStatus != 0: - return cliproxyexecutor.Response{}, statusErr{code: lastStatus, msg: string(lastBody)} + sErr := statusErr{code: lastStatus, msg: string(lastBody)} + if lastStatus == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + return cliproxyexecutor.Response{}, sErr case lastErr != nil: return cliproxyexecutor.Response{}, lastErr default: @@ -968,7 +1016,13 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau } if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { - return auth, statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + if httpResp.StatusCode == http.StatusTooManyRequests { + if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil { + sErr.retryAfter = retryAfter + } + } + return auth, sErr } var tokenResp struct { diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index e3909d45..c700ef84 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -20,6 +20,12 @@ var ( dataTag = []byte("data:") ) +// ConvertCodexResponseToClaudeParams holds parameters for response conversion. +type ConvertCodexResponseToClaudeParams struct { + HasToolCall bool + BlockIndex int +} + // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. // This function implements a complex state machine that translates Codex API responses // into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types @@ -38,8 +44,10 @@ var ( // - []string: A slice of strings, each containing a Claude Code-compatible JSON response func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string { if *param == nil { - hasToolCall := false - *param = &hasToolCall + *param = &ConvertCodexResponseToClaudeParams{ + HasToolCall: false, + BlockIndex: 0, + } } // log.Debugf("rawJSON: %s", string(rawJSON)) @@ -62,46 +70,49 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output += fmt.Sprintf("data: %s\n\n", template) } else if typeStr == "response.reasoning_summary_part.added" { template = `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) output = "event: content_block_start\n" output += fmt.Sprintf("data: %s\n\n", template) } else if typeStr == "response.reasoning_summary_text.delta" { template = `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) template, _ = sjson.Set(template, "delta.thinking", rootResult.Get("delta").String()) output = "event: content_block_delta\n" output += fmt.Sprintf("data: %s\n\n", template) } else if typeStr == "response.reasoning_summary_part.done" { template = `{"type":"content_block_stop","index":0}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++ output = "event: content_block_stop\n" output += fmt.Sprintf("data: %s\n\n", template) + } else if typeStr == "response.content_part.added" { template = `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) output = "event: content_block_start\n" output += fmt.Sprintf("data: %s\n\n", template) } else if typeStr == "response.output_text.delta" { template = `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) template, _ = sjson.Set(template, "delta.text", rootResult.Get("delta").String()) output = "event: content_block_delta\n" output += fmt.Sprintf("data: %s\n\n", template) } else if typeStr == "response.content_part.done" { template = `{"type":"content_block_stop","index":0}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++ output = "event: content_block_stop\n" output += fmt.Sprintf("data: %s\n\n", template) } else if typeStr == "response.completed" { template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` - p := (*param).(*bool) - if *p { + p := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall + if p { template, _ = sjson.Set(template, "delta.stop_reason", "tool_use") } else { template, _ = sjson.Set(template, "delta.stop_reason", "end_turn") @@ -118,10 +129,9 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() if itemType == "function_call" { - p := true - *param = &p + (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall = true template = `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) template, _ = sjson.Set(template, "content_block.id", itemResult.Get("call_id").String()) { // Restore original tool name if shortened @@ -137,7 +147,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output += fmt.Sprintf("data: %s\n\n", template) template = `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) output += "event: content_block_delta\n" output += fmt.Sprintf("data: %s\n\n", template) @@ -147,14 +157,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa itemType := itemResult.Get("type").String() if itemType == "function_call" { template = `{"type":"content_block_stop","index":0}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) + (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++ output = "event: content_block_stop\n" output += fmt.Sprintf("data: %s\n\n", template) } } else if typeStr == "response.function_call_arguments.delta" { template = `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}` - template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int()) + template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex) template, _ = sjson.Set(template, "delta.partial_json", rootResult.Get("delta").String()) output += "event: content_block_delta\n" diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go index 272037da..309c974e 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -275,7 +275,15 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b arr := tools.Array() for i := 0; i < len(arr); i++ { t := arr[i] - if t.Get("type").String() == "function" { + toolType := t.Get("type").String() + // Pass through built-in tools (e.g. {"type":"web_search"}) directly for the Responses API. + // Only "function" needs structural conversion because Chat Completions nests details under "function". + if toolType != "" && toolType != "function" && t.IsObject() { + out, _ = sjson.SetRaw(out, "tools.-1", t.Raw) + continue + } + + if toolType == "function" { item := `{}` item, _ = sjson.Set(item, "type", "function") fn := t.Get("function") @@ -304,6 +312,37 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b } } + // Map tool_choice when present. + // Chat Completions: "tool_choice" can be a string ("auto"/"none") or an object (e.g. {"type":"function","function":{"name":"..."}}). + // Responses API: keep built-in tool choices as-is; flatten function choice to {"type":"function","name":"..."}. + if tc := gjson.GetBytes(rawJSON, "tool_choice"); tc.Exists() { + switch { + case tc.Type == gjson.String: + out, _ = sjson.Set(out, "tool_choice", tc.String()) + case tc.IsObject(): + tcType := tc.Get("type").String() + if tcType == "function" { + name := tc.Get("function.name").String() + if name != "" { + if short, ok := originalToolNameMap[name]; ok { + name = short + } else { + name = shortenNameIfNeeded(name) + } + } + choice := `{}` + choice, _ = sjson.Set(choice, "type", "function") + if name != "" { + choice, _ = sjson.Set(choice, "name", name) + } + out, _ = sjson.SetRaw(out, "tool_choice", choice) + } else if tcType != "" { + // Built-in tool choices (e.g. {"type":"web_search"}) are already Responses-compatible. + out, _ = sjson.SetRaw(out, "tool_choice", tc.Raw) + } + } + } + out, _ = sjson.Set(out, "store", false) return []byte(out) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 687c2a30..86cf19f8 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -163,6 +163,14 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu var chatCompletionsTools []interface{} tools.ForEach(func(_, tool gjson.Result) bool { + // Built-in tools (e.g. {"type":"web_search"}) are already compatible with the Chat Completions schema. + // Only function tools need structural conversion because Chat Completions nests details under "function". + toolType := tool.Get("type").String() + if toolType != "" && toolType != "function" && tool.IsObject() { + chatCompletionsTools = append(chatCompletionsTools, tool.Value()) + return true + } + chatTool := `{"type":"function","function":{}}` // Convert tool structure from responses format to chat completions format diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 698d0102..52da9a44 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "path/filepath" "strconv" "strings" "sync" @@ -388,22 +389,8 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req return cliproxyexecutor.Response{}, errPick } - accountType, accountInfo := auth.AccountInfo() - proxyInfo := auth.ProxyInfo() entry := logEntryWithRequestID(ctx) - if accountType == "api_key" { - if proxyInfo != "" { - entry.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo) - } else { - entry.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model) - } - } else if accountType == "oauth" { - if proxyInfo != "" { - entry.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo) - } else { - entry.Debugf("Use OAuth %s for model %s", accountInfo, req.Model) - } - } + debugLogAuthSelection(entry, auth, provider, req.Model) tried[auth.ID] = struct{}{} execCtx := ctx @@ -450,22 +437,8 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string, return cliproxyexecutor.Response{}, errPick } - accountType, accountInfo := auth.AccountInfo() - proxyInfo := auth.ProxyInfo() entry := logEntryWithRequestID(ctx) - if accountType == "api_key" { - if proxyInfo != "" { - entry.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo) - } else { - entry.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model) - } - } else if accountType == "oauth" { - if proxyInfo != "" { - entry.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo) - } else { - entry.Debugf("Use OAuth %s for model %s", accountInfo, req.Model) - } - } + debugLogAuthSelection(entry, auth, provider, req.Model) tried[auth.ID] = struct{}{} execCtx := ctx @@ -512,22 +485,8 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string return nil, errPick } - accountType, accountInfo := auth.AccountInfo() - proxyInfo := auth.ProxyInfo() entry := logEntryWithRequestID(ctx) - if accountType == "api_key" { - if proxyInfo != "" { - entry.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo) - } else { - entry.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model) - } - } else if accountType == "oauth" { - if proxyInfo != "" { - entry.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo) - } else { - entry.Debugf("Use OAuth %s for model %s", accountInfo, req.Model) - } - } + debugLogAuthSelection(entry, auth, provider, req.Model) tried[auth.ID] = struct{}{} execCtx := ctx @@ -1626,6 +1585,66 @@ func logEntryWithRequestID(ctx context.Context) *log.Entry { return log.NewEntry(log.StandardLogger()) } +func debugLogAuthSelection(entry *log.Entry, auth *Auth, provider string, model string) { + if !log.IsLevelEnabled(log.DebugLevel) { + return + } + if entry == nil || auth == nil { + return + } + accountType, accountInfo := auth.AccountInfo() + proxyInfo := auth.ProxyInfo() + suffix := "" + if proxyInfo != "" { + suffix = " " + proxyInfo + } + switch accountType { + case "api_key": + entry.Debugf("Use API key %s for model %s%s", util.HideAPIKey(accountInfo), model, suffix) + case "oauth": + ident := formatOauthIdentity(auth, provider, accountInfo) + entry.Debugf("Use OAuth %s for model %s%s", ident, model, suffix) + } +} + +func formatOauthIdentity(auth *Auth, provider string, accountInfo string) string { + if auth == nil { + return "" + } + authIndex := auth.EnsureIndex() + // Prefer the auth's provider when available. + providerName := strings.TrimSpace(auth.Provider) + if providerName == "" { + providerName = strings.TrimSpace(provider) + } + // Only log the basename to avoid leaking host paths. + // FileName may be unset for some auth backends; fall back to ID. + authFile := strings.TrimSpace(auth.FileName) + if authFile == "" { + authFile = strings.TrimSpace(auth.ID) + } + if authFile != "" { + authFile = filepath.Base(authFile) + } + parts := make([]string, 0, 3) + if providerName != "" { + parts = append(parts, "provider="+providerName) + } + if authFile != "" { + parts = append(parts, "auth_file="+authFile) + } + if authIndex != "" { + parts = append(parts, "auth_index="+authIndex) + } + if len(parts) == 0 { + return accountInfo + } + if accountInfo == "" { + return strings.Join(parts, " ") + } + return strings.Join(parts, " ") + " account=" + strconv.Quote(accountInfo) +} + // InjectCredentials delegates per-provider HTTP request preparation when supported. // If the registered executor for the auth provider implements RequestPreparer, // it will be invoked to modify the request (e.g., add headers). diff --git a/test/builtin_tools_translation_test.go b/test/builtin_tools_translation_test.go new file mode 100644 index 00000000..b4ca7b0d --- /dev/null +++ b/test/builtin_tools_translation_test.go @@ -0,0 +1,54 @@ +package test + +import ( + "testing" + + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) { + in := []byte(`{ + "model":"gpt-5", + "messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"web_search","search_context_size":"high"}], + "tool_choice":{"type":"web_search"} + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAI, sdktranslator.FormatCodex, "gpt-5", in, false) + + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("expected 1 tool, got %d: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" { + t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "high" { + t.Fatalf("expected tools[0].search_context_size=high, got %q: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "web_search" { + t.Fatalf("expected tool_choice.type=web_search, got %q: %s", got, string(out)) + } +} + +func TestOpenAIResponsesToOpenAI_PreservesBuiltinTools(t *testing.T) { + in := []byte(`{ + "model":"gpt-5", + "input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}], + "tools":[{"type":"web_search","search_context_size":"low"}] + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false) + + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("expected 1 tool, got %d: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" { + t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "low" { + t.Fatalf("expected tools[0].search_context_size=low, got %q: %s", got, string(out)) + } +}