From 5af38ccf416a8bcba36174c8fe68e1afa0bb8ab0 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 14:41:38 +0800 Subject: [PATCH 1/7] fix(grok): add guarded client tool cache routing --- .../service/openai_gateway_grok_cache.go | 136 +++++++++++++++--- .../service/openai_gateway_grok_cache_test.go | 111 ++++++++++++++ 2 files changed, 226 insertions(+), 21 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index d9e34cbb58..1ec6159c1a 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -12,10 +12,12 @@ import ( ) const ( - grokConversationIDHeader = "X-Grok-Conv-Id" - grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]` - grokFreeCacheDisabledToolChoice = "none" - grokFreeRolling24hTokenLimit = int64(2_000_000) + grokConversationIDHeader = "X-Grok-Conv-Id" + grokClientToolCacheOptInHeader = "X-Sub2API-Grok-Client-Tool-Cache" + grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]` + grokFreeCacheDisabledToolChoice = "none" + grokClientToolCacheOptInExtraKey = "grok_client_tool_cache_enabled" + grokFreeRolling24hTokenLimit = int64(2_000_000) ) // resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity @@ -122,7 +124,7 @@ func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity str // Inspect the pre-sanitization source. patchGrokResponsesBody may remove an // unsupported client tool and its tool_choice; that must not turn an // explicit client tool intent into an eligible native-tool request. - if gjson.GetBytes(intentSourceBody, "tools").Exists() || gjson.GetBytes(intentSourceBody, "tool_choice").Exists() { + if hasGrokResponsesToolIntent(intentSourceBody) { return out, nil } out, err = sjson.SetRawBytes(out, "tools", []byte(grokFreeCacheNativeToolsJSON)) @@ -132,12 +134,55 @@ func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity str return sjson.SetBytes(out, "tool_choice", grokFreeCacheDisabledToolChoice) } +func hasGrokResponsesToolIntent(body []byte) bool { + if gjson.GetBytes(body, "tools").Exists() || gjson.GetBytes(body, "tool_choice").Exists() { + return true + } + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return false + } + for _, item := range input.Array() { + if strings.TrimSpace(item.Get("type").String()) != "additional_tools" { + continue + } + tools := item.Get("tools") + if !tools.Exists() || !tools.IsArray() || len(tools.Array()) > 0 { + return true + } + } + return false +} + // applyGrokFreeMessagesFunctionToolCacheRoute enables xAI's cache-capable -// mixed-tools route only for the Anthropic Messages bridge and only when the -// selected account is known to be Free. Native tools become eligible under -// auto selection, so callers must not apply this policy to paid accounts or -// other ingress protocols implicitly. +// mixed-tools route only for known Free accounts. Requests that already expose +// search capability can use the route automatically. Pure client-tool requests +// require an account-level opt-in because adding native search changes xAI's +// automatic tool selection behavior (#4486). func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) { + allowPureClientTools := account != nil && account.getExtraBool(grokClientToolCacheOptInExtraKey) + return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, true) +} + +// applyGrokFreeRequestToolCacheRoute also accepts a request-scoped opt-in. The +// sub2api header is consumed locally because buildGrokResponsesRequest only +// forwards the explicitly supported OpenAI-Beta header from downstream. +func applyGrokFreeRequestToolCacheRoute(c *gin.Context, body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) { + allowPureClientTools := account != nil && account.getExtraBool(grokClientToolCacheOptInExtraKey) + if !allowPureClientTools && c != nil { + switch strings.ToLower(strings.TrimSpace(c.GetHeader(grokClientToolCacheOptInHeader))) { + case "1", "true", "yes", "on", "prefer-cache": + allowPureClientTools = true + } + } + // A function merely named web_search/x_search is still a client function. + // Native Responses search types are safe to recognize automatically, while + // converting a client function requires the same explicit opt-in as every + // other pure client-tool request (#4486). + return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, allowPureClientTools) +} + +func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) { if strings.TrimSpace(cacheIdentity) == "" || !isKnownGrokFreeAccount(account) { return body, nil } @@ -146,7 +191,12 @@ func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, if !isGrokFreeCacheFunctionToolIntent(intentTools, intentToolChoice) { return body, nil } - return appendMissingGrokFreeCacheNativeTools(body) + if intentToolChoice.Type == gjson.String && strings.TrimSpace(intentToolChoice.String()) == grokFreeCacheDisabledToolChoice { + // Adding native cache-routing tools cannot change behavior when the + // client has explicitly disabled all tool execution. + return appendGrokFreeCacheNativeToolsWithPolicy(body, true, false) + } + return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch) } func isKnownGrokFreeAccount(account *Account) bool { @@ -231,22 +281,44 @@ func isGrokFreeCacheFunctionToolIntent(tools, toolChoice gjson.Result) bool { return false } for _, tool := range items { - if !tool.IsObject() || strings.TrimSpace(tool.Get("type").String()) != "function" { + if !tool.IsObject() { return false } - // Responses function declarations keep name at the top level. Reject - // Chat Completions' nested function shape and incomplete declarations. - if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() { + toolType := strings.TrimSpace(tool.Get("type").String()) + if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok { return false } + if toolType == "function" { + // Responses function declarations keep name at the top level. Reject + // Chat Completions' nested function shape and incomplete declarations. + if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() { + return false + } + } } if !toolChoice.Exists() { return true } - return toolChoice.Type == gjson.String && strings.TrimSpace(toolChoice.String()) == "auto" + if toolChoice.Type != gjson.String { + return false + } + switch strings.TrimSpace(toolChoice.String()) { + case "auto", grokFreeCacheDisabledToolChoice: + return true + default: + return false + } } func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { + return appendGrokFreeCacheNativeTools(body, false) +} + +func appendGrokFreeCacheNativeTools(body []byte, allowPureClientTools bool) ([]byte, error) { + return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, true) +} + +func appendGrokFreeCacheNativeToolsWithPolicy(body []byte, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) { tools := gjson.GetBytes(body, "tools") if !tools.Exists() || !tools.IsArray() { return body, nil @@ -256,9 +328,19 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { if len(items) == 0 { return body, nil } + hasNativeSearch := false + for _, tool := range items { + switch strings.TrimSpace(tool.Get("type").String()) { + case "web_search", "x_search": + hasNativeSearch = true + } + } + if !allowPureClientTools && !allowFunctionSearch && !hasNativeSearch { + return body, nil + } merged := make([]json.RawMessage, 0, len(items)+2) present := make(map[string]bool, 2) - hasFunction := false + hasCompanionTool := false for _, tool := range items { toolType := strings.TrimSpace(tool.Get("type").String()) switch toolType { @@ -269,7 +351,7 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { } // Grok Build may declare search as function tools. Convert to native // entries so Free OAuth stays cache-capable without duplicate names. - if name == "web_search" || name == "x_search" { + if (name == "web_search" || name == "x_search") && allowFunctionSearch { if present[name] { continue } @@ -279,9 +361,17 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { } merged = append(merged, raw) present[name] = true + if allowPureClientTools { + hasCompanionTool = true + } continue } - hasFunction = true + if name == "web_search" || name == "x_search" { + // Keep the client function intact and avoid adding a same-named + // native tool unless conversion was explicitly enabled. + present[name] = true + } + hasCompanionTool = true merged = append(merged, json.RawMessage(tool.Raw)) case "web_search", "x_search": if present[toolType] { @@ -290,17 +380,21 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { merged = append(merged, json.RawMessage(tool.Raw)) present[toolType] = true default: - return body, nil + if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok { + return body, nil + } + hasCompanionTool = true + merged = append(merged, json.RawMessage(tool.Raw)) } } - if !hasFunction { + if !hasCompanionTool { return body, nil } // Only complement missing native search tools when the request already contains // at least one search tool (native or function-form). Pure client function tools // (e.g. view_image) must not trigger injection to avoid biasing model tool // selection (#4486). - if !present["web_search"] && !present["x_search"] { + if !allowPureClientTools && !present["web_search"] && !present["x_search"] { return body, nil } for _, toolType := range []string{"web_search", "x_search"} { diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go index 73b1ea5ec1..a6f7d00881 100644 --- a/backend/internal/service/openai_gateway_grok_cache_test.go +++ b/backend/internal/service/openai_gateway_grok_cache_test.go @@ -249,6 +249,117 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsWhenSearchPresent(t *testing.T) require.Equal(t, "x_search", tools[2].Get("type").String()) } +func TestGrokFreeClientToolCacheAccountOptIn(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(9011, "access-token") + account.Credentials["subscription_tier"] = "free" + account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true} + intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"read_file","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true) + require.NoError(t, err) + body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id") + require.NoError(t, err) + + tools := gjson.GetBytes(body, "tools").Array() + require.Len(t, tools, 4) + require.Equal(t, "view_image", tools[0].Get("name").String()) + require.Equal(t, "read_file", tools[1].Get("name").String()) + require.Equal(t, "web_search", tools[2].Get("type").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) +} + +func TestGrokFreeClientToolCacheRequestOptIn(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(9014, "access-token") + account.Credentials["subscription_tier"] = "free" + c := newGrokCacheTestContext(9014) + c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache") + intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true) + require.NoError(t, err) + body, err = applyGrokFreeRequestToolCacheRoute(c, body, intentBody, account, "isolated-id") + require.NoError(t, err) + + tools := gjson.GetBytes(body, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "view_image", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) +} + +func TestGrokFreeRequestClientSearchFunctionRequiresOptIn(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(9015, "access-token") + account.Credentials["subscription_tier"] = "free" + c := newGrokCacheTestContext(9015) + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"web_search","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + require.JSONEq(t, string(body), string(patched)) +} + +func TestGrokFreeRequestToolChoiceNoneUsesSafeCacheRoute(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(9016, "access-token") + account.Credentials["subscription_tier"] = "free" + c := newGrokCacheTestContext(9016) + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"none"}`) + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "view_image", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) + require.Equal(t, "none", gjson.GetBytes(patched, "tool_choice").String()) +} + +func TestApplyGrokCacheIdentityRecognizesResponsesLiteAdditionalTools(t *testing.T) { + intentBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"hello"}]}`) + patchedBody := []byte(`{"model":"grok-4.5","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"type":"message","role":"user","content":"hello"}]}`) + + patched, err := applyGrokResponsesCacheIdentity(patchedBody, intentBody, "isolated-id", true) + + require.NoError(t, err) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 1) + require.Equal(t, "lookup", tools[0].Get("name").String()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String()) +} + +func TestGrokFreeCacheRoutePreservesMixedSupportedToolsWithSearchIntent(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(9012, "access-token") + account.Credentials["subscription_tier"] = "free" + intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"shell"},{"type":"web_search"}],"tool_choice":"auto"}`) + + body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true) + require.NoError(t, err) + body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id") + require.NoError(t, err) + + tools := gjson.GetBytes(body, "tools").Array() + require.Len(t, tools, 4) + require.Equal(t, "function", tools[0].Get("type").String()) + require.Equal(t, "shell", tools[1].Get("type").String()) + require.Equal(t, "web_search", tools[2].Get("type").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) +} + +func TestGrokClientToolCacheOptInDoesNotOverridePaidTier(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(9013, "access-token") + account.Credentials["subscription_tier"] = "supergrok" + account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true} + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id") + + require.NoError(t, err) + require.JSONEq(t, string(body), string(patched)) +} + func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(902, "access-token") account.Credentials["subscription_tier"] = "free" From 026d2a80839286e4992d882d49140c780dcf7a03 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 14:41:47 +0800 Subject: [PATCH 2/7] fix(grok): promote Codex Lite tools for caching --- .../internal/service/openai_gateway_grok.go | 67 ++++++++- .../service/openai_gateway_grok_test.go | 136 +++++++++++++++++- .../internal/service/openai_ws_http_bridge.go | 7 +- .../service/openai_ws_http_bridge_test.go | 72 ++++++++++ 4 files changed, 271 insertions(+), 11 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 198055f20e..dec30c3bd2 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -52,18 +52,21 @@ func (s *OpenAIGatewayService) forwardGrokResponses( if isGrokImageGenerationModel(upstreamModel) { return nil, fmt.Errorf("model %s is an image model and is not available on the Responses endpoint; use /v1/images/generations instead", upstreamModel) } - cacheIdentity := resolveGrokCacheIdentity(c, body, "", upstreamModel) patchedBody, err := patchGrokResponsesBody(body, upstreamModel) if err != nil { return nil, err } + // Derive the identity from the request xAI will actually see. This makes + // Codex Responses Lite additional_tools part of the stable tool prefix. + cacheIdentity := resolveGrokCacheIdentity(c, patchedBody, "", upstreamModel) + mixedCacheIntentBody := append([]byte(nil), patchedBody...) patchedBody, err = applyGrokResponsesCacheIdentity(patchedBody, body, cacheIdentity, account.IsGrokOAuth()) if err != nil { return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } // Free OAuth + client function tools: reuse Messages mixed-tools cache route // (append web_search/x_search so xAI does not force non-cacheable build-free). - patchedBody, err = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, account, cacheIdentity) + patchedBody, err = applyGrokFreeRequestToolCacheRoute(c, patchedBody, mixedCacheIntentBody, account, cacheIdentity) if err != nil { return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) } @@ -374,10 +377,9 @@ func deleteJSONFields(value any, fields map[string]struct{}) bool { } // additional_tools is a Codex/Responses Lite private input carrier. xAI's -// Responses schema accepts ordinary message/function-call input items but -// rejects this carrier before inference with a ModelInput deserialization -// error. Top-level supported tools remain available through the separate -// sanitizeGrokResponsesTools path. +// Responses schema rejects the carrier itself, but accepts supported tools at +// the top level. Preserve top-level order, append newly discovered tools in +// carrier order, then let sanitizeGrokResponsesTools filter unsupported types. func sanitizeGrokResponsesInput(body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"additional_tools"`)) { return body, nil @@ -389,8 +391,36 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) { rawItems := input.Array() filtered := make([]json.RawMessage, 0, len(rawItems)) + topLevelTools := gjson.GetBytes(body, "tools") + mergedTools := make([]json.RawMessage, 0) + seenTools := make(map[string]struct{}) + appendTool := func(tool gjson.Result) bool { + key := grokResponsesToolDedupKey(tool) + if _, exists := seenTools[key]; exists { + return false + } + seenTools[key] = struct{}{} + mergedTools = append(mergedTools, json.RawMessage(tool.Raw)) + return true + } + if topLevelTools.IsArray() { + for _, tool := range topLevelTools.Array() { + seenTools[grokResponsesToolDedupKey(tool)] = struct{}{} + mergedTools = append(mergedTools, json.RawMessage(tool.Raw)) + } + } + + promoted := false for _, item := range rawItems { if strings.TrimSpace(item.Get("type").String()) == "additional_tools" { + tools := item.Get("tools") + if tools.IsArray() { + for _, tool := range tools.Array() { + if appendTool(tool) { + promoted = true + } + } + } continue } filtered = append(filtered, json.RawMessage(item.Raw)) @@ -402,7 +432,30 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) { if err != nil { return nil, err } - return sjson.SetRawBytes(body, "input", encoded) + body, err = sjson.SetRawBytes(body, "input", encoded) + if err != nil || !promoted { + return body, err + } + encodedTools, err := json.Marshal(mergedTools) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(body, "tools", encodedTools) +} + +func grokResponsesToolDedupKey(tool gjson.Result) string { + toolType := strings.TrimSpace(tool.Get("type").String()) + if toolType != "" { + if name := strings.TrimSpace(tool.Get("name").String()); name != "" { + return "type:" + toolType + "\x00name:" + name + } + if toolType == "mcp" { + if label := strings.TrimSpace(tool.Get("server_label").String()); label != "" { + return "type:mcp\x00server_label:" + label + } + } + } + return "json:" + normalizeCompatSeedJSON(json.RawMessage(tool.Raw)) } // sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。 diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index fd7eb09c3d..42d88875db 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -206,18 +206,27 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) } -func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T) { +func TestPatchGrokResponsesBodyPromotesCodexAdditionalTools(t *testing.T) { t.Parallel() body := []byte(`{ "model": "grok", + "tools": [ + {"type": "function", "name": "existing", "description": "top-level wins"}, + {"type": "web_search"} + ], + "tool_choice": "auto", "input": [ { "type": "additional_tools", "role": "developer", "tools": [ - {"type": "namespace", "name": "image_gen"}, - {"type": "function", "name": "wait"} + {"type": "function", "name": "existing", "description": "duplicate carrier definition"}, + {"type": "function", "name": "wait"}, + {"type": "web_search"}, + {"type": "shell"}, + {"type": "custom", "name": "apply_patch"}, + {"type": "namespace", "name": "collaboration"} ] }, { @@ -239,12 +248,133 @@ func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T) require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String()) require.Equal(t, 2, len(gjson.GetBytes(patched, "input").Array())) require.False(t, gjson.GetBytes(patched, `input.#(type=="additional_tools")`).Exists()) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 4) + require.Equal(t, "existing", tools[0].Get("name").String()) + require.Equal(t, "top-level wins", tools[0].Get("description").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "wait", tools[2].Get("name").String()) + require.Equal(t, "shell", tools[3].Get("type").String()) + require.False(t, gjson.GetBytes(patched, `tools.#(type=="custom")`).Exists()) + require.False(t, gjson.GetBytes(patched, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "auto", gjson.GetBytes(patched, "tool_choice").String()) require.Equal(t, "developer", gjson.GetBytes(patched, "input.0.role").String()) require.Equal(t, "system prompt", gjson.GetBytes(patched, "input.0.content.0.text").String()) require.Equal(t, "user", gjson.GetBytes(patched, "input.1.role").String()) require.Equal(t, "hello", gjson.GetBytes(patched, "input.1.content.0.text").String()) } +func TestForwardGrokResponsesCodexAdditionalToolsUsesMixedCacheIntent(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{ + "model":"grok", + "stream":false, + "prompt_cache_key":"codex-session", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"function","name":"lookup","description":"look up a key","parameters":{"type":"object"}}, + {"type":"function","name":"web_search","description":"search","parameters":{"type":"object"}}, + {"type":"custom","name":"apply_patch"}, + {"type":"namespace","name":"collaboration"} + ]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache") + c.Set("api_key", &APIKey{ID: 4501}) + + account := healthyGrokOAuthGatewayTestAccount(4501, "access-token") + account.Credentials["subscription_tier"] = "free" + repo := &grokQuotaAccountRepo{ + mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_codex_lite","object":"response","model":"grok-4.5","status":"completed", + "output":[],"usage":{"input_tokens":10,"output_tokens":1} + }`)), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now()) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "resp_codex_lite", result.ResponseID) + require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists()) + tools := gjson.GetBytes(upstream.lastBody, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "function", tools[0].Get("type").String()) + require.Equal(t, "lookup", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists()) + identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String() + require.NotEmpty(t, identity) + require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader)) + require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader)) +} + +func TestGrokResponsesCacheIdentityIncludesPromotedCodexTools(t *testing.T) { + c := newGrokCacheTestContext(4503) + lookupBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"same prompt"}]}`) + readBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"read_file","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"same prompt"}]}`) + + patchedLookup, err := patchGrokResponsesBody(lookupBody, "grok-4.5") + require.NoError(t, err) + patchedRead, err := patchGrokResponsesBody(readBody, "grok-4.5") + require.NoError(t, err) + + lookupIdentity := resolveGrokCacheIdentity(c, patchedLookup, "", "grok-4.5") + readIdentity := resolveGrokCacheIdentity(c, patchedRead, "", "grok-4.5") + require.NotEmpty(t, lookupIdentity) + require.NotEmpty(t, readIdentity) + require.NotEqual(t, lookupIdentity, readIdentity) +} + +func TestCodexUnsupportedAdditionalToolsDoNotBecomeToolFreeCacheIntent(t *testing.T) { + body := []byte(`{ + "model":"grok","tool_choice":"auto", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"custom","name":"apply_patch"}, + {"type":"namespace","name":"collaboration"} + ]}, + {"type":"message","role":"user","content":"hello"} + ] + }`) + patched, err := patchGrokResponsesBody(body, "grok-4.5") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tools").Exists()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + + mixedCacheIntent := patched + patched, err = applyGrokResponsesCacheIdentity(patched, body, "isolated-id", true) + require.NoError(t, err) + account := healthyGrokOAuthGatewayTestAccount(4502, "access-token") + account.Credentials["subscription_tier"] = "free" + patched, err = applyGrokFreeRequestToolCacheRoute(nil, patched, mixedCacheIntent, account, "isolated-id") + + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tools").Exists()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String()) +} + func TestBuildGrokResponsesRequestUsesAccountBaseURLAndBearerToken(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index cc7b307982..dd9875d0ad 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -196,12 +196,13 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( releaseUpstreamCtx() return nil, err } + grokMixedCacheIntentBody := append([]byte(nil), body...) body, err = applyGrokResponsesCacheIdentity(body, grokIntentSourceBody, grokCacheIdentity, account.IsGrokOAuth()) if err != nil { releaseUpstreamCtx() return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } - body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity) + body, err = applyGrokFreeRequestToolCacheRoute(c, body, grokMixedCacheIntentBody, account, grokCacheIdentity) if err != nil { releaseUpstreamCtx() return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) @@ -446,6 +447,10 @@ func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte return "", err } upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) + body, err = patchGrokResponsesBody(body, upstreamModel) + if err != nil { + return "", err + } return resolveGrokCacheIdentity(c, body, "", upstreamModel), nil } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 808a63c6b1..2f77690b22 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -235,6 +235,78 @@ func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T) require.Len(t, events, 2) } +func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_grok_codex_lite","model":"grok-4.5"}}`, + "", + `data: {"type":"response.completed","response":{"id":"resp_grok_codex_lite","model":"grok-4.5","usage":{"input_tokens":4,"output_tokens":1}}}`, + "", + }, "\n"))), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 73, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "base_url": xai.DefaultCLIBaseURL, + "subscription_tier": "free", + }, + } + payload := []byte(`{ + "type":"response.create","generate":true,"model":"grok","stream":true, + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"function","name":"lookup","parameters":{"type":"object"}}, + {"type":"function","name":"web_search","parameters":{"type":"object"}}, + {"type":"custom","name":"apply_patch"}, + {"type":"namespace","name":"collaboration"} + ]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache") + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", payload, len(payload), + "grok", "", "", "", "isolated-ws-cache-id", 1, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, events, 2) + require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists()) + tools := gjson.GetBytes(upstream.lastBody, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "function", tools[0].Get("type").String()) + require.Equal(t, "lookup", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "isolated-ws-cache-id", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, "isolated-ws-cache-id", upstream.lastReq.Header.Get(grokConversationIDHeader)) + require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader)) +} + func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) { gin.SetMode(gin.TestMode) From 752b2eaa9b255ba9b5fe3cab689ec66cf07c1527 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 14:41:55 +0800 Subject: [PATCH 3/7] fix(grok): bridge Trae tool requests through responses --- .../openai_gateway_grok_chat_bridge.go | 315 +++++++++++++++--- .../openai_gateway_grok_chat_bridge_test.go | 117 ++++++- 2 files changed, 381 insertions(+), 51 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 0b8f0e2d83..baf1d8a592 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -33,6 +33,7 @@ var grokChatResponsesBridgeTopLevelFields = map[string]struct{}{ "tool_choice": {}, "functions": {}, "function_call": {}, + "parallel_tool_calls": {}, } // grokChatResponsesBridgeEligibility deliberately accepts only request shapes @@ -50,13 +51,22 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) { return false, "unsupported_" + field } } - for _, field := range []string{"tools", "functions"} { - if raw, exists := root[field]; exists && !grokChatNullOrEmptyArray(raw) { - return false, "unsupported_" + field + if raw, exists := root["tools"]; exists { + if ok, reason := grokChatFunctionDeclarationsBridgeable(raw); !ok { + return false, reason } } - if raw, exists := root["tool_choice"]; exists && !grokChatNullOrNone(raw) { - return false, "unsupported_tool_choice" + if raw, exists := root["functions"]; exists && !grokChatNullOrEmptyArray(raw) { + return false, "unsupported_functions" + } + if raw, exists := root["tool_choice"]; exists { + if ok, reason := grokChatToolChoiceBridgeable(raw); !ok { + return false, reason + } + var choice string + if json.Unmarshal(raw, &choice) == nil && choice == "required" && !grokChatHasFunctionDeclarations(root) { + return false, "required_tool_choice_without_tools" + } } if raw, exists := root["function_call"]; exists && !grokChatNullOrNone(raw) { return false, "unsupported_function_call" @@ -78,6 +88,12 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) { return false, "invalid_stream" } } + if raw, ok := root["parallel_tool_calls"]; ok { + var parallelToolCalls *bool + if json.Unmarshal(raw, ¶llelToolCalls) != nil || parallelToolCalls == nil { + return false, "invalid_parallel_tool_calls" + } + } if raw, ok := root["stream_options"]; ok { var options map[string]json.RawMessage if json.Unmarshal(raw, &options) != nil || options == nil { @@ -128,42 +144,238 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) { return false, "invalid_messages" } for _, message := range messages { - for field := range message { - if field != "role" && field != "content" { - return false, "unsafe_message_field_" + field - } - } var role string if raw, exists := message["role"]; !exists || json.Unmarshal(raw, &role) != nil { return false, "invalid_message_role" } switch role { - case "system", "user", "assistant": + case "system", "user": + if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content"); !ok { + return false, reason + } + raw, exists := message["content"] + if !exists { + return false, "non_text_message_content" + } + if ok, reason := grokChatRequiredMessageContentBridgeable(raw); !ok { + return false, reason + } + case "assistant": + if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_calls"); !ok { + return false, reason + } + toolCallCount := 0 + if raw, exists := message["tool_calls"]; exists { + var reason string + toolCallCount, reason = grokChatAssistantToolCallsBridgeable(raw) + if reason != "" { + return false, reason + } + } + raw, hasContent := message["content"] + if !hasContent || strings.TrimSpace(string(raw)) == "null" { + if toolCallCount == 0 { + return false, "non_text_message_content" + } + continue + } + var content string + if json.Unmarshal(raw, &content) == nil { + if strings.TrimSpace(content) == "" && toolCallCount == 0 { + return false, "empty_message_content" + } + continue + } + if ok, reason := grokChatStructuredContentBridgeable(raw); !ok { + return false, reason + } + case "tool": + if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_call_id"); !ok { + return false, reason + } + var callID string + if raw, exists := message["tool_call_id"]; !exists || json.Unmarshal(raw, &callID) != nil || strings.TrimSpace(callID) == "" { + return false, "invalid_tool_call_id" + } + var output string + if raw, exists := message["content"]; !exists || json.Unmarshal(raw, &output) != nil || output == "" { + return false, "invalid_tool_message_content" + } default: return false, "unsupported_message_role_" + role } - raw, exists := message["content"] - if !exists { - return false, "non_text_message_content" - } - var content string - if json.Unmarshal(raw, &content) == nil { - if strings.TrimSpace(content) == "" { - return false, "empty_message_content" - } - continue - } - // Structured content: only allow arrays whose parts are text or - // image_url. These are losslessly convertible to Responses input_text/ - // input_image parts, so the bridge preserves Chat Completions semantics. - if ok, reason := grokChatStructuredContentBridgeable(raw); !ok { - return false, reason - } } return true, "" } +func grokChatFunctionDeclarationsBridgeable(raw json.RawMessage) (bool, string) { + if strings.TrimSpace(string(raw)) == "null" { + return true, "" + } + var declarations []json.RawMessage + if json.Unmarshal(raw, &declarations) != nil { + return false, "invalid_tools" + } + for _, declaration := range declarations { + var tool map[string]json.RawMessage + if json.Unmarshal(declaration, &tool) != nil || tool == nil { + return false, "invalid_tool" + } + for field := range tool { + if field != "type" && field != "function" { + return false, "unsafe_tool_field_" + field + } + } + var toolType string + if rawType, exists := tool["type"]; !exists || json.Unmarshal(rawType, &toolType) != nil || toolType != "function" { + return false, "unsupported_tool_type" + } + functionRaw, exists := tool["function"] + if !exists { + return false, "invalid_tool_function" + } + + var function map[string]json.RawMessage + if json.Unmarshal(functionRaw, &function) != nil || function == nil { + return false, "invalid_tool_function" + } + for field := range function { + switch field { + case "name", "description", "parameters", "strict": + default: + return false, "unsafe_tool_function_field_" + field + } + } + var name string + if rawName, exists := function["name"]; !exists || json.Unmarshal(rawName, &name) != nil || strings.TrimSpace(name) == "" { + return false, "invalid_tool_function_name" + } + if rawDescription, exists := function["description"]; exists { + var description string + if json.Unmarshal(rawDescription, &description) != nil { + return false, "invalid_tool_function_description" + } + } + var parameters map[string]json.RawMessage + if rawParameters, exists := function["parameters"]; !exists || json.Unmarshal(rawParameters, ¶meters) != nil || parameters == nil { + return false, "invalid_tool_function_parameters" + } + if rawStrict, exists := function["strict"]; exists { + var strict bool + if json.Unmarshal(rawStrict, &strict) != nil { + return false, "invalid_tool_function_strict" + } + } + } + return true, "" +} + +func grokChatToolChoiceBridgeable(raw json.RawMessage) (bool, string) { + if strings.TrimSpace(string(raw)) == "null" { + return true, "" + } + var choice string + if json.Unmarshal(raw, &choice) != nil { + return false, "unsupported_tool_choice" + } + switch choice { + case "auto", "none", "required": + return true, "" + default: + return false, "unsupported_tool_choice" + } +} + +func grokChatHasFunctionDeclarations(root map[string]json.RawMessage) bool { + for _, field := range []string{"tools", "functions"} { + raw, exists := root[field] + if !exists { + continue + } + var declarations []json.RawMessage + if json.Unmarshal(raw, &declarations) == nil && len(declarations) > 0 { + return true + } + } + return false +} + +func grokChatMessageFieldsBridgeable(message map[string]json.RawMessage, allowedFields ...string) (bool, string) { + allowed := make(map[string]struct{}, len(allowedFields)) + for _, field := range allowedFields { + allowed[field] = struct{}{} + } + for field := range message { + if _, ok := allowed[field]; !ok { + return false, "unsafe_message_field_" + field + } + } + return true, "" +} + +func grokChatRequiredMessageContentBridgeable(raw json.RawMessage) (bool, string) { + var content string + if json.Unmarshal(raw, &content) == nil { + if strings.TrimSpace(content) == "" { + return false, "empty_message_content" + } + return true, "" + } + // Structured content: only allow arrays whose parts are text or + // image_url. These are losslessly convertible to Responses input_text/ + // input_image parts, so the bridge preserves Chat Completions semantics. + return grokChatStructuredContentBridgeable(raw) +} + +func grokChatAssistantToolCallsBridgeable(raw json.RawMessage) (int, string) { + if strings.TrimSpace(string(raw)) == "null" { + return 0, "" + } + var calls []map[string]json.RawMessage + if json.Unmarshal(raw, &calls) != nil { + return 0, "invalid_tool_calls" + } + for _, call := range calls { + if call == nil { + return 0, "invalid_tool_call" + } + for field := range call { + switch field { + case "id", "type", "function": + default: + return 0, "unsafe_tool_call_field_" + field + } + } + var callID string + if rawID, exists := call["id"]; !exists || json.Unmarshal(rawID, &callID) != nil || strings.TrimSpace(callID) == "" { + return 0, "invalid_tool_call_id" + } + var callType string + if rawType, exists := call["type"]; !exists || json.Unmarshal(rawType, &callType) != nil || callType != "function" { + return 0, "unsupported_tool_call_type" + } + var function map[string]json.RawMessage + if rawFunction, exists := call["function"]; !exists || json.Unmarshal(rawFunction, &function) != nil || function == nil { + return 0, "invalid_tool_call_function" + } + for field := range function { + if field != "name" && field != "arguments" { + return 0, "unsafe_tool_call_function_field_" + field + } + } + var name string + if rawName, exists := function["name"]; !exists || json.Unmarshal(rawName, &name) != nil || strings.TrimSpace(name) == "" { + return 0, "invalid_tool_call_function_name" + } + var arguments string + if rawArguments, exists := function["arguments"]; !exists || json.Unmarshal(rawArguments, &arguments) != nil || !json.Valid([]byte(arguments)) { + return 0, "invalid_tool_call_arguments" + } + } + return len(calls), "" +} + func grokChatStructuredContentBridgeable(raw json.RawMessage) (bool, string) { var parts []map[string]json.RawMessage if err := json.Unmarshal(raw, &parts); err != nil { @@ -199,14 +411,6 @@ func grokChatStructuredContentBridgeable(raw json.RawMessage) (bool, string) { return true, "" } -func grokChatNullOrEmptyArray(raw json.RawMessage) bool { - if strings.TrimSpace(string(raw)) == "null" { - return true - } - var values []json.RawMessage - return json.Unmarshal(raw, &values) == nil && len(values) == 0 -} - func grokChatNullOrNone(raw json.RawMessage) bool { if strings.TrimSpace(string(raw)) == "null" { return true @@ -215,14 +419,30 @@ func grokChatNullOrNone(raw json.RawMessage) bool { return json.Unmarshal(raw, &value) == nil && strings.EqualFold(strings.TrimSpace(value), "none") } -func grokChatCacheIntentBody(body []byte) ([]byte, error) { +func grokChatNullOrEmptyArray(raw json.RawMessage) bool { + if strings.TrimSpace(string(raw)) == "null" { + return true + } + var values []json.RawMessage + return json.Unmarshal(raw, &values) == nil && len(values) == 0 +} + +func grokChatResponsesCacheIntentBody(body []byte) ([]byte, error) { + // An empty Chat tools array is omitted by the Responses converter. In that + // case auto/none is also a semantic no-op and must not suppress the normal + // tool-free cache route. Non-empty converted tools are always kept intact. + if gjson.GetBytes(body, "tools").Exists() { + return append([]byte(nil), body...), nil + } + choice := gjson.GetBytes(body, "tool_choice") + if !choice.Exists() || choice.Type != gjson.String || (choice.String() != "auto" && choice.String() != "none") { + return append([]byte(nil), body...), nil + } var root map[string]json.RawMessage if err := json.Unmarshal(body, &root); err != nil { return nil, err } - for _, field := range []string{"tools", "tool_choice", "functions", "function_call"} { - delete(root, field) - } + delete(root, "tool_choice") return json.Marshal(root) } @@ -277,18 +497,25 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( if err != nil { return nil, fmt.Errorf("marshal grok responses bridge request: %w", err) } + // Preserve the converted Responses intent before Grok capability + // sanitization. Cache routing must see the actual client function tools, + // not the nested Chat Completions declarations and not a tool-free copy. + intentBody, err := grokChatResponsesCacheIntentBody(responsesBody) + if err != nil { + return nil, fmt.Errorf("normalize grok responses bridge cache intent: %w", err) + } responsesBody, err = patchGrokResponsesBody(responsesBody, upstreamModel) if err != nil { return nil, fmt.Errorf("patch grok responses bridge request: %w", err) } - intentBody, err := grokChatCacheIntentBody(body) - if err != nil { - return nil, fmt.Errorf("normalize grok responses bridge tool intent: %w", err) - } responsesBody, err = applyGrokResponsesCacheIdentity(responsesBody, intentBody, cacheIdentity, true) if err != nil { return nil, fmt.Errorf("apply grok responses bridge cache identity: %w", err) } + responsesBody, err = applyGrokFreeRequestToolCacheRoute(c, responsesBody, intentBody, account, cacheIdentity) + if err != nil { + return nil, fmt.Errorf("apply grok responses bridge function-tool cache route: %w", err) + } updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, responsesBody) if policyErr != nil { diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 86fc45e952..6e39bae15d 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -75,15 +75,60 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) { reason: "empty_message_content", }, { - name: "function tools fall back", - body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`, - reason: "unsupported_tools", + name: "function tools bridge", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"},"strict":false}}]}`, + want: true, }, { - name: "automatic tool choice falls back", - body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`, + name: "legacy functions fall back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"functions":[{"name":"lookup","parameters":{"type":"object"}}]}`, + reason: "unsupported_functions", + }, + { + name: "automatic tool choice bridges", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`, + want: true, + }, + { + name: "required tool choice without tools falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"required"}`, + reason: "required_tool_choice_without_tools", + }, + { + name: "tool history bridges", + body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":true}`, + want: true, + }, + { + name: "unknown tool type falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{"name":"lookup","parameters":{"type":"object"}}}]}`, + reason: "unsupported_tool_type", + }, + { + name: "missing tool schema falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`, + reason: "invalid_tool_function_parameters", + }, + { + name: "named tool choice falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}}}`, reason: "unsupported_tool_choice", }, + { + name: "invalid tool call arguments fall back", + body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{"}}]}]}`, + reason: "invalid_tool_call_arguments", + }, + { + name: "tool result without call id falls back", + body: `{"model":"grok","messages":[{"role":"tool","content":"ok"}]}`, + reason: "invalid_tool_call_id", + }, + { + name: "non boolean parallel tool calls falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"parallel_tool_calls":"true"}`, + reason: "invalid_parallel_tool_calls", + }, { name: "reasoning effort falls back because conversion adds summary", body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`, @@ -100,9 +145,9 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) { reason: "empty_message_content", }, { - name: "tool history falls back", + name: "empty tool history falls back", body: `{"model":"grok","messages":[{"role":"assistant","content":"","tool_calls":[]}]}`, - reason: "unsafe_message_field_tool_calls", + reason: "empty_message_content", }, { name: "unknown field falls back", @@ -183,6 +228,64 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing. require.NotNil(t, repo.updates[account.ID][grokQuotaSnapshotExtraKey]) } +func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`) + body := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body)) + c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache") + c.Set("api_key", &APIKey{ID: 7151}) + + account := grokChatBridgeTestAccount(715) + account.Credentials["subscription_tier"] = "free" + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae", 8192)} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + require.NotEmpty(t, firstTurnIdentity) + require.Equal(t, firstTurnIdentity, extendedTurnIdentity) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) + require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint) + require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader)) + + tools := gjson.GetBytes(upstream.lastBody, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "function", tools[0].Get("type").String()) + require.Equal(t, "lookup", tools[0].Get("name").String()) + require.Equal(t, "Lookup a value", tools[0].Get("description").String()) + require.Equal(t, "string", tools[0].Get("parameters.properties.key.type").String()) + require.True(t, tools[0].Get("strict").Exists()) + require.False(t, tools[0].Get("strict").Bool()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) + require.Equal(t, "auto", gjson.GetBytes(upstream.lastBody, "tool_choice").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + + require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String()) + require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.2.call_id").String()) + require.Equal(t, "lookup", gjson.GetBytes(upstream.lastBody, "input.2.name").String()) + require.Equal(t, `{"key":"alpha"}`, gjson.GetBytes(upstream.lastBody, "input.2.arguments").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String()) + require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.3.call_id").String()) + require.Equal(t, `{"value":"ok"}`, gjson.GetBytes(upstream.lastBody, "input.3.output").String()) +} + func TestForwardGrokChatViaResponsesStreamingPropagatesCachedUsage(t *testing.T) { gin.SetMode(gin.TestMode) From 9d79b42729ba1ff8e5070c59154ead3cb03b3b14 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 14:42:06 +0800 Subject: [PATCH 4/7] feat(admin): expose Grok client tool cache opt-in --- .../components/account/EditAccountModal.vue | 36 +++++++++++ .../EditAccountModal.grokUpstream.spec.ts | 64 ++++++++++++++++++- .../src/i18n/locales/en/admin/accounts.ts | 4 ++ .../src/i18n/locales/zh/admin/accounts.ts | 4 ++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index c61ebf6050..12afc93a11 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -428,6 +428,26 @@ + +
+
+
+ +

