fix(xai): normalize forced web_search tool_choice during Responses request prep

Closes: #4718
This commit is contained in:
Luis Pater
2026-08-09 23:15:46 +08:00
parent 6710a5af30
commit a6825fe992
2 changed files with 61 additions and 0 deletions

View File

@@ -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

View File

@@ -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()