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] 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"}]}`)