fix(xai): qualify namespaced tool choices

This commit is contained in:
huahai0202
2026-07-12 23:58:16 +08:00
parent c4eda81b93
commit eb6d169407
2 changed files with 67 additions and 0 deletions
+29
View File
@@ -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
@@ -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