mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(grok): cache Claude Desktop client tools
This commit is contained in:
@@ -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"}]}`)
|
||||
|
||||
Reference in New Issue
Block a user