+ {{ t('admin.accounts.grokClientToolCache.hint') }} +

+
+ +
+
+
([]) const DEFAULT_POOL_MODE_RETRY_COUNT = 3 const MAX_POOL_MODE_RETRY_COUNT = 10 const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429] +const GROK_CLIENT_TOOL_CACHE_EXTRA_KEY = 'grok_client_tool_cache_enabled' const poolModeEnabled = ref(false) const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT) const poolModeRetryStatusCodesInput = ref('') @@ -2747,6 +2768,7 @@ const headerOverrideCapable = computed( // Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响) const grokOAuthCustomBaseUrlEnabled = ref(false) const grokOAuthBaseUrl = ref('') +const grokClientToolCacheEnabled = ref(false) const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(false) @@ -3402,6 +3424,10 @@ const syncFormFromAccount = (newAccount: Account | null) => { // Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制) grokOAuthCustomBaseUrlEnabled.value = false grokOAuthBaseUrl.value = '' + grokClientToolCacheEnabled.value = + newAccount.platform === 'grok' && + newAccount.type === 'oauth' && + newAccount.extra?.[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] === true if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) { const grokCreds = newAccount.credentials as Record if (isCustomGrokBaseUrl(grokCreds.base_url)) { @@ -4288,6 +4314,16 @@ const handleSubmit = async () => { applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit') updatePayload.credentials = newCredentials + + const newExtra: Record = { + ...((props.account.extra as Record) || {}) + } + if (grokClientToolCacheEnabled.value) { + newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] = true + } else { + delete newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] + } + updatePayload.extra = newExtra } // OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号: diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts index 310a370889..068d7ac1a4 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts @@ -67,7 +67,10 @@ const BaseDialogStub = defineComponent({ template: '
' }) -function buildGrokOAuthAccount(credentials: Record = {}) { +function buildGrokOAuthAccount( + credentials: Record = {}, + extra: Record = {} +) { return { id: 5, name: 'Grok OAuth', @@ -80,7 +83,7 @@ function buildGrokOAuthAccount(credentials: Record = {}) { ...credentials }, credentials_status: { has_access_token: true, has_refresh_token: true }, - extra: {}, + extra, proxy_id: null, concurrency: 1, priority: 1, @@ -225,4 +228,61 @@ describe('EditAccountModal Grok OAuth upstream config', () => { 'x-xai-token-auth': 'xai-grok-cli' }) }) + + it('shows the client-tool cache switch only for Grok OAuth accounts', () => { + const grokOAuthWrapper = mountModal(buildGrokOAuthAccount()) + expect(grokOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(true) + + const grokAPIKeyWrapper = mountModal({ + ...buildGrokOAuthAccount(), + type: 'apikey', + credentials: { api_key: 'xai-test', base_url: 'https://api.x.ai/v1' } + }) + expect(grokAPIKeyWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false) + + const openAIOAuthWrapper = mountModal({ + ...buildGrokOAuthAccount(), + platform: 'openai' + }) + expect(openAIOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false) + }) + + it('loads and disables client-tool caching while preserving unrelated extra fields', async () => { + const account = buildGrokOAuthAccount({}, { + grok_client_tool_cache_enabled: true, + custom_setting: 'keep-me' + }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('true') + + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.extra?.custom_setting).toBe('keep-me') + expect(payload?.extra).not.toHaveProperty('grok_client_tool_cache_enabled') + }) + + it('enables client-tool caching while preserving unrelated extra fields', async () => { + const account = buildGrokOAuthAccount({}, { custom_setting: 'keep-me' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('false') + + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.extra).toMatchObject({ + grok_client_tool_cache_enabled: true, + custom_setting: 'keep-me' + }) + }) }) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 206c7c5172..772161f6c8 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -641,6 +641,10 @@ export default { official: 'Official API' } }, + grokClientToolCache: { + title: 'Client Tool Cache (May Change Automatic Tool Selection)', + hint: 'For detected Grok Free OAuth accounts, enable upstream prompt caching for client function tools such as Codex and Trae. Enable only after confirming the automatic tool-selection behavior is acceptable.' + }, autoPauseOnExpired: 'Auto Pause On Expired', autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', autoPause5hThreshold: '5h Usage Threshold (%)', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 48da50175d..9cfa1ee17c 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -734,6 +734,10 @@ export default { official: '官方 API' } }, + grokClientToolCache: { + title: '客户端工具缓存(可能改变自动工具选择)', + hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;请确认自动工具选择行为可接受后开启。' + }, autoPauseOnExpired: '过期自动暂停调度', autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', autoPause5hThreshold: '5h 用量阈值(%)', From eea02c38fc9aa12ebf9b08609f0902da612e1670 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 16:03:18 +0800 Subject: [PATCH 5/7] fix(grok): cache Claude Desktop client tools --- .../service/openai_gateway_grok_cache.go | 41 ++++- .../service/openai_gateway_grok_cache_test.go | 168 ++++++++++++++++++ .../service/openai_gateway_grok_test.go | 102 +++++++++++ 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index 1ec6159c1a..ab81c16080 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -169,19 +169,54 @@ func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, // forwards the explicitly supported OpenAI-Beta header from downstream. func applyGrokFreeRequestToolCacheRoute(c *gin.Context, body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) { allowPureClientTools := account != nil && account.getExtraBool(grokClientToolCacheOptInExtraKey) - if !allowPureClientTools && c != nil { + requestOptOut := false + if c != nil { switch strings.ToLower(strings.TrimSpace(c.GetHeader(grokClientToolCacheOptInHeader))) { case "1", "true", "yes", "on", "prefer-cache": allowPureClientTools = true + case "0", "false", "no", "off": + allowPureClientTools = false + requestOptOut = true } } + if !allowPureClientTools && !requestOptOut && isGrokClaudeDesktopResponsesCacheRequest(c) { + allowPureClientTools = true + } // A function merely named web_search/x_search is still a client function. // Native Responses search types are safe to recognize automatically, while - // converting a client function requires the same explicit opt-in as every - // other pure client-tool request (#4486). + // converting a client function requires either explicit opt-in or the strict + // Claude Desktop Responses fingerprint used above (#4486). return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, allowPureClientTools) } +// isGrokClaudeDesktopResponsesCacheRequest recognizes the strict wire +// fingerprint emitted when Claude Desktop's local agent is translated by +// CC Switch into an OpenAI Responses request. Requiring every independent +// signal prevents a generic Claude-compatible client (or the Chat bridge) +// from silently opting into the mixed native/client tool route. +func isGrokClaudeDesktopResponsesCacheRequest(c *gin.Context) bool { + if c == nil || c.Request == nil || c.Request.URL == nil || isOpenAIResponsesCompactPath(c) { + return false + } + path := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") + if !strings.HasSuffix(path, "/responses") { + return false + } + + if !claudeCodeUAPattern.MatchString(strings.TrimSpace(c.GetHeader("User-Agent"))) { + return false + } + switch strings.ToLower(strings.TrimSpace(c.GetHeader("X-App"))) { + case "cli", "cli-bg": + default: + return false + } + if !strings.EqualFold(strings.TrimSpace(c.GetHeader("anthropic-client-platform")), "desktop_app") { + return false + } + return strings.TrimSpace(c.GetHeader("X-Claude-Code-Session-Id")) != "" +} + func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) { if strings.TrimSpace(cacheIdentity) == "" || !isKnownGrokFreeAccount(account) { return body, nil diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go index a6f7d00881..d0d33ffb90 100644 --- a/backend/internal/service/openai_gateway_grok_cache_test.go +++ b/backend/internal/service/openai_gateway_grok_cache_test.go @@ -287,6 +287,174 @@ func TestGrokFreeClientToolCacheRequestOptIn(t *testing.T) { require.Equal(t, "x_search", tools[2].Get("type").String()) } +func TestGrokFreeClientToolCacheClaudeDesktopResponsesAutoOptIn(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90141, "access-token") + account.Credentials["subscription_tier"] = "free" + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}},{"type":"function","name":"Edit","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + for _, xApp := range []string{"cli", "cli-bg"} { + t.Run(xApp, func(t *testing.T) { + c := newGrokCacheTestContext(90141) + // The desktop marker text is intentionally not required; CC Switch and + // Claude Desktop may change the descriptive User-Agent suffix. + c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, future-desktop, agent-sdk/0.3.215)") + c.Request.Header.Set("X-App", xApp) + c.Request.Header.Set("anthropic-client-platform", "desktop_app") + c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1") + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 4) + require.Equal(t, "Read", tools[0].Get("name").String()) + require.Equal(t, "Edit", tools[1].Get("name").String()) + require.Equal(t, "web_search", tools[2].Get("type").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) + }) + } +} + +func TestGrokFreeClientToolCacheClaudeDesktopFingerprintRequiresAllSignals(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90142, "access-token") + account.Credentials["subscription_tier"] = "free" + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + tests := []struct { + name string + path string + ua string + xApp string + platform string + session string + }{ + { + name: "chat path", + path: "/v1/chat/completions", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + xApp: "cli", + platform: "desktop_app", + session: "desktop-session-1", + }, + { + name: "compact responses path", + path: "/v1/responses/compact", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + xApp: "cli", + platform: "desktop_app", + session: "desktop-session-1", + }, + { + name: "non claude cli user agent", + path: "/v1/responses", + ua: "Mozilla/5.0 (claude-desktop-3p)", + xApp: "cli", + platform: "desktop_app", + session: "desktop-session-1", + }, + { + name: "missing x app", + path: "/v1/responses", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + platform: "desktop_app", + session: "desktop-session-1", + }, + { + name: "wrong x app", + path: "/v1/responses", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + xApp: "desktop", + platform: "desktop_app", + session: "desktop-session-1", + }, + { + name: "missing client platform", + path: "/v1/responses", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + xApp: "cli", + session: "desktop-session-1", + }, + { + name: "wrong client platform", + path: "/v1/responses", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + xApp: "cli", + platform: "web", + session: "desktop-session-1", + }, + { + name: "missing session header", + path: "/v1/responses", + ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)", + xApp: "cli", + platform: "desktop_app", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newGrokCacheTestContext(90142) + c.Request.URL.Path = tt.path + if tt.ua != "" { + c.Request.Header.Set("User-Agent", tt.ua) + } + if tt.xApp != "" { + c.Request.Header.Set("X-App", tt.xApp) + } + if tt.platform != "" { + c.Request.Header.Set("anthropic-client-platform", tt.platform) + } + if tt.session != "" { + c.Request.Header.Set("X-Claude-Code-Session-Id", tt.session) + } + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + require.JSONEq(t, string(body), string(patched)) + }) + } +} + +func TestGrokFreeClientToolCacheClaudeDesktopExplicitRequestOptOut(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90144, "access-token") + account.Credentials["subscription_tier"] = "free" + account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true} + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + for _, value := range []string{"0", "false", "no", "off"} { + t.Run(value, func(t *testing.T) { + c := newGrokCacheTestContext(90144) + c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)") + c.Request.Header.Set("X-App", "cli") + c.Request.Header.Set("anthropic-client-platform", "desktop_app") + c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1") + c.Request.Header.Set(grokClientToolCacheOptInHeader, value) + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + require.JSONEq(t, string(body), string(patched)) + }) + } +} + +func TestGrokFreeClientToolCacheClaudeDesktopAutoOptInDoesNotOverridePaidTier(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90143, "access-token") + account.Credentials["subscription_tier"] = "supergrok" + c := newGrokCacheTestContext(90143) + c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)") + c.Request.Header.Set("X-App", "cli") + c.Request.Header.Set("anthropic-client-platform", "desktop_app") + c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1") + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + require.JSONEq(t, string(body), string(patched)) +} + func TestGrokFreeRequestClientSearchFunctionRequiresOptIn(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(9015, "access-token") account.Credentials["subscription_tier"] = "free" diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 42d88875db..bffb2744c5 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -329,6 +329,108 @@ func TestForwardGrokResponsesCodexAdditionalToolsUsesMixedCacheIntent(t *testing require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader)) } +func TestForwardGrokResponsesClaudeDesktopClientToolsUseCacheRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstBody := []byte(`{ + "model":"grok","stream":false,"instructions":"You are Claude Desktop.", + "tools":[ + {"type":"function","name":"Read","parameters":{"type":"object"}}, + {"type":"function","name":"Edit","parameters":{"type":"object"}}, + {"type":"function","name":"WebSearch","parameters":{"type":"object"}}, + {"type":"function","name":"mcp__workspace__bash","parameters":{"type":"object"}} + ], + "input":[{"role":"user","content":[{"type":"input_text","text":"first turn"}]}] + }`) + secondBody := []byte(`{ + "model":"grok","stream":false,"instructions":"You are Claude Desktop.", + "tools":[ + {"type":"function","name":"Read","parameters":{"type":"object"}}, + {"type":"function","name":"Edit","parameters":{"type":"object"}}, + {"type":"function","name":"WebSearch","parameters":{"type":"object"}}, + {"type":"function","name":"mcp__workspace__bash","parameters":{"type":"object"}} + ], + "input":[ + {"role":"user","content":[{"type":"input_text","text":"first turn"}]}, + {"role":"assistant","content":[{"type":"output_text","text":"first answer"}]}, + {"role":"user","content":[{"type":"input_text","text":"second turn"}]} + ] + }`) + + account := healthyGrokOAuthGatewayTestAccount(4504, "access-token") + account.Credentials["subscription_tier"] = "free" + repo := &grokQuotaAccountRepo{ + mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }, + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_claude_desktop_1","object":"response","model":"grok-4.5","status":"completed", + "output":[],"usage":{"input_tokens":30000,"output_tokens":10,"input_tokens_details":{"cached_tokens":0}} + }`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_claude_desktop_2","object":"response","model":"grok-4.5","status":"completed", + "output":[],"usage":{"input_tokens":30100,"output_tokens":12,"input_tokens_details":{"cached_tokens":28672}} + }`)), + }, + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + newContext := func(body []byte) *gin.Context { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)") + c.Request.Header.Set("X-App", "cli") + c.Request.Header.Set("anthropic-client-platform", "desktop_app") + c.Request.Header.Set("X-Claude-Code-Session-Id", "claude-desktop-session") + c.Set("api_key", &APIKey{ID: 4504}) + return c + } + + first, err := svc.forwardGrokResponses(context.Background(), newContext(firstBody), account, firstBody, "grok", false, time.Now()) + require.NoError(t, err) + second, err := svc.forwardGrokResponses(context.Background(), newContext(secondBody), account, secondBody, "grok", false, time.Now()) + require.NoError(t, err) + + require.Equal(t, 0, first.Usage.CacheReadInputTokens) + require.Equal(t, 28672, second.Usage.CacheReadInputTokens) + require.Len(t, upstream.bodies, 2) + require.Len(t, upstream.requests, 2) + for i := range upstream.bodies { + tools := gjson.GetBytes(upstream.bodies[i], "tools").Array() + require.Len(t, tools, 6) + require.Equal(t, "Read", tools[0].Get("name").String()) + require.Equal(t, "Edit", tools[1].Get("name").String()) + require.Equal(t, "WebSearch", tools[2].Get("name").String()) + require.Equal(t, "mcp__workspace__bash", tools[3].Get("name").String()) + require.Equal(t, "web_search", tools[4].Get("type").String()) + require.Equal(t, "x_search", tools[5].Get("type").String()) + require.False(t, gjson.GetBytes(upstream.bodies[i], "tool_choice").Exists()) + require.Empty(t, upstream.requests[i].Header.Get("X-App")) + require.Empty(t, upstream.requests[i].Header.Get("anthropic-client-platform")) + require.Empty(t, upstream.requests[i].Header.Get("X-Claude-Code-Session-Id")) + } + firstIdentity := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String() + secondIdentity := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String() + require.NotEmpty(t, firstIdentity) + require.Equal(t, firstIdentity, secondIdentity) + require.Equal(t, firstIdentity, upstream.requests[0].Header.Get(grokConversationIDHeader)) + require.Equal(t, secondIdentity, upstream.requests[1].Header.Get(grokConversationIDHeader)) +} + func TestGrokResponsesCacheIdentityIncludesPromotedCodexTools(t *testing.T) { c := newGrokCacheTestContext(4503) lookupBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"same prompt"}]}`) From c8ff478876ccdfc4be2041fee0456270f3a4c5e4 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 18:59:50 +0800 Subject: [PATCH 6/7] fix(grok): default client tool caching on for Free accounts --- .../service/openai_gateway_grok_cache.go | 43 ++++++-- .../service/openai_gateway_grok_cache_test.go | 103 +++++++++++++++--- .../components/account/EditAccountModal.vue | 18 +-- .../EditAccountModal.grokUpstream.spec.ts | 46 +++++++- .../src/i18n/locales/en/admin/accounts.ts | 2 +- .../src/i18n/locales/zh/admin/accounts.ts | 2 +- 6 files changed, 176 insertions(+), 38 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index ab81c16080..9fb916cacd 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -155,12 +155,12 @@ func hasGrokResponsesToolIntent(body []byte) bool { } // applyGrokFreeMessagesFunctionToolCacheRoute enables xAI's cache-capable -// mixed-tools route only for known Free accounts. Requests that already expose -// search capability can use the route automatically. Pure client-tool requests -// require an account-level opt-in because adding native search changes xAI's -// automatic tool selection behavior (#4486). +// mixed-tools route only for known Free accounts. Pure client tools default to +// the cache-capable route so an intermediate sub2api does not need to preserve +// client-specific opt-in headers. Operators can explicitly disable this per +// account when native search tools would change the desired behavior (#4486). func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) { - allowPureClientTools := account != nil && account.getExtraBool(grokClientToolCacheOptInExtraKey) + allowPureClientTools, _ := grokClientToolCacheAccountPolicy(account) return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, true) } @@ -168,7 +168,7 @@ func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, // sub2api header is consumed locally because buildGrokResponsesRequest only // forwards the explicitly supported OpenAI-Beta header from downstream. func applyGrokFreeRequestToolCacheRoute(c *gin.Context, body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) { - allowPureClientTools := account != nil && account.getExtraBool(grokClientToolCacheOptInExtraKey) + allowPureClientTools, accountPolicyExplicit := grokClientToolCacheAccountPolicy(account) requestOptOut := false if c != nil { switch strings.ToLower(strings.TrimSpace(c.GetHeader(grokClientToolCacheOptInHeader))) { @@ -179,16 +179,39 @@ func applyGrokFreeRequestToolCacheRoute(c *gin.Context, body, intentSourceBody [ requestOptOut = true } } - if !allowPureClientTools && !requestOptOut && isGrokClaudeDesktopResponsesCacheRequest(c) { + if !allowPureClientTools && !accountPolicyExplicit && !requestOptOut && isGrokClaudeDesktopResponsesCacheRequest(c) { allowPureClientTools = true } // A function merely named web_search/x_search is still a client function. - // Native Responses search types are safe to recognize automatically, while - // converting a client function requires either explicit opt-in or the strict - // Claude Desktop Responses fingerprint used above (#4486). + // Known Free OAuth accounts use the cache route by default; a request-scoped + // opt-in may override an account opt-out, while an explicit request opt-out + // always wins. The legacy Claude fingerprint remains only as a compatibility + // fallback when no account policy has been recorded (#4486). return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, allowPureClientTools) } +// grokClientToolCacheAccountPolicy is intentionally strict for configured +// values: only a JSON boolean is accepted. A missing key defaults on solely for +// accounts positively identified as Grok Free OAuth; paid, API-key, and unknown +// accounts remain fail-closed. +func grokClientToolCacheAccountPolicy(account *Account) (enabled, explicit bool) { + if !isKnownGrokFreeAccount(account) { + return false, false + } + if account.Extra == nil { + return true, false + } + value, exists := account.Extra[grokClientToolCacheOptInExtraKey] + if !exists { + return true, false + } + enabled, valid := value.(bool) + if !valid { + return false, true + } + return enabled, true +} + // isGrokClaudeDesktopResponsesCacheRequest recognizes the strict wire // fingerprint emitted when Claude Desktop's local agent is translated by // CC Switch into an OpenAI Responses request. Requiring every independent diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go index d0d33ffb90..90155aadc6 100644 --- a/backend/internal/service/openai_gateway_grok_cache_test.go +++ b/backend/internal/service/openai_gateway_grok_cache_test.go @@ -197,7 +197,7 @@ func TestApplyGrokCacheIdentityWritesResponsesBodyAndHeader(t *testing.T) { require.False(t, gjson.GetBytes(unscopedBody, "tool_choice").Exists()) } -func TestApplyGrokCacheIdentityAppendsNativeToolsToResponseFunctions(t *testing.T) { +func TestGrokFreeMessagesClientToolCacheDefaultsOnForKnownFree(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(901, "access-token") account.Credentials["subscription_tier"] = " FREE " tests := []struct { @@ -211,7 +211,6 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsToResponseFunctions(t *testing. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Pure client function tools without search → no native injection (#4486). intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup","description":"look up a value","parameters":{"type":"object"}},{"type":"function","name":"save","parameters":{"type":"object"}}]` + tt.toolChoiceJSON + `}`) body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true) require.NoError(t, err) @@ -220,16 +219,46 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsToResponseFunctions(t *testing. require.NoError(t, err) require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String()) tools := gjson.GetBytes(body, "tools").Array() - require.Len(t, tools, 2, "pure client functions should not get native search injected") + require.Len(t, tools, 4) require.Equal(t, "function", tools[0].Get("type").String()) require.Equal(t, "lookup", tools[0].Get("name").String()) require.Equal(t, "function", tools[1].Get("type").String()) require.Equal(t, "save", tools[1].Get("name").String()) + require.Equal(t, "web_search", tools[2].Get("type").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) require.Equal(t, tt.wantChoice, gjson.GetBytes(body, "tool_choice").Exists()) }) } } +func TestGrokFreeMessagesClientToolCacheDefaultsWithMissingAccountSetting(t *testing.T) { + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + tests := []struct { + name string + extra map[string]any + }{ + {name: "nil extra"}, + {name: "empty extra", extra: map[string]any{}}, + {name: "unrelated extra", extra: map[string]any{"other_setting": true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90101, "access-token") + account.Credentials["subscription_tier"] = "free" + account.Extra = tt.extra + + patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id") + + require.NoError(t, err) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) + }) + } +} + func TestApplyGrokCacheIdentityAppendsNativeToolsWhenSearchPresent(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(901, "access-token") account.Credentials["subscription_tier"] = " FREE " @@ -268,9 +297,36 @@ func TestGrokFreeClientToolCacheAccountOptIn(t *testing.T) { require.Equal(t, "x_search", tools[3].Get("type").String()) } -func TestGrokFreeClientToolCacheRequestOptIn(t *testing.T) { +func TestGrokFreeMessagesClientToolCacheAccountOptOut(t *testing.T) { + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + tests := []struct { + name string + value any + }{ + {name: "explicit false", value: false}, + {name: "string false is malformed", value: "false"}, + {name: "numeric value is malformed", value: 1}, + {name: "null value is malformed", value: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90111, "access-token") + account.Credentials["subscription_tier"] = "free" + account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: tt.value} + + patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id") + + require.NoError(t, err) + require.JSONEq(t, string(body), string(patched)) + }) + } +} + +func TestGrokFreeClientToolCacheRequestOptInOverridesAccountOptOut(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(9014, "access-token") account.Credentials["subscription_tier"] = "free" + account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: false} c := newGrokCacheTestContext(9014) c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache") intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`) @@ -287,6 +343,23 @@ func TestGrokFreeClientToolCacheRequestOptIn(t *testing.T) { require.Equal(t, "x_search", tools[2].Get("type").String()) } +func TestGrokFreeChatRequestClientToolCacheDefaultsOnWithoutClientFingerprint(t *testing.T) { + account := healthyGrokOAuthGatewayTestAccount(90140, "access-token") + account.Credentials["subscription_tier"] = "free" + c := newGrokCacheTestContext(90140) + c.Request.URL.Path = "/v1/chat/completions" + body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`) + + patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") + + require.NoError(t, err) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "view_image", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) +} + func TestGrokFreeClientToolCacheClaudeDesktopResponsesAutoOptIn(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(90141, "access-token") account.Credentials["subscription_tier"] = "free" @@ -318,6 +391,7 @@ func TestGrokFreeClientToolCacheClaudeDesktopResponsesAutoOptIn(t *testing.T) { func TestGrokFreeClientToolCacheClaudeDesktopFingerprintRequiresAllSignals(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(90142, "access-token") account.Credentials["subscription_tier"] = "free" + account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: false} body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`) tests := []struct { @@ -416,19 +490,15 @@ func TestGrokFreeClientToolCacheClaudeDesktopFingerprintRequiresAllSignals(t *te } } -func TestGrokFreeClientToolCacheClaudeDesktopExplicitRequestOptOut(t *testing.T) { +func TestGrokFreeClientToolCacheExplicitRequestOptOut(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(90144, "access-token") account.Credentials["subscription_tier"] = "free" - account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true} body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`) for _, value := range []string{"0", "false", "no", "off"} { t.Run(value, func(t *testing.T) { c := newGrokCacheTestContext(90144) - c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)") - c.Request.Header.Set("X-App", "cli") - c.Request.Header.Set("anthropic-client-platform", "desktop_app") - c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1") + c.Request.URL.Path = "/v1/chat/completions" c.Request.Header.Set(grokClientToolCacheOptInHeader, value) patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") @@ -455,7 +525,7 @@ func TestGrokFreeClientToolCacheClaudeDesktopAutoOptInDoesNotOverridePaidTier(t require.JSONEq(t, string(body), string(patched)) } -func TestGrokFreeRequestClientSearchFunctionRequiresOptIn(t *testing.T) { +func TestGrokFreeRequestClientSearchFunctionUsesDefaultAccountPolicy(t *testing.T) { account := healthyGrokOAuthGatewayTestAccount(9015, "access-token") account.Credentials["subscription_tier"] = "free" c := newGrokCacheTestContext(9015) @@ -464,7 +534,11 @@ func TestGrokFreeRequestClientSearchFunctionRequiresOptIn(t *testing.T) { patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id") require.NoError(t, err) - require.JSONEq(t, string(body), string(patched)) + tools := gjson.GetBytes(patched, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "view_image", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) } func TestGrokFreeRequestToolChoiceNoneUsesSafeCacheRoute(t *testing.T) { @@ -539,7 +613,7 @@ func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) { {name: "missing tools", patchedBody: `{"model":"grok-4.5"}`}, {name: "empty tools", patchedBody: `{"model":"grok-4.5","tools":[]}`}, {name: "native tools only", patchedBody: `{"model":"grok-4.5","tools":[{"type":"web_search"}]}`}, - {name: "unexpected patched tool", patchedBody: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"},{"type":"mcp","name":"server"}]}`}, + {name: "unexpected patched tool", patchedBody: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"},{"type":"namespace","name":"server"}]}`}, } for _, tt := range tests { @@ -559,8 +633,7 @@ func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) { } func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing.T) { - // Include web_search as function to trigger native tool injection (pure client - // functions no longer trigger injection after #4486 fix). + // Include web_search as a function to also cover its conversion to a native tool. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"web_search"}],"tool_choice":"auto"}`) tests := []struct { name string diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 12afc93a11..df4838fd31 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2768,7 +2768,9 @@ const headerOverrideCapable = computed( // Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响) const grokOAuthCustomBaseUrlEnabled = ref(false) const grokOAuthBaseUrl = ref('') -const grokClientToolCacheEnabled = ref(false) +// Grok Free OAuth accounts use client-tool prompt caching by default. Keep an +// explicit false in the account extra as the opt-out signal. +const grokClientToolCacheEnabled = ref(true) const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(false) @@ -3424,10 +3426,14 @@ const syncFormFromAccount = (newAccount: Account | null) => { // Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制) grokOAuthCustomBaseUrlEnabled.value = false grokOAuthBaseUrl.value = '' + const grokClientToolCacheSetting = + newAccount.platform === 'grok' && newAccount.type === 'oauth' + ? newAccount.extra?.[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] + : undefined grokClientToolCacheEnabled.value = newAccount.platform === 'grok' && newAccount.type === 'oauth' && - newAccount.extra?.[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] === true + (grokClientToolCacheSetting === undefined || grokClientToolCacheSetting === true) if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) { const grokCreds = newAccount.credentials as Record if (isCustomGrokBaseUrl(grokCreds.base_url)) { @@ -4318,11 +4324,9 @@ const handleSubmit = async () => { const newExtra: Record = { ...((props.account.extra as Record) || {}) } - if (grokClientToolCacheEnabled.value) { - newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] = true - } else { - delete newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] - } + // Persist both states so a disabled account remains opted out when the + // backend applies the default-enabled policy to missing values. + newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] = grokClientToolCacheEnabled.value updatePayload.extra = newExtra } diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts index 068d7ac1a4..c96cd86d0c 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts @@ -264,18 +264,17 @@ describe('EditAccountModal Grok OAuth upstream config', () => { const payload = updateAccountMock.mock.calls[0]?.[1] expect(payload?.extra?.custom_setting).toBe('keep-me') - expect(payload?.extra).not.toHaveProperty('grok_client_tool_cache_enabled') + expect(payload?.extra?.grok_client_tool_cache_enabled).toBe(false) }) - it('enables client-tool caching while preserving unrelated extra fields', async () => { + it('defaults client-tool caching on when the setting is missing and persists explicit true', async () => { const account = buildGrokOAuthAccount({}, { custom_setting: 'keep-me' }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]') - expect(toggle.attributes('aria-checked')).toBe('false') + expect(toggle.attributes('aria-checked')).toBe('true') - await toggle.trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) @@ -285,4 +284,43 @@ describe('EditAccountModal Grok OAuth upstream config', () => { custom_setting: 'keep-me' }) }) + + it('keeps an explicit false opt-out when saving an untouched account', async () => { + const account = buildGrokOAuthAccount( + {}, + { + grok_client_tool_cache_enabled: false, + custom_setting: 'keep-me' + } + ) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('false') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.extra).toMatchObject({ + grok_client_tool_cache_enabled: false, + custom_setting: 'keep-me' + }) + }) + + it('shows malformed cache settings as disabled so the UI matches the fail-closed backend', () => { + const account = buildGrokOAuthAccount( + {}, + { + grok_client_tool_cache_enabled: 'true', + custom_setting: 'keep-me' + } + ) + + const wrapper = mountModal(account) + expect( + wrapper.get('[data-testid="grok-client-tool-cache-toggle"]').attributes('aria-checked') + ).toBe('false') + }) }) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 772161f6c8..9352f04c35 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -643,7 +643,7 @@ export default { }, grokClientToolCache: { title: 'Client Tool Cache (May Change Automatic Tool Selection)', - hint: 'For detected Grok Free OAuth accounts, enable upstream prompt caching for client function tools such as Codex and Trae. Enable only after confirming the automatic tool-selection behavior is acceptable.' + hint: 'For detected Grok Free OAuth accounts, this is enabled by default for client function tools such as Codex and Trae. Turn it off to opt out if the automatic tool-selection behavior is not acceptable.' }, autoPauseOnExpired: 'Auto Pause On Expired', autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 9cfa1ee17c..d493b7aeff 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -736,7 +736,7 @@ export default { }, grokClientToolCache: { title: '客户端工具缓存(可能改变自动工具选择)', - hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;请确认自动工具选择行为可接受后开启。' + hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。' }, autoPauseOnExpired: '过期自动暂停调度', autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', From 3dcf412b6514a617fa7d709a1d3ddf1265a2e1f9 Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Sun, 19 Jul 2026 19:00:08 +0800 Subject: [PATCH 7/7] fix(grok): preserve Trae cache routing across turns --- .../openai_gateway_grok_chat_bridge.go | 65 +++++++++++-- .../openai_gateway_grok_chat_bridge_test.go | 97 +++++++++++++++++++ 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index baf1d8a592..89ebb4049e 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -22,18 +22,23 @@ const ( var grokChatResponsesBridgeTopLevelFields = map[string]struct{}{ "model": {}, "messages": {}, + "instructions": {}, "stream": {}, "stream_options": {}, "max_tokens": {}, "max_completion_tokens": {}, "temperature": {}, "top_p": {}, + "stop": {}, + "reasoning_effort": {}, "prompt_cache_key": {}, "tools": {}, "tool_choice": {}, "functions": {}, "function_call": {}, "parallel_tool_calls": {}, + "response_format": {}, + "service_tier": {}, } // grokChatResponsesBridgeEligibility deliberately accepts only request shapes @@ -46,11 +51,33 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) { return false, "invalid_json" } + // These fields have no effect when explicitly set to JSON null. Accepting + // that common SDK representation keeps the request on the bridge path, + // while non-null values remain unsupported because the Responses converter + // cannot preserve their Chat Completions semantics. for _, field := range []string{"stop", "reasoning_effort"} { - if _, exists := root[field]; exists { + if raw, exists := root[field]; exists && !grokChatJSONNull(raw) { return false, "unsupported_" + field } } + if raw, exists := root["instructions"]; exists { + var instructions string + if !grokChatJSONNull(raw) && json.Unmarshal(raw, &instructions) != nil { + return false, "invalid_instructions" + } + } + if raw, exists := root["response_format"]; exists { + var responseFormat map[string]json.RawMessage + if !grokChatJSONNull(raw) && (json.Unmarshal(raw, &responseFormat) != nil || responseFormat == nil) { + return false, "invalid_response_format" + } + } + if raw, exists := root["service_tier"]; exists { + var serviceTier string + if !grokChatJSONNull(raw) && json.Unmarshal(raw, &serviceTier) != nil { + return false, "invalid_service_tier" + } + } if raw, exists := root["tools"]; exists { if ok, reason := grokChatFunctionDeclarationsBridgeable(raw); !ok { return false, reason @@ -161,9 +188,16 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) { return false, reason } case "assistant": - if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_calls"); !ok { + if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "reasoning_content", "tool_calls"); !ok { return false, reason } + reasoningContent := "" + if raw, exists := message["reasoning_content"]; exists { + if !grokChatJSONNull(raw) && json.Unmarshal(raw, &reasoningContent) != nil { + return false, "invalid_reasoning_content" + } + } + hasReasoningContent := strings.TrimSpace(reasoningContent) != "" toolCallCount := 0 if raw, exists := message["tool_calls"]; exists { var reason string @@ -174,20 +208,25 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) { } raw, hasContent := message["content"] if !hasContent || strings.TrimSpace(string(raw)) == "null" { - if toolCallCount == 0 { + if toolCallCount == 0 && !hasReasoningContent { return false, "non_text_message_content" } continue } var content string if json.Unmarshal(raw, &content) == nil { - if strings.TrimSpace(content) == "" && toolCallCount == 0 { + if strings.TrimSpace(content) == "" && toolCallCount == 0 && !hasReasoningContent { return false, "empty_message_content" } continue } if ok, reason := grokChatStructuredContentBridgeable(raw); !ok { - return false, reason + // The converter can still emit a standalone reasoning part when + // an otherwise empty content array accompanies reasoning_content. + // Do not broaden this exception to unsupported/malformed parts. + if !hasReasoningContent || reason != "empty_message_content" { + return false, reason + } } case "tool": if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_call_id"); !ok { @@ -342,11 +381,17 @@ func grokChatAssistantToolCallsBridgeable(raw json.RawMessage) (int, string) { } for field := range call { switch field { - case "id", "type", "function": + case "id", "type", "function", "index": default: return 0, "unsafe_tool_call_field_" + field } } + if rawIndex, exists := call["index"]; exists { + var index *int + if json.Unmarshal(rawIndex, &index) != nil || (index != nil && *index < 0) { + return 0, "invalid_tool_call_index" + } + } var callID string if rawID, exists := call["id"]; !exists || json.Unmarshal(rawID, &callID) != nil || strings.TrimSpace(callID) == "" { return 0, "invalid_tool_call_id" @@ -419,6 +464,10 @@ func grokChatNullOrNone(raw json.RawMessage) bool { return json.Unmarshal(raw, &value) == nil && strings.EqualFold(strings.TrimSpace(value), "none") } +func grokChatJSONNull(raw json.RawMessage) bool { + return strings.TrimSpace(string(raw)) == "null" +} + func grokChatNullOrEmptyArray(raw json.RawMessage) bool { if strings.TrimSpace(string(raw)) == "null" { return true @@ -488,6 +537,10 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( } responsesReq.Model = upstreamModel responsesReq.Stream = true + // Keep Chat and native Responses paths aligned for OpenAI-compatible + // service_tier aliases (for example, "fast" -> "priority"). Unknown + // values are omitted by the shared normalizer instead of reaching xAI. + normalizeResponsesRequestServiceTier(responsesReq) // These fields are useful to Codex but are not needed by the Grok CLI // protocol. Keep the bridge request as close as possible to native Grok. responsesReq.Include = nil diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 6e39bae15d..188e38f702 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -39,6 +39,31 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) { body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":true,"stream_options":{"include_usage":true},"max_completion_tokens":256,"temperature":0.2,"top_p":0.9,"prompt_cache_key":"session","tools":[],"functions":null,"tool_choice":"none"}`, want: true, }, + { + name: "Trae compatible fields", + body: `{"model":"grok","messages":[{"role":"user","content":"return json"}],"instructions":"Be concise","response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null}`, + want: true, + }, + { + name: "Trae nullable SDK fields", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":null,"response_format":null,"service_tier":null,"stop":null,"reasoning_effort":null}`, + want: true, + }, + { + name: "invalid instructions fall back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":{"text":"invalid"}}`, + reason: "invalid_instructions", + }, + { + name: "invalid response format falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"response_format":"json_object"}`, + reason: "invalid_response_format", + }, + { + name: "invalid service tier falls back", + body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"service_tier":1}`, + reason: "invalid_service_tier", + }, { name: "stop falls back", body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stop":"done"}`, @@ -99,6 +124,26 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) { body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":true}`, want: true, }, + { + name: "Trae reasoning and indexed tool history bridges", + body: `{"model":"grok","messages":[{"role":"assistant","content":null,"reasoning_content":"I should call lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`, + want: true, + }, + { + name: "reasoning only assistant history bridges", + body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":"Prior reasoning"},{"role":"user","content":"continue"}]}`, + want: true, + }, + { + name: "invalid reasoning content falls back", + body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":{"text":"invalid"}},{"role":"user","content":"continue"}]}`, + reason: "invalid_reasoning_content", + }, + { + name: "negative tool call index falls back", + body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"index":-1,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{}"}}]}]}`, + reason: "invalid_tool_call_index", + }, { name: "unknown tool type falls back", body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{"name":"lookup","parameters":{"type":"object"}}}]}`, @@ -286,6 +331,58 @@ func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) require.Equal(t, `{"value":"ok"}`, gjson.GetBytes(upstream.lastBody, "input.3.output").String()) } +func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`) + body := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"reasoning_content":"I should use lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7161}) + + account := grokChatBridgeTestAccount(716) + account.Credentials["subscription_tier"] = "free" + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae_compat", 12288)} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + require.NotEmpty(t, firstTurnIdentity) + require.Equal(t, firstTurnIdentity, extendedTurnIdentity) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) + require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint) + require.Equal(t, 12288, result.Usage.CacheReadInputTokens) + require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader)) + require.Equal(t, "Return concise JSON", gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.Equal(t, "json_object", gjson.GetBytes(upstream.lastBody, "text.format.type").String()) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "stop").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning").Exists()) + require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "I should use lookup") + require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String()) + + tools := gjson.GetBytes(upstream.lastBody, "tools").Array() + require.Len(t, tools, 3) + require.Equal(t, "lookup", tools[0].Get("name").String()) + require.Equal(t, "web_search", tools[1].Get("type").String()) + require.Equal(t, "x_search", tools[2].Get("type").String()) + require.Equal(t, int64(12288), gjson.Get(recorder.Body.String(), "usage.prompt_tokens_details.cached_tokens").Int()) +} + func TestForwardGrokChatViaResponsesStreamingPropagatesCachedUsage(t *testing.T) { gin.SetMode(gin.TestMode)