From 7bb8132817a6b2f53d22be9e25e69612e39b8648 Mon Sep 17 00:00:00 2001 From: vvanglro Date: Mon, 13 Jul 2026 23:45:44 +0800 Subject: [PATCH] fix(xai): normalize custom tool call history --- internal/runtime/executor/xai_executor.go | 92 +++++++++++++++++++ .../runtime/executor/xai_executor_test.go | 84 +++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 45ab5422..a769f0dd 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -869,6 +869,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox if err != nil { return nil, err } + body = normalizeXAIInputCustomToolCalls(body) body = normalizeXAIInputNamespaceToolCalls(body) body = normalizeXAIInputReasoningItems(body) body = sanitizeXAIInputEncryptedContent(body) @@ -1455,6 +1456,97 @@ func collectXAINamespaceToolRefs(body []byte) map[string]xaiNamespaceToolRef { return refs } +func normalizeXAIInputCustomToolCalls(body []byte) []byte { + input := gjson.GetBytes(body, "input") + if !input.Exists() || !input.IsArray() { + return body + } + + changed := false + items := make([]json.RawMessage, 0, len(input.Array())) + for _, item := range input.Array() { + var normalized []byte + switch item.Get("type").String() { + case "custom_tool_call": + callID := strings.TrimSpace(item.Get("call_id").String()) + name := strings.TrimSpace(item.Get("name").String()) + if callID == "" || name == "" { + changed = true + continue + } + normalized = []byte(`{"type":"function_call"}`) + normalized, _ = sjson.SetBytes(normalized, "call_id", callID) + normalized, _ = sjson.SetBytes(normalized, "name", name) + normalized, _ = sjson.SetBytes(normalized, "arguments", xaiCustomToolCallArguments(item.Get("input"))) + case "custom_tool_call_output": + callID := strings.TrimSpace(item.Get("call_id").String()) + if callID == "" { + changed = true + continue + } + normalized = []byte(`{"type":"function_call_output"}`) + normalized, _ = sjson.SetBytes(normalized, "call_id", callID) + normalized, _ = sjson.SetBytes(normalized, "output", xaiCustomToolCallOutput(item.Get("output"))) + default: + items = append(items, json.RawMessage(item.Raw)) + continue + } + items = append(items, json.RawMessage(normalized)) + changed = true + } + if !changed { + return body + } + + rawInput, errMarshal := json.Marshal(items) + if errMarshal != nil { + return body + } + updated, errSet := sjson.SetRawBytes(body, "input", rawInput) + if errSet != nil { + return body + } + return updated +} + +func xaiCustomToolCallArguments(input gjson.Result) string { + if !input.Exists() { + return "{}" + } + if input.Type == gjson.String { + text := input.String() + trimmed := strings.TrimSpace(text) + if gjson.Valid(trimmed) { + parsed := gjson.Parse(trimmed) + if parsed.IsObject() { + return parsed.Raw + } + } + encoded, errMarshal := json.Marshal(text) + if errMarshal != nil { + return "{}" + } + return `{"input":` + string(encoded) + `}` + } + if input.IsObject() { + return input.Raw + } + if input.Raw != "" { + return `{"input":` + input.Raw + `}` + } + return "{}" +} + +func xaiCustomToolCallOutput(output gjson.Result) string { + if !output.Exists() { + return "" + } + if output.Type == gjson.String { + return output.String() + } + return output.Raw +} + func normalizeXAIInputNamespaceToolCalls(body []byte) []byte { if !gjson.ValidBytes(body) { return body diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 929e58c9..abd6fc5c 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -249,6 +249,90 @@ func TestXAIExecutorExecuteRestoresAdditionalToolsNamespaceCalls(t *testing.T) { } } +func TestXAIExecutorExecuteNormalizesCustomToolCallHistory(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var errRead error + gotBody, errRead = io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + for _, item := range gjson.GetBytes(gotBody, "input").Array() { + if strings.HasPrefix(item.Get("type").String(), "custom_tool_call") { + http.Error(w, `{"error":"data did not match any variant of untagged enum ModelInput"}`, http.StatusUnprocessableEntity) + return + } + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.5\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}]}}\n\n")) + })) + defer server.Close() + + exec := NewXAIExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "xai", + Attributes: map[string]string{ + "base_url": server.URL, + "auth_kind": "oauth", + }, + Metadata: map[string]any{"access_token": "xai-token"}, + } + payload := []byte(`{ + "model":"grok-4.5", + "input":[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"search"}]}, + {"type":"custom_tool_call","name":"missing_call_id","input":"invalid"}, + {"type":"custom_tool_call_output","output":"missing call id"}, + {"type":"custom_tool_call","status":"completed","call_id":"xs_call-1","name":"x_semantic_search","input":"{\"query\":\"US stocks\",\"limit\":\"10\"}","internal_chat_message_metadata_passthrough":{"turn_id":"turn-1"}}, + {"type":"custom_tool_call_output","call_id":"xs_call-1","output":"unsupported custom tool call: x_semantic_search","internal_chat_message_metadata_passthrough":{"turn_id":"turn-1"}}, + {"type":"custom_tool_call","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch"}, + {"type":"custom_tool_call_output","call_id":"call-2","output":[{"type":"input_text","text":"done"}]} + ], + "tools":[{"type":"x_search"}], + "tool_choice":"auto" + }`) + + _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "grok-4.5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + Stream: false, + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + input := gjson.GetBytes(gotBody, "input").Array() + if len(input) != 5 { + t.Fatalf("input length = %d, want 5; body=%s", len(input), gotBody) + } + if got := input[1].Get("type").String(); got != "function_call" { + t.Fatalf("input.1.type = %q, want function_call; body=%s", got, gotBody) + } + if got := gjson.Get(input[1].Get("arguments").String(), "query").String(); got != "US stocks" { + t.Fatalf("input.1 arguments query = %q, want US stocks; body=%s", got, gotBody) + } + if input[1].Get("input").Exists() || input[1].Get("internal_chat_message_metadata_passthrough").Exists() { + t.Fatalf("input.1 contains unsupported custom fields: %s", input[1].Raw) + } + if got := input[2].Get("type").String(); got != "function_call_output" { + t.Fatalf("input.2.type = %q, want function_call_output; body=%s", got, gotBody) + } + if got := input[2].Get("output").String(); got != "unsupported custom tool call: x_semantic_search" { + t.Fatalf("input.2.output = %q; body=%s", got, gotBody) + } + if got := gjson.Get(input[3].Get("arguments").String(), "input").String(); got != "*** Begin Patch" { + t.Fatalf("input.3 freeform arguments = %q, want patch input; body=%s", got, gotBody) + } + if got := input[4].Get("output").String(); got != `[{"type":"input_text","text":"done"}]` { + t.Fatalf("input.4 output = %q, want flattened JSON string; body=%s", got, gotBody) + } + if got := gjson.GetBytes(gotBody, "tools.0.type").String(); got != "x_search" { + t.Fatalf("tools.0.type = %q, want x_search; body=%s", got, gotBody) + } +} + func TestXAIExecutorComposerSessionIsolation(t *testing.T) { exec := NewXAIExecutor(&config.Config{}) auth := &cliproxyauth.Auth{