fix(grok): cache Claude Desktop client tools

This commit is contained in:
superman2003
2026-07-19 16:03:18 +08:00
parent 9d79b42729
commit eea02c38fc
3 changed files with 308 additions and 3 deletions
@@ -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
@@ -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"
@@ -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"}]}`)