diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index cc2ff93c2..6ee90d66f 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -97,6 +97,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox // Drop choices that point at tools removed by normalizeXAITools before any // configured x_search injection, so no surviving choice references a deleted tool. body = normalizeXAINamespaceToolChoice(body) + body = normalizeXAIForcedWebSearchToolChoice(body) body = pruneXAIOrphanedToolChoice(body) body = normalizeXAIToolChoiceForTools(body) if e.cfg != nil && e.cfg.XAI.InjectXSearch { @@ -584,6 +585,26 @@ func ensureXAINativeXSearchAllowedTools(body []byte) []byte { return body } +// normalizeXAIForcedWebSearchToolChoice rewrites Codex's hosted-tool choice +// into the allowed_tools form accepted by xAI's ModelToolChoice schema. +func normalizeXAIForcedWebSearchToolChoice(body []byte) []byte { + choice := gjson.GetBytes(body, "tool_choice") + if !choice.IsObject() || strings.TrimSpace(choice.Get("type").String()) != xaiWebSearchToolType { + return body + } + + allowedChoice := []byte(`{"type":"allowed_tools","mode":"required","tools":[]}`) + allowedChoice, errSetAllowed := sjson.SetRawBytes(allowedChoice, "tools.-1", []byte(choice.Raw)) + if errSetAllowed != nil { + return body + } + updated, errSetChoice := sjson.SetRawBytes(body, "tool_choice", allowedChoice) + if errSetChoice != nil { + return body + } + return updated +} + // pruneXAIOrphanedToolChoice removes tool_choice entries that no longer match // any remaining tool after normalizeXAITools filtering. Forced choices that // reference a deleted tool are dropped entirely; allowed_tools lists keep only diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 79eb16a36..74548bd89 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -802,6 +802,46 @@ func TestEnsureXAINativeXSearchTool(t *testing.T) { } } +func TestXAIExecutorPrepareNormalizesClaudeWebSearchToolChoice(t *testing.T) { + t.Parallel() + + exec := NewXAIExecutor(&config.Config{}) + prepared, errPrepare := exec.prepareResponsesRequest(context.Background(), cliproxyexecutor.Request{ + Model: "grok-4.5", + Payload: []byte(`{ + "model":"grok-4.5", + "max_tokens":4096, + "stream":true, + "output_config":{"effort":"high"}, + "thinking":{"type":"disabled"}, + "messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search"}]}], + "tool_choice":{"type":"tool","name":"web_search"}, + "tools":[{"type":"web_search_20250305","name":"web_search","max_uses":8}] + }`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Stream: true, + }, true) + if errPrepare != nil { + t.Fatalf("prepareResponsesRequest() error = %v", errPrepare) + } + + choice := gjson.GetBytes(prepared.body, "tool_choice") + if got := choice.Get("type").String(); got != "allowed_tools" { + t.Fatalf("tool_choice.type = %q, want allowed_tools; body=%s", got, prepared.body) + } + if got := choice.Get("mode").String(); got != "required" { + t.Fatalf("tool_choice.mode = %q, want required; body=%s", got, prepared.body) + } + allowed := choice.Get("tools").Array() + if len(allowed) != 1 { + t.Fatalf("tool_choice.tools length = %d, want 1; body=%s", len(allowed), prepared.body) + } + if got := allowed[0].Get("type").String(); got != "web_search" { + t.Fatalf("tool_choice.tools.0.type = %q, want web_search; body=%s", got, prepared.body) + } +} + func TestPruneXAIOrphanedToolChoice(t *testing.T) { t.Parallel()