fix(grok): default client tool caching on for Free accounts

This commit is contained in:
superman2003
2026-07-19 18:59:50 +08:00
parent eea02c38fc
commit c8ff478876
6 changed files with 176 additions and 38 deletions
@@ -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
@@ -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
@@ -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<string, unknown>
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
@@ -4318,11 +4324,9 @@ const handleSubmit = async () => {
const newExtra: Record<string, unknown> = {
...((props.account.extra as Record<string, unknown>) || {})
}
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
}
@@ -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')
})
})
@@ -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',
@@ -736,7 +736,7 @@ export default {
},
grokClientToolCache: {
title: '客户端工具缓存(可能改变自动工具选择)',
hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;请确认自动工具选择行为可接受后开启。'
hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。'
},
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',