mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-27 08:05:33 +00:00
fix(grok): default client tool caching on for Free accounts
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user