diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index cb42f9393..5cb279498 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -506,6 +506,7 @@ func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxye body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeXAITools(body) + body = normalizeXAIToolChoiceForTools(body) body = normalizeXAIInputReasoningItems(body) body = normalizeCodexInstructions(body) body = sanitizeXAIResponsesBody(body, baseModel) @@ -715,6 +716,28 @@ func normalizeXAITools(body []byte) []byte { return updated } +// normalizeXAIToolChoiceForTools drops tool_choice and parallel_tool_calls +// when tools are absent or empty (including after normalizeXAITools filtering). +// xAI rejects payloads that include tool_choice without any tools defined. +// Existence checks avoid unnecessary sjson parse/copy passes. +func normalizeXAIToolChoiceForTools(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 + if hasTools { + return body + } + if tools.Exists() { + body, _ = sjson.DeleteBytes(body, "tools") + } + if gjson.GetBytes(body, "tool_choice").Exists() { + body, _ = sjson.DeleteBytes(body, "tool_choice") + } + if gjson.GetBytes(body, "parallel_tool_calls").Exists() { + body, _ = sjson.DeleteBytes(body, "parallel_tool_calls") + } + return body +} + func normalizeXAITool(tool gjson.Result) ([]byte, bool, bool) { toolType := tool.Get("type").String() changed := false diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 5579cd904..e8c11cf6e 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -592,3 +592,57 @@ func TestXAIExecutorExecuteVideosUsesNativeEndpointFromRequestPath(t *testing.T) }) } } + +func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsEmpty(t *testing.T) { + body := []byte(`{"model":"grok-4","tools":[],"tool_choice":"auto","parallel_tool_calls":true,"input":"hi"}`) + out := normalizeXAIToolChoiceForTools(body) + + if gjson.GetBytes(out, "tools").Exists() { + t.Fatalf("empty tools should be removed: %s", string(out)) + } + if gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("tool_choice should be removed when tools empty: %s", string(out)) + } + if gjson.GetBytes(out, "parallel_tool_calls").Exists() { + t.Fatalf("parallel_tool_calls should be removed when tools empty: %s", string(out)) + } +} + +func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsMissing(t *testing.T) { + body := []byte(`{"model":"grok-4","tool_choice":"auto","input":"hi"}`) + out := normalizeXAIToolChoiceForTools(body) + + if gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("tool_choice should be removed when tools missing: %s", string(out)) + } +} + +func TestNormalizeXAIToolChoiceForTools_DropsOrphanedParallelToolCalls(t *testing.T) { + body := []byte(`{"model":"grok-4","parallel_tool_calls":true,"input":"hi"}`) + out := normalizeXAIToolChoiceForTools(body) + + if gjson.GetBytes(out, "parallel_tool_calls").Exists() { + t.Fatalf("parallel_tool_calls should be removed when tools missing even without tool_choice: %s", string(out)) + } +} + +func TestNormalizeXAIToolChoiceForTools_KeepsWhenToolsPresent(t *testing.T) { + body := []byte(`{"model":"grok-4","tools":[{"type":"function","name":"Bash"}],"tool_choice":"auto","input":"hi"}`) + out := normalizeXAIToolChoiceForTools(body) + + if !gjson.GetBytes(out, "tools").Exists() { + t.Fatalf("tools should be kept: %s", string(out)) + } + if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" { + t.Fatalf("tool_choice = %q, want auto: %s", got, string(out)) + } +} + +func TestNormalizeXAIToolChoiceForTools_NoOpWhenBothAbsent(t *testing.T) { + body := []byte(`{"model":"grok-4","input":"hi"}`) + out := normalizeXAIToolChoiceForTools(body) + + if gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("tool_choice should not appear: %s", string(out)) + } +}