Skip to content

Commit 387c783

Browse files
authored
Merge pull request router-for-me#3649 from intcua/fix/xai-empty-tools-orphan-tool-choice
fix(executor/xai): drop orphaned tool_choice when Claude tools array is empty
2 parents 90d46e7 + 303685c commit 387c783

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

internal/runtime/executor/xai_executor.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxye
506506
body, _ = sjson.DeleteBytes(body, "safety_identifier")
507507
body, _ = sjson.DeleteBytes(body, "stream_options")
508508
body = normalizeXAITools(body)
509+
body = normalizeXAIToolChoiceForTools(body)
509510
body = normalizeXAIInputReasoningItems(body)
510511
body = normalizeCodexInstructions(body)
511512
body = sanitizeXAIResponsesBody(body, baseModel)
@@ -715,6 +716,28 @@ func normalizeXAITools(body []byte) []byte {
715716
return updated
716717
}
717718

719+
// normalizeXAIToolChoiceForTools drops tool_choice and parallel_tool_calls
720+
// when tools are absent or empty (including after normalizeXAITools filtering).
721+
// xAI rejects payloads that include tool_choice without any tools defined.
722+
// Existence checks avoid unnecessary sjson parse/copy passes.
723+
func normalizeXAIToolChoiceForTools(body []byte) []byte {
724+
tools := gjson.GetBytes(body, "tools")
725+
hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
726+
if hasTools {
727+
return body
728+
}
729+
if tools.Exists() {
730+
body, _ = sjson.DeleteBytes(body, "tools")
731+
}
732+
if gjson.GetBytes(body, "tool_choice").Exists() {
733+
body, _ = sjson.DeleteBytes(body, "tool_choice")
734+
}
735+
if gjson.GetBytes(body, "parallel_tool_calls").Exists() {
736+
body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
737+
}
738+
return body
739+
}
740+
718741
func normalizeXAITool(tool gjson.Result) ([]byte, bool, bool) {
719742
toolType := tool.Get("type").String()
720743
changed := false

internal/runtime/executor/xai_executor_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,57 @@ func TestXAIExecutorExecuteVideosUsesNativeEndpointFromRequestPath(t *testing.T)
592592
})
593593
}
594594
}
595+
596+
func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsEmpty(t *testing.T) {
597+
body := []byte(`{"model":"grok-4","tools":[],"tool_choice":"auto","parallel_tool_calls":true,"input":"hi"}`)
598+
out := normalizeXAIToolChoiceForTools(body)
599+
600+
if gjson.GetBytes(out, "tools").Exists() {
601+
t.Fatalf("empty tools should be removed: %s", string(out))
602+
}
603+
if gjson.GetBytes(out, "tool_choice").Exists() {
604+
t.Fatalf("tool_choice should be removed when tools empty: %s", string(out))
605+
}
606+
if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
607+
t.Fatalf("parallel_tool_calls should be removed when tools empty: %s", string(out))
608+
}
609+
}
610+
611+
func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsMissing(t *testing.T) {
612+
body := []byte(`{"model":"grok-4","tool_choice":"auto","input":"hi"}`)
613+
out := normalizeXAIToolChoiceForTools(body)
614+
615+
if gjson.GetBytes(out, "tool_choice").Exists() {
616+
t.Fatalf("tool_choice should be removed when tools missing: %s", string(out))
617+
}
618+
}
619+
620+
func TestNormalizeXAIToolChoiceForTools_DropsOrphanedParallelToolCalls(t *testing.T) {
621+
body := []byte(`{"model":"grok-4","parallel_tool_calls":true,"input":"hi"}`)
622+
out := normalizeXAIToolChoiceForTools(body)
623+
624+
if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
625+
t.Fatalf("parallel_tool_calls should be removed when tools missing even without tool_choice: %s", string(out))
626+
}
627+
}
628+
629+
func TestNormalizeXAIToolChoiceForTools_KeepsWhenToolsPresent(t *testing.T) {
630+
body := []byte(`{"model":"grok-4","tools":[{"type":"function","name":"Bash"}],"tool_choice":"auto","input":"hi"}`)
631+
out := normalizeXAIToolChoiceForTools(body)
632+
633+
if !gjson.GetBytes(out, "tools").Exists() {
634+
t.Fatalf("tools should be kept: %s", string(out))
635+
}
636+
if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" {
637+
t.Fatalf("tool_choice = %q, want auto: %s", got, string(out))
638+
}
639+
}
640+
641+
func TestNormalizeXAIToolChoiceForTools_NoOpWhenBothAbsent(t *testing.T) {
642+
body := []byte(`{"model":"grok-4","input":"hi"}`)
643+
out := normalizeXAIToolChoiceForTools(body)
644+
645+
if gjson.GetBytes(out, "tool_choice").Exists() {
646+
t.Fatalf("tool_choice should not appear: %s", string(out))
647+
}
648+
}

0 commit comments

Comments
 (0)