feat(executor): promote additional_tools to top-level tools in XAI requests

- Added `promoteXAIAdditionalTools` to normalize and migrate `additional_tools` to the top-level `tools` array in XAI request bodies.
- Updated test cases to validate the promotion logic and ensure unsupported `additional_tools` items are excluded from input payloads.

Closes: #4434
This commit is contained in:
Luis Pater
2026-07-19 16:32:47 +08:00
parent 7c61e982e4
commit f175c084d3
3 changed files with 105 additions and 15 deletions
+59
View File
@@ -915,6 +915,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
// the post-restore (namespace, short-name) shape used by the response filter.
clientDeclaredTools := collectXAIClientDeclaredToolKeys(body)
body = normalizeXAITools(body)
body = promoteXAIAdditionalTools(body)
// Drop choices that point at tools removed by normalizeXAITools before we
// inject native x_search, so a surviving allowed_tools / forced choice is not
// left pointing at a deleted tool once only x_search remains.
@@ -1537,6 +1538,64 @@ func normalizeXAITools(body []byte) []byte {
return body
}
// promoteXAIAdditionalTools moves Responses Lite tool declarations to the
// top-level tools array because xAI does not accept additional_tools input items.
func promoteXAIAdditionalTools(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body
}
inputItems := input.Array()
remainingInput := make([]json.RawMessage, 0, len(inputItems))
promotedTools := make([]json.RawMessage, 0)
for _, item := range inputItems {
if item.Get("type").String() != "additional_tools" {
remainingInput = append(remainingInput, json.RawMessage(item.Raw))
continue
}
for _, tool := range item.Get("tools").Array() {
promotedTools = append(promotedTools, json.RawMessage(tool.Raw))
}
}
if len(remainingInput) == len(inputItems) {
return body
}
rawInput, errMarshalInput := json.Marshal(remainingInput)
if errMarshalInput != nil {
return body
}
updated, errSetInput := sjson.SetRawBytes(body, "input", rawInput)
if errSetInput != nil {
return body
}
if len(promotedTools) == 0 {
return updated
}
topLevelTools := gjson.GetBytes(updated, "tools")
tools := make([]json.RawMessage, 0, len(topLevelTools.Array())+len(promotedTools))
if topLevelTools.IsArray() {
for _, tool := range topLevelTools.Array() {
tools = append(tools, json.RawMessage(tool.Raw))
}
}
tools = append(tools, promotedTools...)
rawTools, errMarshalTools := json.Marshal(tools)
if errMarshalTools != nil {
return body
}
updated, errSetTools := sjson.SetRawBytes(updated, "tools", rawTools)
if errSetTools != nil {
return body
}
return updated
}
func normalizeXAIToolArray(tools gjson.Result) ([]byte, bool, bool) {
changed := false
filtered := []byte(`[]`)
+37 -14
View File
@@ -253,15 +253,23 @@ func TestXAIExecutorExecuteRestoresAdditionalToolsNamespaceCalls(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
tool := gjson.GetBytes(gotBody, "input.0.tools.0")
for _, item := range gjson.GetBytes(gotBody, "input").Array() {
if got := item.Get("type").String(); got == "additional_tools" {
t.Fatalf("upstream input contains unsupported additional_tools item: %s", gotBody)
}
}
if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want user; body=%s", got, gotBody)
}
tool := gjson.GetBytes(gotBody, "tools.0")
if got := tool.Get("name").String(); got != "mcp__exa__web_search_exa" {
t.Fatalf("upstream additional tool name = %q, want qualified name; body=%s", got, gotBody)
t.Fatalf("upstream tool name = %q, want qualified name; body=%s", got, gotBody)
}
if got := tool.Get("type").String(); got != "function" {
t.Fatalf("upstream additional tool type = %q, want function; body=%s", got, gotBody)
t.Fatalf("upstream tool type = %q, want function; body=%s", got, gotBody)
}
if tool.Get("tools").Exists() {
t.Fatalf("upstream additional tool should not contain namespace children: %s", gotBody)
t.Fatalf("upstream tool should not contain namespace children: %s", gotBody)
}
output := gjson.GetBytes(resp.Payload, "output.0")
if got := output.Get("name").String(); got != "web_search_exa" {
@@ -2907,24 +2915,39 @@ func TestNormalizeXAITools_QualifiesSameNamedNamespaceTools(t *testing.T) {
}
}
func TestNormalizeXAITools_AdditionalToolsNamespace(t *testing.T) {
func TestPromoteXAIAdditionalTools(t *testing.T) {
body := []byte(`{
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
"input":[
{"type":"additional_tools","role":"developer","tools":[{"type":"namespace","name":"mcp__exa","tools":[{"type":"function","name":"search","parameters":{"type":"object"}}]}]},
{"role":"user","content":"hello"}
{"role":"user","content":"hello"},
{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"custom_lookup"}]}
]
}`)
out := normalizeXAITools(body)
out := promoteXAIAdditionalTools(normalizeXAITools(body))
tools := gjson.GetBytes(out, "input.0.tools").Array()
if len(tools) != 1 {
t.Fatalf("additional tools length = %d, want 1; body=%s", len(tools), string(out))
input := gjson.GetBytes(out, "input").Array()
if len(input) != 1 || input[0].Get("role").String() != "user" {
t.Fatalf("input should contain only the user message: %s", string(out))
}
if got := tools[0].Get("name").String(); got != "mcp__exa__search" {
t.Fatalf("additional tool name = %q, want mcp__exa__search; body=%s", got, string(out))
tools := gjson.GetBytes(out, "tools").Array()
if len(tools) != 3 {
t.Fatalf("tools length = %d, want 3; body=%s", len(tools), string(out))
}
if got := tools[0].Get("type").String(); got != "function" {
t.Fatalf("additional tool type = %q, want function; body=%s", got, string(out))
if got := tools[0].Get("name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup; body=%s", got, string(out))
}
if got := tools[1].Get("name").String(); got != "mcp__exa__search" {
t.Fatalf("tools.1.name = %q, want mcp__exa__search; body=%s", got, string(out))
}
if got := tools[2].Get("name").String(); got != "custom_lookup" {
t.Fatalf("tools.2.name = %q, want custom_lookup; body=%s", got, string(out))
}
if got := tools[2].Get("type").String(); got != "function" {
t.Fatalf("tools.2.type = %q, want function; body=%s", got, string(out))
}
if !tools[2].Get("parameters").Exists() {
t.Fatalf("tools.2.parameters missing: %s", string(out))
}
}
@@ -202,7 +202,15 @@ func TestXAIWebsocketsExecuteStreamRestoresNamespaceToolCalls(t *testing.T) {
select {
case payload := <-capturedPayload:
tool := gjson.GetBytes(payload, "input.0.tools.0")
for _, item := range gjson.GetBytes(payload, "input").Array() {
if got := item.Get("type").String(); got == "additional_tools" {
t.Fatalf("upstream input contains unsupported additional_tools item: %s", payload)
}
}
if got := gjson.GetBytes(payload, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want user; payload=%s", got, payload)
}
tool := gjson.GetBytes(payload, "tools.0")
if got := tool.Get("name").String(); got != "mcp__exa__web_search_exa" {
t.Fatalf("upstream tool name = %q, want qualified name; payload=%s", got, payload)
}