diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index c52d3f5f..0acc5af7 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -861,6 +861,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox body, _ = sjson.DeleteBytes(body, "stream_options") namespaceTools := collectXAINamespaceToolRefs(body) body = normalizeXAITools(body) + body = normalizeXAINamespaceToolChoice(body) body = normalizeXAIToolChoiceForTools(body) var replayScope xaiReasoningReplayScope body, replayScope, err = applyXAIReasoningReplayCacheRequired(ctx, from, req, opts, body) @@ -1246,6 +1247,34 @@ func normalizeXAIToolChoiceForTools(body []byte) []byte { return body } +// normalizeXAINamespaceToolChoice qualifies a forced namespaced function choice +// using the same name sent in the flattened tools list. xAI does not accept the +// Responses namespace field on tool_choice. +func normalizeXAINamespaceToolChoice(body []byte) []byte { + if !gjson.ValidBytes(body) { + return body + } + toolChoice := gjson.GetBytes(body, "tool_choice") + if !toolChoice.IsObject() || toolChoice.Get("type").String() != xaiFunctionToolType { + return body + } + namespaceName := strings.TrimSpace(toolChoice.Get("namespace").String()) + toolName := strings.TrimSpace(toolChoice.Get("name").String()) + qualifiedName := qualifyXAINamespaceToolName(namespaceName, toolName) + if namespaceName == "" || qualifiedName == "" { + return body + } + updated, errSet := sjson.SetBytes(body, "tool_choice.name", qualifiedName) + if errSet != nil { + return body + } + updated, errDelete := sjson.DeleteBytes(updated, "tool_choice.namespace") + if errDelete != nil { + return body + } + return updated +} + func normalizeXAITool(tool gjson.Result, namespaceName string) ([]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 f69cb737..112d7e8e 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -1136,6 +1136,44 @@ func TestNormalizeXAITools_QualifiesSameNamedNamespaceTools(t *testing.T) { } } +func TestNormalizeXAINamespaceToolChoice(t *testing.T) { + body := []byte(`{ + "tools":[{"type":"namespace","name":"mcp__exa","tools":[{"type":"function","name":"search","parameters":{"type":"object"}}]}], + "tool_choice":{"type":"function","name":"search","namespace":"mcp__exa"} + }`) + out := normalizeXAITools(body) + out = normalizeXAINamespaceToolChoice(out) + + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "mcp__exa__search" { + t.Fatalf("tools.0.name = %q, want mcp__exa__search; body=%s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "mcp__exa__search" { + t.Fatalf("tool_choice.name = %q, want mcp__exa__search; body=%s", got, string(out)) + } + if gjson.GetBytes(out, "tool_choice.namespace").Exists() { + t.Fatalf("tool_choice.namespace should be removed for xAI upstream: %s", string(out)) + } +} + +func TestNormalizeXAINamespaceToolChoice_PreservesOtherChoices(t *testing.T) { + tests := []struct { + name string + body []byte + }{ + {name: "automatic choice", body: []byte(`{"tool_choice":"auto"}`)}, + {name: "top-level function", body: []byte(`{"tool_choice":{"type":"function","name":"search"}}`)}, + {name: "non-function choice", body: []byte(`{"tool_choice":{"type":"web_search","name":"search","namespace":"mcp__exa"}}`)}, + {name: "malformed payload", body: []byte(`{"tool_choice":{"type":"function"`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeXAINamespaceToolChoice(tt.body); !bytes.Equal(got, tt.body) { + t.Fatalf("payload changed: got=%q want=%q", got, tt.body) + } + }) + } +} + func TestQualifyXAINamespaceToolNamePreservesQualifiedNames(t *testing.T) { tests := []struct { name string