From 5af38ccf416a8bcba36174c8fe68e1afa0bb8ab0 Mon Sep 17 00:00:00 2001
From: superman2003 <2112076433zcr@gmail.com>
Date: Sun, 19 Jul 2026 14:41:38 +0800
Subject: [PATCH 1/7] fix(grok): add guarded client tool cache routing
---
.../service/openai_gateway_grok_cache.go | 136 +++++++++++++++---
.../service/openai_gateway_grok_cache_test.go | 111 ++++++++++++++
2 files changed, 226 insertions(+), 21 deletions(-)
diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go
index d9e34cbb58..1ec6159c1a 100644
--- a/backend/internal/service/openai_gateway_grok_cache.go
+++ b/backend/internal/service/openai_gateway_grok_cache.go
@@ -12,10 +12,12 @@ import (
)
const (
- grokConversationIDHeader = "X-Grok-Conv-Id"
- grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
- grokFreeCacheDisabledToolChoice = "none"
- grokFreeRolling24hTokenLimit = int64(2_000_000)
+ grokConversationIDHeader = "X-Grok-Conv-Id"
+ grokClientToolCacheOptInHeader = "X-Sub2API-Grok-Client-Tool-Cache"
+ grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
+ grokFreeCacheDisabledToolChoice = "none"
+ grokClientToolCacheOptInExtraKey = "grok_client_tool_cache_enabled"
+ grokFreeRolling24hTokenLimit = int64(2_000_000)
)
// resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity
@@ -122,7 +124,7 @@ func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity str
// Inspect the pre-sanitization source. patchGrokResponsesBody may remove an
// unsupported client tool and its tool_choice; that must not turn an
// explicit client tool intent into an eligible native-tool request.
- if gjson.GetBytes(intentSourceBody, "tools").Exists() || gjson.GetBytes(intentSourceBody, "tool_choice").Exists() {
+ if hasGrokResponsesToolIntent(intentSourceBody) {
return out, nil
}
out, err = sjson.SetRawBytes(out, "tools", []byte(grokFreeCacheNativeToolsJSON))
@@ -132,12 +134,55 @@ func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity str
return sjson.SetBytes(out, "tool_choice", grokFreeCacheDisabledToolChoice)
}
+func hasGrokResponsesToolIntent(body []byte) bool {
+ if gjson.GetBytes(body, "tools").Exists() || gjson.GetBytes(body, "tool_choice").Exists() {
+ return true
+ }
+ input := gjson.GetBytes(body, "input")
+ if !input.IsArray() {
+ return false
+ }
+ for _, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
+ continue
+ }
+ tools := item.Get("tools")
+ if !tools.Exists() || !tools.IsArray() || len(tools.Array()) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
// applyGrokFreeMessagesFunctionToolCacheRoute enables xAI's cache-capable
-// mixed-tools route only for the Anthropic Messages bridge and only when the
-// selected account is known to be Free. Native tools become eligible under
-// auto selection, so callers must not apply this policy to paid accounts or
-// other ingress protocols implicitly.
+// 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).
func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) {
+ allowPureClientTools := account != nil && account.getExtraBool(grokClientToolCacheOptInExtraKey)
+ return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, true)
+}
+
+// applyGrokFreeRequestToolCacheRoute also accepts a request-scoped opt-in. The
+// 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)
+ if !allowPureClientTools && c != nil {
+ switch strings.ToLower(strings.TrimSpace(c.GetHeader(grokClientToolCacheOptInHeader))) {
+ case "1", "true", "yes", "on", "prefer-cache":
+ 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).
+ return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, allowPureClientTools)
+}
+
+func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) {
if strings.TrimSpace(cacheIdentity) == "" || !isKnownGrokFreeAccount(account) {
return body, nil
}
@@ -146,7 +191,12 @@ func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte,
if !isGrokFreeCacheFunctionToolIntent(intentTools, intentToolChoice) {
return body, nil
}
- return appendMissingGrokFreeCacheNativeTools(body)
+ if intentToolChoice.Type == gjson.String && strings.TrimSpace(intentToolChoice.String()) == grokFreeCacheDisabledToolChoice {
+ // Adding native cache-routing tools cannot change behavior when the
+ // client has explicitly disabled all tool execution.
+ return appendGrokFreeCacheNativeToolsWithPolicy(body, true, false)
+ }
+ return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch)
}
func isKnownGrokFreeAccount(account *Account) bool {
@@ -231,22 +281,44 @@ func isGrokFreeCacheFunctionToolIntent(tools, toolChoice gjson.Result) bool {
return false
}
for _, tool := range items {
- if !tool.IsObject() || strings.TrimSpace(tool.Get("type").String()) != "function" {
+ if !tool.IsObject() {
return false
}
- // Responses function declarations keep name at the top level. Reject
- // Chat Completions' nested function shape and incomplete declarations.
- if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
+ toolType := strings.TrimSpace(tool.Get("type").String())
+ if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
return false
}
+ if toolType == "function" {
+ // Responses function declarations keep name at the top level. Reject
+ // Chat Completions' nested function shape and incomplete declarations.
+ if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
+ return false
+ }
+ }
}
if !toolChoice.Exists() {
return true
}
- return toolChoice.Type == gjson.String && strings.TrimSpace(toolChoice.String()) == "auto"
+ if toolChoice.Type != gjson.String {
+ return false
+ }
+ switch strings.TrimSpace(toolChoice.String()) {
+ case "auto", grokFreeCacheDisabledToolChoice:
+ return true
+ default:
+ return false
+ }
}
func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
+ return appendGrokFreeCacheNativeTools(body, false)
+}
+
+func appendGrokFreeCacheNativeTools(body []byte, allowPureClientTools bool) ([]byte, error) {
+ return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, true)
+}
+
+func appendGrokFreeCacheNativeToolsWithPolicy(body []byte, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) {
tools := gjson.GetBytes(body, "tools")
if !tools.Exists() || !tools.IsArray() {
return body, nil
@@ -256,9 +328,19 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
if len(items) == 0 {
return body, nil
}
+ hasNativeSearch := false
+ for _, tool := range items {
+ switch strings.TrimSpace(tool.Get("type").String()) {
+ case "web_search", "x_search":
+ hasNativeSearch = true
+ }
+ }
+ if !allowPureClientTools && !allowFunctionSearch && !hasNativeSearch {
+ return body, nil
+ }
merged := make([]json.RawMessage, 0, len(items)+2)
present := make(map[string]bool, 2)
- hasFunction := false
+ hasCompanionTool := false
for _, tool := range items {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
@@ -269,7 +351,7 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
}
// Grok Build may declare search as function tools. Convert to native
// entries so Free OAuth stays cache-capable without duplicate names.
- if name == "web_search" || name == "x_search" {
+ if (name == "web_search" || name == "x_search") && allowFunctionSearch {
if present[name] {
continue
}
@@ -279,9 +361,17 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
}
merged = append(merged, raw)
present[name] = true
+ if allowPureClientTools {
+ hasCompanionTool = true
+ }
continue
}
- hasFunction = true
+ if name == "web_search" || name == "x_search" {
+ // Keep the client function intact and avoid adding a same-named
+ // native tool unless conversion was explicitly enabled.
+ present[name] = true
+ }
+ hasCompanionTool = true
merged = append(merged, json.RawMessage(tool.Raw))
case "web_search", "x_search":
if present[toolType] {
@@ -290,17 +380,21 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
merged = append(merged, json.RawMessage(tool.Raw))
present[toolType] = true
default:
- return body, nil
+ if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
+ return body, nil
+ }
+ hasCompanionTool = true
+ merged = append(merged, json.RawMessage(tool.Raw))
}
}
- if !hasFunction {
+ if !hasCompanionTool {
return body, nil
}
// Only complement missing native search tools when the request already contains
// at least one search tool (native or function-form). Pure client function tools
// (e.g. view_image) must not trigger injection to avoid biasing model tool
// selection (#4486).
- if !present["web_search"] && !present["x_search"] {
+ if !allowPureClientTools && !present["web_search"] && !present["x_search"] {
return body, nil
}
for _, toolType := range []string{"web_search", "x_search"} {
diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go
index 73b1ea5ec1..a6f7d00881 100644
--- a/backend/internal/service/openai_gateway_grok_cache_test.go
+++ b/backend/internal/service/openai_gateway_grok_cache_test.go
@@ -249,6 +249,117 @@ func TestApplyGrokCacheIdentityAppendsNativeToolsWhenSearchPresent(t *testing.T)
require.Equal(t, "x_search", tools[2].Get("type").String())
}
+func TestGrokFreeClientToolCacheAccountOptIn(t *testing.T) {
+ account := healthyGrokOAuthGatewayTestAccount(9011, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true}
+ intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"read_file","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
+
+ body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
+ require.NoError(t, err)
+ body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
+ require.NoError(t, err)
+
+ tools := gjson.GetBytes(body, "tools").Array()
+ require.Len(t, tools, 4)
+ require.Equal(t, "view_image", tools[0].Get("name").String())
+ require.Equal(t, "read_file", 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 TestGrokFreeClientToolCacheRequestOptIn(t *testing.T) {
+ account := healthyGrokOAuthGatewayTestAccount(9014, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ 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"}`)
+
+ body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
+ require.NoError(t, err)
+ body, err = applyGrokFreeRequestToolCacheRoute(c, body, intentBody, account, "isolated-id")
+ require.NoError(t, err)
+
+ tools := gjson.GetBytes(body, "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 TestGrokFreeRequestClientSearchFunctionRequiresOptIn(t *testing.T) {
+ account := healthyGrokOAuthGatewayTestAccount(9015, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ c := newGrokCacheTestContext(9015)
+ body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"web_search","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 TestGrokFreeRequestToolChoiceNoneUsesSafeCacheRoute(t *testing.T) {
+ account := healthyGrokOAuthGatewayTestAccount(9016, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ c := newGrokCacheTestContext(9016)
+ body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"none"}`)
+
+ 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())
+ require.Equal(t, "none", gjson.GetBytes(patched, "tool_choice").String())
+}
+
+func TestApplyGrokCacheIdentityRecognizesResponsesLiteAdditionalTools(t *testing.T) {
+ intentBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"hello"}]}`)
+ patchedBody := []byte(`{"model":"grok-4.5","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"type":"message","role":"user","content":"hello"}]}`)
+
+ patched, err := applyGrokResponsesCacheIdentity(patchedBody, intentBody, "isolated-id", true)
+
+ require.NoError(t, err)
+ tools := gjson.GetBytes(patched, "tools").Array()
+ require.Len(t, tools, 1)
+ require.Equal(t, "lookup", tools[0].Get("name").String())
+ require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
+ require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String())
+}
+
+func TestGrokFreeCacheRoutePreservesMixedSupportedToolsWithSearchIntent(t *testing.T) {
+ account := healthyGrokOAuthGatewayTestAccount(9012, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"shell"},{"type":"web_search"}],"tool_choice":"auto"}`)
+
+ body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
+ require.NoError(t, err)
+ body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
+ require.NoError(t, err)
+
+ tools := gjson.GetBytes(body, "tools").Array()
+ require.Len(t, tools, 4)
+ require.Equal(t, "function", tools[0].Get("type").String())
+ require.Equal(t, "shell", tools[1].Get("type").String())
+ require.Equal(t, "web_search", tools[2].Get("type").String())
+ require.Equal(t, "x_search", tools[3].Get("type").String())
+}
+
+func TestGrokClientToolCacheOptInDoesNotOverridePaidTier(t *testing.T) {
+ account := healthyGrokOAuthGatewayTestAccount(9013, "access-token")
+ account.Credentials["subscription_tier"] = "supergrok"
+ account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true}
+ body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
+
+ patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
+
+ require.NoError(t, err)
+ require.JSONEq(t, string(body), string(patched))
+}
+
func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) {
account := healthyGrokOAuthGatewayTestAccount(902, "access-token")
account.Credentials["subscription_tier"] = "free"
From 026d2a80839286e4992d882d49140c780dcf7a03 Mon Sep 17 00:00:00 2001
From: superman2003 <2112076433zcr@gmail.com>
Date: Sun, 19 Jul 2026 14:41:47 +0800
Subject: [PATCH 2/7] fix(grok): promote Codex Lite tools for caching
---
.../internal/service/openai_gateway_grok.go | 67 ++++++++-
.../service/openai_gateway_grok_test.go | 136 +++++++++++++++++-
.../internal/service/openai_ws_http_bridge.go | 7 +-
.../service/openai_ws_http_bridge_test.go | 72 ++++++++++
4 files changed, 271 insertions(+), 11 deletions(-)
diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go
index 198055f20e..dec30c3bd2 100644
--- a/backend/internal/service/openai_gateway_grok.go
+++ b/backend/internal/service/openai_gateway_grok.go
@@ -52,18 +52,21 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
if isGrokImageGenerationModel(upstreamModel) {
return nil, fmt.Errorf("model %s is an image model and is not available on the Responses endpoint; use /v1/images/generations instead", upstreamModel)
}
- cacheIdentity := resolveGrokCacheIdentity(c, body, "", upstreamModel)
patchedBody, err := patchGrokResponsesBody(body, upstreamModel)
if err != nil {
return nil, err
}
+ // Derive the identity from the request xAI will actually see. This makes
+ // Codex Responses Lite additional_tools part of the stable tool prefix.
+ cacheIdentity := resolveGrokCacheIdentity(c, patchedBody, "", upstreamModel)
+ mixedCacheIntentBody := append([]byte(nil), patchedBody...)
patchedBody, err = applyGrokResponsesCacheIdentity(patchedBody, body, cacheIdentity, account.IsGrokOAuth())
if err != nil {
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
}
// Free OAuth + client function tools: reuse Messages mixed-tools cache route
// (append web_search/x_search so xAI does not force non-cacheable build-free).
- patchedBody, err = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, account, cacheIdentity)
+ patchedBody, err = applyGrokFreeRequestToolCacheRoute(c, patchedBody, mixedCacheIntentBody, account, cacheIdentity)
if err != nil {
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
}
@@ -374,10 +377,9 @@ func deleteJSONFields(value any, fields map[string]struct{}) bool {
}
// additional_tools is a Codex/Responses Lite private input carrier. xAI's
-// Responses schema accepts ordinary message/function-call input items but
-// rejects this carrier before inference with a ModelInput deserialization
-// error. Top-level supported tools remain available through the separate
-// sanitizeGrokResponsesTools path.
+// Responses schema rejects the carrier itself, but accepts supported tools at
+// the top level. Preserve top-level order, append newly discovered tools in
+// carrier order, then let sanitizeGrokResponsesTools filter unsupported types.
func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"additional_tools"`)) {
return body, nil
@@ -389,8 +391,36 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
rawItems := input.Array()
filtered := make([]json.RawMessage, 0, len(rawItems))
+ topLevelTools := gjson.GetBytes(body, "tools")
+ mergedTools := make([]json.RawMessage, 0)
+ seenTools := make(map[string]struct{})
+ appendTool := func(tool gjson.Result) bool {
+ key := grokResponsesToolDedupKey(tool)
+ if _, exists := seenTools[key]; exists {
+ return false
+ }
+ seenTools[key] = struct{}{}
+ mergedTools = append(mergedTools, json.RawMessage(tool.Raw))
+ return true
+ }
+ if topLevelTools.IsArray() {
+ for _, tool := range topLevelTools.Array() {
+ seenTools[grokResponsesToolDedupKey(tool)] = struct{}{}
+ mergedTools = append(mergedTools, json.RawMessage(tool.Raw))
+ }
+ }
+
+ promoted := false
for _, item := range rawItems {
if strings.TrimSpace(item.Get("type").String()) == "additional_tools" {
+ tools := item.Get("tools")
+ if tools.IsArray() {
+ for _, tool := range tools.Array() {
+ if appendTool(tool) {
+ promoted = true
+ }
+ }
+ }
continue
}
filtered = append(filtered, json.RawMessage(item.Raw))
@@ -402,7 +432,30 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
if err != nil {
return nil, err
}
- return sjson.SetRawBytes(body, "input", encoded)
+ body, err = sjson.SetRawBytes(body, "input", encoded)
+ if err != nil || !promoted {
+ return body, err
+ }
+ encodedTools, err := json.Marshal(mergedTools)
+ if err != nil {
+ return nil, err
+ }
+ return sjson.SetRawBytes(body, "tools", encodedTools)
+}
+
+func grokResponsesToolDedupKey(tool gjson.Result) string {
+ toolType := strings.TrimSpace(tool.Get("type").String())
+ if toolType != "" {
+ if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
+ return "type:" + toolType + "\x00name:" + name
+ }
+ if toolType == "mcp" {
+ if label := strings.TrimSpace(tool.Get("server_label").String()); label != "" {
+ return "type:mcp\x00server_label:" + label
+ }
+ }
+ }
+ return "json:" + normalizeCompatSeedJSON(json.RawMessage(tool.Raw))
}
// sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。
diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go
index fd7eb09c3d..42d88875db 100644
--- a/backend/internal/service/openai_gateway_grok_test.go
+++ b/backend/internal/service/openai_gateway_grok_test.go
@@ -206,18 +206,27 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
}
-func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T) {
+func TestPatchGrokResponsesBodyPromotesCodexAdditionalTools(t *testing.T) {
t.Parallel()
body := []byte(`{
"model": "grok",
+ "tools": [
+ {"type": "function", "name": "existing", "description": "top-level wins"},
+ {"type": "web_search"}
+ ],
+ "tool_choice": "auto",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [
- {"type": "namespace", "name": "image_gen"},
- {"type": "function", "name": "wait"}
+ {"type": "function", "name": "existing", "description": "duplicate carrier definition"},
+ {"type": "function", "name": "wait"},
+ {"type": "web_search"},
+ {"type": "shell"},
+ {"type": "custom", "name": "apply_patch"},
+ {"type": "namespace", "name": "collaboration"}
]
},
{
@@ -239,12 +248,133 @@ func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T)
require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String())
require.Equal(t, 2, len(gjson.GetBytes(patched, "input").Array()))
require.False(t, gjson.GetBytes(patched, `input.#(type=="additional_tools")`).Exists())
+ tools := gjson.GetBytes(patched, "tools").Array()
+ require.Len(t, tools, 4)
+ require.Equal(t, "existing", tools[0].Get("name").String())
+ require.Equal(t, "top-level wins", tools[0].Get("description").String())
+ require.Equal(t, "web_search", tools[1].Get("type").String())
+ require.Equal(t, "wait", tools[2].Get("name").String())
+ require.Equal(t, "shell", tools[3].Get("type").String())
+ require.False(t, gjson.GetBytes(patched, `tools.#(type=="custom")`).Exists())
+ require.False(t, gjson.GetBytes(patched, `tools.#(type=="namespace")`).Exists())
+ require.Equal(t, "auto", gjson.GetBytes(patched, "tool_choice").String())
require.Equal(t, "developer", gjson.GetBytes(patched, "input.0.role").String())
require.Equal(t, "system prompt", gjson.GetBytes(patched, "input.0.content.0.text").String())
require.Equal(t, "user", gjson.GetBytes(patched, "input.1.role").String())
require.Equal(t, "hello", gjson.GetBytes(patched, "input.1.content.0.text").String())
}
+func TestForwardGrokResponsesCodexAdditionalToolsUsesMixedCacheIntent(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ body := []byte(`{
+ "model":"grok",
+ "stream":false,
+ "prompt_cache_key":"codex-session",
+ "input":[
+ {"type":"additional_tools","role":"developer","tools":[
+ {"type":"function","name":"lookup","description":"look up a key","parameters":{"type":"object"}},
+ {"type":"function","name":"web_search","description":"search","parameters":{"type":"object"}},
+ {"type":"custom","name":"apply_patch"},
+ {"type":"namespace","name":"collaboration"}
+ ]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}
+ ]
+ }`)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
+ c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
+ c.Set("api_key", &APIKey{ID: 4501})
+
+ account := healthyGrokOAuthGatewayTestAccount(4501, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ repo := &grokQuotaAccountRepo{
+ mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
+ accountsByID: map[int64]*Account{account.ID: account},
+ },
+ }
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{
+ "id":"resp_codex_lite","object":"response","model":"grok-4.5","status":"completed",
+ "output":[],"usage":{"input_tokens":10,"output_tokens":1}
+ }`)),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ grokTokenProvider: NewGrokTokenProvider(repo, nil),
+ accountRepo: repo,
+ }
+
+ result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "resp_codex_lite", result.ResponseID)
+ require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists())
+ tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
+ require.Len(t, tools, 3)
+ require.Equal(t, "function", tools[0].Get("type").String())
+ require.Equal(t, "lookup", 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())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
+ identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()
+ require.NotEmpty(t, identity)
+ require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader))
+ require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader))
+}
+
+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"}]}`)
+ readBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"read_file","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"same prompt"}]}`)
+
+ patchedLookup, err := patchGrokResponsesBody(lookupBody, "grok-4.5")
+ require.NoError(t, err)
+ patchedRead, err := patchGrokResponsesBody(readBody, "grok-4.5")
+ require.NoError(t, err)
+
+ lookupIdentity := resolveGrokCacheIdentity(c, patchedLookup, "", "grok-4.5")
+ readIdentity := resolveGrokCacheIdentity(c, patchedRead, "", "grok-4.5")
+ require.NotEmpty(t, lookupIdentity)
+ require.NotEmpty(t, readIdentity)
+ require.NotEqual(t, lookupIdentity, readIdentity)
+}
+
+func TestCodexUnsupportedAdditionalToolsDoNotBecomeToolFreeCacheIntent(t *testing.T) {
+ body := []byte(`{
+ "model":"grok","tool_choice":"auto",
+ "input":[
+ {"type":"additional_tools","role":"developer","tools":[
+ {"type":"custom","name":"apply_patch"},
+ {"type":"namespace","name":"collaboration"}
+ ]},
+ {"type":"message","role":"user","content":"hello"}
+ ]
+ }`)
+ patched, err := patchGrokResponsesBody(body, "grok-4.5")
+ require.NoError(t, err)
+ require.False(t, gjson.GetBytes(patched, "tools").Exists())
+ require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
+
+ mixedCacheIntent := patched
+ patched, err = applyGrokResponsesCacheIdentity(patched, body, "isolated-id", true)
+ require.NoError(t, err)
+ account := healthyGrokOAuthGatewayTestAccount(4502, "access-token")
+ account.Credentials["subscription_tier"] = "free"
+ patched, err = applyGrokFreeRequestToolCacheRoute(nil, patched, mixedCacheIntent, account, "isolated-id")
+
+ require.NoError(t, err)
+ require.False(t, gjson.GetBytes(patched, "tools").Exists())
+ require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
+ require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String())
+}
+
func TestBuildGrokResponsesRequestUsesAccountBaseURLAndBearerToken(t *testing.T) {
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go
index cc7b307982..dd9875d0ad 100644
--- a/backend/internal/service/openai_ws_http_bridge.go
+++ b/backend/internal/service/openai_ws_http_bridge.go
@@ -196,12 +196,13 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
releaseUpstreamCtx()
return nil, err
}
+ grokMixedCacheIntentBody := append([]byte(nil), body...)
body, err = applyGrokResponsesCacheIdentity(body, grokIntentSourceBody, grokCacheIdentity, account.IsGrokOAuth())
if err != nil {
releaseUpstreamCtx()
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
}
- body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity)
+ body, err = applyGrokFreeRequestToolCacheRoute(c, body, grokMixedCacheIntentBody, account, grokCacheIdentity)
if err != nil {
releaseUpstreamCtx()
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
@@ -446,6 +447,10 @@ func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte
return "", err
}
upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel)
+ body, err = patchGrokResponsesBody(body, upstreamModel)
+ if err != nil {
+ return "", err
+ }
return resolveGrokCacheIdentity(c, body, "", upstreamModel), nil
}
diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go
index 808a63c6b1..2f77690b22 100644
--- a/backend/internal/service/openai_ws_http_bridge_test.go
+++ b/backend/internal/service/openai_ws_http_bridge_test.go
@@ -235,6 +235,78 @@ func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T)
require.Len(t, events, 2)
}
+func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(strings.Join([]string{
+ `data: {"type":"response.created","response":{"id":"resp_grok_codex_lite","model":"grok-4.5"}}`,
+ "",
+ `data: {"type":"response.completed","response":{"id":"resp_grok_codex_lite","model":"grok-4.5","usage":{"input_tokens":4,"output_tokens":1}}}`,
+ "",
+ }, "\n"))),
+ }}
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 73,
+ Platform: PlatformGrok,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "base_url": xai.DefaultCLIBaseURL,
+ "subscription_tier": "free",
+ },
+ }
+ payload := []byte(`{
+ "type":"response.create","generate":true,"model":"grok","stream":true,
+ "input":[
+ {"type":"additional_tools","role":"developer","tools":[
+ {"type":"function","name":"lookup","parameters":{"type":"object"}},
+ {"type":"function","name":"web_search","parameters":{"type":"object"}},
+ {"type":"custom","name":"apply_patch"},
+ {"type":"namespace","name":"collaboration"}
+ ]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}
+ ]
+ }`)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
+ c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
+ var events [][]byte
+
+ result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
+ context.Background(), c, account, "access-token", payload, len(payload),
+ "grok", "", "", "", "isolated-ws-cache-id", 1,
+ func(message []byte) error {
+ events = append(events, append([]byte(nil), message...))
+ return nil
+ },
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Len(t, events, 2)
+ require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists())
+ tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
+ require.Len(t, tools, 3)
+ require.Equal(t, "function", tools[0].Get("type").String())
+ require.Equal(t, "lookup", 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())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
+ require.Equal(t, "isolated-ws-cache-id", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
+ require.Equal(t, "isolated-ws-cache-id", upstream.lastReq.Header.Get(grokConversationIDHeader))
+ require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader))
+}
+
func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) {
gin.SetMode(gin.TestMode)
From 752b2eaa9b255ba9b5fe3cab689ec66cf07c1527 Mon Sep 17 00:00:00 2001
From: superman2003 <2112076433zcr@gmail.com>
Date: Sun, 19 Jul 2026 14:41:55 +0800
Subject: [PATCH 3/7] fix(grok): bridge Trae tool requests through responses
---
.../openai_gateway_grok_chat_bridge.go | 315 +++++++++++++++---
.../openai_gateway_grok_chat_bridge_test.go | 117 ++++++-
2 files changed, 381 insertions(+), 51 deletions(-)
diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go
index 0b8f0e2d83..baf1d8a592 100644
--- a/backend/internal/service/openai_gateway_grok_chat_bridge.go
+++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go
@@ -33,6 +33,7 @@ var grokChatResponsesBridgeTopLevelFields = map[string]struct{}{
"tool_choice": {},
"functions": {},
"function_call": {},
+ "parallel_tool_calls": {},
}
// grokChatResponsesBridgeEligibility deliberately accepts only request shapes
@@ -50,13 +51,22 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
return false, "unsupported_" + field
}
}
- for _, field := range []string{"tools", "functions"} {
- if raw, exists := root[field]; exists && !grokChatNullOrEmptyArray(raw) {
- return false, "unsupported_" + field
+ if raw, exists := root["tools"]; exists {
+ if ok, reason := grokChatFunctionDeclarationsBridgeable(raw); !ok {
+ return false, reason
}
}
- if raw, exists := root["tool_choice"]; exists && !grokChatNullOrNone(raw) {
- return false, "unsupported_tool_choice"
+ if raw, exists := root["functions"]; exists && !grokChatNullOrEmptyArray(raw) {
+ return false, "unsupported_functions"
+ }
+ if raw, exists := root["tool_choice"]; exists {
+ if ok, reason := grokChatToolChoiceBridgeable(raw); !ok {
+ return false, reason
+ }
+ var choice string
+ if json.Unmarshal(raw, &choice) == nil && choice == "required" && !grokChatHasFunctionDeclarations(root) {
+ return false, "required_tool_choice_without_tools"
+ }
}
if raw, exists := root["function_call"]; exists && !grokChatNullOrNone(raw) {
return false, "unsupported_function_call"
@@ -78,6 +88,12 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
return false, "invalid_stream"
}
}
+ if raw, ok := root["parallel_tool_calls"]; ok {
+ var parallelToolCalls *bool
+ if json.Unmarshal(raw, ¶llelToolCalls) != nil || parallelToolCalls == nil {
+ return false, "invalid_parallel_tool_calls"
+ }
+ }
if raw, ok := root["stream_options"]; ok {
var options map[string]json.RawMessage
if json.Unmarshal(raw, &options) != nil || options == nil {
@@ -128,42 +144,238 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
return false, "invalid_messages"
}
for _, message := range messages {
- for field := range message {
- if field != "role" && field != "content" {
- return false, "unsafe_message_field_" + field
- }
- }
var role string
if raw, exists := message["role"]; !exists || json.Unmarshal(raw, &role) != nil {
return false, "invalid_message_role"
}
switch role {
- case "system", "user", "assistant":
+ case "system", "user":
+ if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content"); !ok {
+ return false, reason
+ }
+ raw, exists := message["content"]
+ if !exists {
+ return false, "non_text_message_content"
+ }
+ if ok, reason := grokChatRequiredMessageContentBridgeable(raw); !ok {
+ return false, reason
+ }
+ case "assistant":
+ if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_calls"); !ok {
+ return false, reason
+ }
+ toolCallCount := 0
+ if raw, exists := message["tool_calls"]; exists {
+ var reason string
+ toolCallCount, reason = grokChatAssistantToolCallsBridgeable(raw)
+ if reason != "" {
+ return false, reason
+ }
+ }
+ raw, hasContent := message["content"]
+ if !hasContent || strings.TrimSpace(string(raw)) == "null" {
+ if toolCallCount == 0 {
+ return false, "non_text_message_content"
+ }
+ continue
+ }
+ var content string
+ if json.Unmarshal(raw, &content) == nil {
+ if strings.TrimSpace(content) == "" && toolCallCount == 0 {
+ return false, "empty_message_content"
+ }
+ continue
+ }
+ if ok, reason := grokChatStructuredContentBridgeable(raw); !ok {
+ return false, reason
+ }
+ case "tool":
+ if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_call_id"); !ok {
+ return false, reason
+ }
+ var callID string
+ if raw, exists := message["tool_call_id"]; !exists || json.Unmarshal(raw, &callID) != nil || strings.TrimSpace(callID) == "" {
+ return false, "invalid_tool_call_id"
+ }
+ var output string
+ if raw, exists := message["content"]; !exists || json.Unmarshal(raw, &output) != nil || output == "" {
+ return false, "invalid_tool_message_content"
+ }
default:
return false, "unsupported_message_role_" + role
}
- raw, exists := message["content"]
- if !exists {
- return false, "non_text_message_content"
- }
- var content string
- if json.Unmarshal(raw, &content) == nil {
- if strings.TrimSpace(content) == "" {
- return false, "empty_message_content"
- }
- continue
- }
- // Structured content: only allow arrays whose parts are text or
- // image_url. These are losslessly convertible to Responses input_text/
- // input_image parts, so the bridge preserves Chat Completions semantics.
- if ok, reason := grokChatStructuredContentBridgeable(raw); !ok {
- return false, reason
- }
}
return true, ""
}
+func grokChatFunctionDeclarationsBridgeable(raw json.RawMessage) (bool, string) {
+ if strings.TrimSpace(string(raw)) == "null" {
+ return true, ""
+ }
+ var declarations []json.RawMessage
+ if json.Unmarshal(raw, &declarations) != nil {
+ return false, "invalid_tools"
+ }
+ for _, declaration := range declarations {
+ var tool map[string]json.RawMessage
+ if json.Unmarshal(declaration, &tool) != nil || tool == nil {
+ return false, "invalid_tool"
+ }
+ for field := range tool {
+ if field != "type" && field != "function" {
+ return false, "unsafe_tool_field_" + field
+ }
+ }
+ var toolType string
+ if rawType, exists := tool["type"]; !exists || json.Unmarshal(rawType, &toolType) != nil || toolType != "function" {
+ return false, "unsupported_tool_type"
+ }
+ functionRaw, exists := tool["function"]
+ if !exists {
+ return false, "invalid_tool_function"
+ }
+
+ var function map[string]json.RawMessage
+ if json.Unmarshal(functionRaw, &function) != nil || function == nil {
+ return false, "invalid_tool_function"
+ }
+ for field := range function {
+ switch field {
+ case "name", "description", "parameters", "strict":
+ default:
+ return false, "unsafe_tool_function_field_" + field
+ }
+ }
+ var name string
+ if rawName, exists := function["name"]; !exists || json.Unmarshal(rawName, &name) != nil || strings.TrimSpace(name) == "" {
+ return false, "invalid_tool_function_name"
+ }
+ if rawDescription, exists := function["description"]; exists {
+ var description string
+ if json.Unmarshal(rawDescription, &description) != nil {
+ return false, "invalid_tool_function_description"
+ }
+ }
+ var parameters map[string]json.RawMessage
+ if rawParameters, exists := function["parameters"]; !exists || json.Unmarshal(rawParameters, ¶meters) != nil || parameters == nil {
+ return false, "invalid_tool_function_parameters"
+ }
+ if rawStrict, exists := function["strict"]; exists {
+ var strict bool
+ if json.Unmarshal(rawStrict, &strict) != nil {
+ return false, "invalid_tool_function_strict"
+ }
+ }
+ }
+ return true, ""
+}
+
+func grokChatToolChoiceBridgeable(raw json.RawMessage) (bool, string) {
+ if strings.TrimSpace(string(raw)) == "null" {
+ return true, ""
+ }
+ var choice string
+ if json.Unmarshal(raw, &choice) != nil {
+ return false, "unsupported_tool_choice"
+ }
+ switch choice {
+ case "auto", "none", "required":
+ return true, ""
+ default:
+ return false, "unsupported_tool_choice"
+ }
+}
+
+func grokChatHasFunctionDeclarations(root map[string]json.RawMessage) bool {
+ for _, field := range []string{"tools", "functions"} {
+ raw, exists := root[field]
+ if !exists {
+ continue
+ }
+ var declarations []json.RawMessage
+ if json.Unmarshal(raw, &declarations) == nil && len(declarations) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func grokChatMessageFieldsBridgeable(message map[string]json.RawMessage, allowedFields ...string) (bool, string) {
+ allowed := make(map[string]struct{}, len(allowedFields))
+ for _, field := range allowedFields {
+ allowed[field] = struct{}{}
+ }
+ for field := range message {
+ if _, ok := allowed[field]; !ok {
+ return false, "unsafe_message_field_" + field
+ }
+ }
+ return true, ""
+}
+
+func grokChatRequiredMessageContentBridgeable(raw json.RawMessage) (bool, string) {
+ var content string
+ if json.Unmarshal(raw, &content) == nil {
+ if strings.TrimSpace(content) == "" {
+ return false, "empty_message_content"
+ }
+ return true, ""
+ }
+ // Structured content: only allow arrays whose parts are text or
+ // image_url. These are losslessly convertible to Responses input_text/
+ // input_image parts, so the bridge preserves Chat Completions semantics.
+ return grokChatStructuredContentBridgeable(raw)
+}
+
+func grokChatAssistantToolCallsBridgeable(raw json.RawMessage) (int, string) {
+ if strings.TrimSpace(string(raw)) == "null" {
+ return 0, ""
+ }
+ var calls []map[string]json.RawMessage
+ if json.Unmarshal(raw, &calls) != nil {
+ return 0, "invalid_tool_calls"
+ }
+ for _, call := range calls {
+ if call == nil {
+ return 0, "invalid_tool_call"
+ }
+ for field := range call {
+ switch field {
+ case "id", "type", "function":
+ default:
+ return 0, "unsafe_tool_call_field_" + field
+ }
+ }
+ var callID string
+ if rawID, exists := call["id"]; !exists || json.Unmarshal(rawID, &callID) != nil || strings.TrimSpace(callID) == "" {
+ return 0, "invalid_tool_call_id"
+ }
+ var callType string
+ if rawType, exists := call["type"]; !exists || json.Unmarshal(rawType, &callType) != nil || callType != "function" {
+ return 0, "unsupported_tool_call_type"
+ }
+ var function map[string]json.RawMessage
+ if rawFunction, exists := call["function"]; !exists || json.Unmarshal(rawFunction, &function) != nil || function == nil {
+ return 0, "invalid_tool_call_function"
+ }
+ for field := range function {
+ if field != "name" && field != "arguments" {
+ return 0, "unsafe_tool_call_function_field_" + field
+ }
+ }
+ var name string
+ if rawName, exists := function["name"]; !exists || json.Unmarshal(rawName, &name) != nil || strings.TrimSpace(name) == "" {
+ return 0, "invalid_tool_call_function_name"
+ }
+ var arguments string
+ if rawArguments, exists := function["arguments"]; !exists || json.Unmarshal(rawArguments, &arguments) != nil || !json.Valid([]byte(arguments)) {
+ return 0, "invalid_tool_call_arguments"
+ }
+ }
+ return len(calls), ""
+}
+
func grokChatStructuredContentBridgeable(raw json.RawMessage) (bool, string) {
var parts []map[string]json.RawMessage
if err := json.Unmarshal(raw, &parts); err != nil {
@@ -199,14 +411,6 @@ func grokChatStructuredContentBridgeable(raw json.RawMessage) (bool, string) {
return true, ""
}
-func grokChatNullOrEmptyArray(raw json.RawMessage) bool {
- if strings.TrimSpace(string(raw)) == "null" {
- return true
- }
- var values []json.RawMessage
- return json.Unmarshal(raw, &values) == nil && len(values) == 0
-}
-
func grokChatNullOrNone(raw json.RawMessage) bool {
if strings.TrimSpace(string(raw)) == "null" {
return true
@@ -215,14 +419,30 @@ func grokChatNullOrNone(raw json.RawMessage) bool {
return json.Unmarshal(raw, &value) == nil && strings.EqualFold(strings.TrimSpace(value), "none")
}
-func grokChatCacheIntentBody(body []byte) ([]byte, error) {
+func grokChatNullOrEmptyArray(raw json.RawMessage) bool {
+ if strings.TrimSpace(string(raw)) == "null" {
+ return true
+ }
+ var values []json.RawMessage
+ return json.Unmarshal(raw, &values) == nil && len(values) == 0
+}
+
+func grokChatResponsesCacheIntentBody(body []byte) ([]byte, error) {
+ // An empty Chat tools array is omitted by the Responses converter. In that
+ // case auto/none is also a semantic no-op and must not suppress the normal
+ // tool-free cache route. Non-empty converted tools are always kept intact.
+ if gjson.GetBytes(body, "tools").Exists() {
+ return append([]byte(nil), body...), nil
+ }
+ choice := gjson.GetBytes(body, "tool_choice")
+ if !choice.Exists() || choice.Type != gjson.String || (choice.String() != "auto" && choice.String() != "none") {
+ return append([]byte(nil), body...), nil
+ }
var root map[string]json.RawMessage
if err := json.Unmarshal(body, &root); err != nil {
return nil, err
}
- for _, field := range []string{"tools", "tool_choice", "functions", "function_call"} {
- delete(root, field)
- }
+ delete(root, "tool_choice")
return json.Marshal(root)
}
@@ -277,18 +497,25 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
if err != nil {
return nil, fmt.Errorf("marshal grok responses bridge request: %w", err)
}
+ // Preserve the converted Responses intent before Grok capability
+ // sanitization. Cache routing must see the actual client function tools,
+ // not the nested Chat Completions declarations and not a tool-free copy.
+ intentBody, err := grokChatResponsesCacheIntentBody(responsesBody)
+ if err != nil {
+ return nil, fmt.Errorf("normalize grok responses bridge cache intent: %w", err)
+ }
responsesBody, err = patchGrokResponsesBody(responsesBody, upstreamModel)
if err != nil {
return nil, fmt.Errorf("patch grok responses bridge request: %w", err)
}
- intentBody, err := grokChatCacheIntentBody(body)
- if err != nil {
- return nil, fmt.Errorf("normalize grok responses bridge tool intent: %w", err)
- }
responsesBody, err = applyGrokResponsesCacheIdentity(responsesBody, intentBody, cacheIdentity, true)
if err != nil {
return nil, fmt.Errorf("apply grok responses bridge cache identity: %w", err)
}
+ responsesBody, err = applyGrokFreeRequestToolCacheRoute(c, responsesBody, intentBody, account, cacheIdentity)
+ if err != nil {
+ return nil, fmt.Errorf("apply grok responses bridge function-tool cache route: %w", err)
+ }
updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, responsesBody)
if policyErr != nil {
diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go
index 86fc45e952..6e39bae15d 100644
--- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go
+++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go
@@ -75,15 +75,60 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
reason: "empty_message_content",
},
{
- name: "function tools fall back",
- body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`,
- reason: "unsupported_tools",
+ name: "function tools bridge",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"},"strict":false}}]}`,
+ want: true,
},
{
- name: "automatic tool choice falls back",
- body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`,
+ name: "legacy functions fall back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"functions":[{"name":"lookup","parameters":{"type":"object"}}]}`,
+ reason: "unsupported_functions",
+ },
+ {
+ name: "automatic tool choice bridges",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`,
+ want: true,
+ },
+ {
+ name: "required tool choice without tools falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"required"}`,
+ reason: "required_tool_choice_without_tools",
+ },
+ {
+ name: "tool history bridges",
+ body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":true}`,
+ want: true,
+ },
+ {
+ name: "unknown tool type falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
+ reason: "unsupported_tool_type",
+ },
+ {
+ name: "missing tool schema falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`,
+ reason: "invalid_tool_function_parameters",
+ },
+ {
+ name: "named tool choice falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}}}`,
reason: "unsupported_tool_choice",
},
+ {
+ name: "invalid tool call arguments fall back",
+ body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{"}}]}]}`,
+ reason: "invalid_tool_call_arguments",
+ },
+ {
+ name: "tool result without call id falls back",
+ body: `{"model":"grok","messages":[{"role":"tool","content":"ok"}]}`,
+ reason: "invalid_tool_call_id",
+ },
+ {
+ name: "non boolean parallel tool calls falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"parallel_tool_calls":"true"}`,
+ reason: "invalid_parallel_tool_calls",
+ },
{
name: "reasoning effort falls back because conversion adds summary",
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`,
@@ -100,9 +145,9 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
reason: "empty_message_content",
},
{
- name: "tool history falls back",
+ name: "empty tool history falls back",
body: `{"model":"grok","messages":[{"role":"assistant","content":"","tool_calls":[]}]}`,
- reason: "unsafe_message_field_tool_calls",
+ reason: "empty_message_content",
},
{
name: "unknown field falls back",
@@ -183,6 +228,64 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing.
require.NotNil(t, repo.updates[account.ID][grokQuotaSnapshotExtraKey])
}
+func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`)
+ body := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
+ c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
+ c.Set("api_key", &APIKey{ID: 7151})
+
+ account := grokChatBridgeTestAccount(715)
+ account.Credentials["subscription_tier"] = "free"
+ repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
+ accountsByID: map[int64]*Account{account.ID: account},
+ }}
+ upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae", 8192)}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ grokTokenProvider: NewGrokTokenProvider(repo, nil),
+ accountRepo: repo,
+ }
+
+ firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5")
+ extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
+ require.NotEmpty(t, firstTurnIdentity)
+ require.Equal(t, firstTurnIdentity, extendedTurnIdentity)
+
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
+ require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
+ require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
+ require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader))
+
+ tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
+ require.Len(t, tools, 3)
+ require.Equal(t, "function", tools[0].Get("type").String())
+ require.Equal(t, "lookup", tools[0].Get("name").String())
+ require.Equal(t, "Lookup a value", tools[0].Get("description").String())
+ require.Equal(t, "string", tools[0].Get("parameters.properties.key.type").String())
+ require.True(t, tools[0].Get("strict").Exists())
+ require.False(t, tools[0].Get("strict").Bool())
+ require.Equal(t, "web_search", tools[1].Get("type").String())
+ require.Equal(t, "x_search", tools[2].Get("type").String())
+ require.Equal(t, "auto", gjson.GetBytes(upstream.lastBody, "tool_choice").String())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
+
+ require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
+ require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.2.call_id").String())
+ require.Equal(t, "lookup", gjson.GetBytes(upstream.lastBody, "input.2.name").String())
+ require.Equal(t, `{"key":"alpha"}`, gjson.GetBytes(upstream.lastBody, "input.2.arguments").String())
+ require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
+ require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.3.call_id").String())
+ require.Equal(t, `{"value":"ok"}`, gjson.GetBytes(upstream.lastBody, "input.3.output").String())
+}
+
func TestForwardGrokChatViaResponsesStreamingPropagatesCachedUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
From 9d79b42729ba1ff8e5070c59154ead3cb03b3b14 Mon Sep 17 00:00:00 2001
From: superman2003 <2112076433zcr@gmail.com>
Date: Sun, 19 Jul 2026 14:42:06 +0800
Subject: [PATCH 4/7] feat(admin): expose Grok client tool cache opt-in
---
.../components/account/EditAccountModal.vue | 36 +++++++++++
.../EditAccountModal.grokUpstream.spec.ts | 64 ++++++++++++++++++-
.../src/i18n/locales/en/admin/accounts.ts | 4 ++
.../src/i18n/locales/zh/admin/accounts.ts | 4 ++
4 files changed, 106 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index c61ebf6050..12afc93a11 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -428,6 +428,26 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.grokClientToolCache.hint') }}
+
+
+
+
+
+
([])
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
const MAX_POOL_MODE_RETRY_COUNT = 10
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
+const GROK_CLIENT_TOOL_CACHE_EXTRA_KEY = 'grok_client_tool_cache_enabled'
const poolModeEnabled = ref(false)
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
const poolModeRetryStatusCodesInput = ref('')
@@ -2747,6 +2768,7 @@ const headerOverrideCapable = computed(
// Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响)
const grokOAuthCustomBaseUrlEnabled = ref(false)
const grokOAuthBaseUrl = ref('')
+const grokClientToolCacheEnabled = ref(false)
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
@@ -3402,6 +3424,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制)
grokOAuthCustomBaseUrlEnabled.value = false
grokOAuthBaseUrl.value = ''
+ grokClientToolCacheEnabled.value =
+ newAccount.platform === 'grok' &&
+ newAccount.type === 'oauth' &&
+ newAccount.extra?.[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] === true
if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) {
const grokCreds = newAccount.credentials as Record
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
@@ -4288,6 +4314,16 @@ const handleSubmit = async () => {
applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
updatePayload.credentials = newCredentials
+
+ const newExtra: Record = {
+ ...((props.account.extra as Record) || {})
+ }
+ if (grokClientToolCacheEnabled.value) {
+ newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] = true
+ } else {
+ delete newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY]
+ }
+ updatePayload.extra = newExtra
}
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
index 310a370889..068d7ac1a4 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
@@ -67,7 +67,10 @@ const BaseDialogStub = defineComponent({
template: '
'
})
-function buildGrokOAuthAccount(credentials: Record = {}) {
+function buildGrokOAuthAccount(
+ credentials: Record = {},
+ extra: Record = {}
+) {
return {
id: 5,
name: 'Grok OAuth',
@@ -80,7 +83,7 @@ function buildGrokOAuthAccount(credentials: Record = {}) {
...credentials
},
credentials_status: { has_access_token: true, has_refresh_token: true },
- extra: {},
+ extra,
proxy_id: null,
concurrency: 1,
priority: 1,
@@ -225,4 +228,61 @@ describe('EditAccountModal Grok OAuth upstream config', () => {
'x-xai-token-auth': 'xai-grok-cli'
})
})
+
+ it('shows the client-tool cache switch only for Grok OAuth accounts', () => {
+ const grokOAuthWrapper = mountModal(buildGrokOAuthAccount())
+ expect(grokOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(true)
+
+ const grokAPIKeyWrapper = mountModal({
+ ...buildGrokOAuthAccount(),
+ type: 'apikey',
+ credentials: { api_key: 'xai-test', base_url: 'https://api.x.ai/v1' }
+ })
+ expect(grokAPIKeyWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false)
+
+ const openAIOAuthWrapper = mountModal({
+ ...buildGrokOAuthAccount(),
+ platform: 'openai'
+ })
+ expect(openAIOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false)
+ })
+
+ it('loads and disables client-tool caching while preserving unrelated extra fields', async () => {
+ const account = buildGrokOAuthAccount({}, {
+ grok_client_tool_cache_enabled: true,
+ 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('true')
+
+ await toggle.trigger('click')
+ 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?.custom_setting).toBe('keep-me')
+ expect(payload?.extra).not.toHaveProperty('grok_client_tool_cache_enabled')
+ })
+
+ it('enables client-tool caching while preserving unrelated extra fields', 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')
+
+ await toggle.trigger('click')
+ 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: true,
+ custom_setting: 'keep-me'
+ })
+ })
})
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 206c7c5172..772161f6c8 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -641,6 +641,10 @@ export default {
official: 'Official API'
}
},
+ 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.'
+ },
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
autoPause5hThreshold: '5h Usage Threshold (%)',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 48da50175d..9cfa1ee17c 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -734,6 +734,10 @@ export default {
official: '官方 API'
}
},
+ grokClientToolCache: {
+ title: '客户端工具缓存(可能改变自动工具选择)',
+ hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;请确认自动工具选择行为可接受后开启。'
+ },
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)',
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 5/7] 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"}]}`)
From c8ff478876ccdfc4be2041fee0456270f3a4c5e4 Mon Sep 17 00:00:00 2001
From: superman2003 <2112076433zcr@gmail.com>
Date: Sun, 19 Jul 2026 18:59:50 +0800
Subject: [PATCH 6/7] fix(grok): default client tool caching on for Free
accounts
---
.../service/openai_gateway_grok_cache.go | 43 ++++++--
.../service/openai_gateway_grok_cache_test.go | 103 +++++++++++++++---
.../components/account/EditAccountModal.vue | 18 +--
.../EditAccountModal.grokUpstream.spec.ts | 46 +++++++-
.../src/i18n/locales/en/admin/accounts.ts | 2 +-
.../src/i18n/locales/zh/admin/accounts.ts | 2 +-
6 files changed, 176 insertions(+), 38 deletions(-)
diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go
index ab81c16080..9fb916cacd 100644
--- a/backend/internal/service/openai_gateway_grok_cache.go
+++ b/backend/internal/service/openai_gateway_grok_cache.go
@@ -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
diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go
index d0d33ffb90..90155aadc6 100644
--- a/backend/internal/service/openai_gateway_grok_cache_test.go
+++ b/backend/internal/service/openai_gateway_grok_cache_test.go
@@ -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
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 12afc93a11..df4838fd31 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -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
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
@@ -4318,11 +4324,9 @@ const handleSubmit = async () => {
const newExtra: Record = {
...((props.account.extra as Record) || {})
}
- 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
}
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
index 068d7ac1a4..c96cd86d0c 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
@@ -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')
+ })
})
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 772161f6c8..9352f04c35 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -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',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 9cfa1ee17c..d493b7aeff 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -736,7 +736,7 @@ export default {
},
grokClientToolCache: {
title: '客户端工具缓存(可能改变自动工具选择)',
- hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;请确认自动工具选择行为可接受后开启。'
+ hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。'
},
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
From 3dcf412b6514a617fa7d709a1d3ddf1265a2e1f9 Mon Sep 17 00:00:00 2001
From: superman2003 <2112076433zcr@gmail.com>
Date: Sun, 19 Jul 2026 19:00:08 +0800
Subject: [PATCH 7/7] fix(grok): preserve Trae cache routing across turns
---
.../openai_gateway_grok_chat_bridge.go | 65 +++++++++++--
.../openai_gateway_grok_chat_bridge_test.go | 97 +++++++++++++++++++
2 files changed, 156 insertions(+), 6 deletions(-)
diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go
index baf1d8a592..89ebb4049e 100644
--- a/backend/internal/service/openai_gateway_grok_chat_bridge.go
+++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go
@@ -22,18 +22,23 @@ const (
var grokChatResponsesBridgeTopLevelFields = map[string]struct{}{
"model": {},
"messages": {},
+ "instructions": {},
"stream": {},
"stream_options": {},
"max_tokens": {},
"max_completion_tokens": {},
"temperature": {},
"top_p": {},
+ "stop": {},
+ "reasoning_effort": {},
"prompt_cache_key": {},
"tools": {},
"tool_choice": {},
"functions": {},
"function_call": {},
"parallel_tool_calls": {},
+ "response_format": {},
+ "service_tier": {},
}
// grokChatResponsesBridgeEligibility deliberately accepts only request shapes
@@ -46,11 +51,33 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
return false, "invalid_json"
}
+ // These fields have no effect when explicitly set to JSON null. Accepting
+ // that common SDK representation keeps the request on the bridge path,
+ // while non-null values remain unsupported because the Responses converter
+ // cannot preserve their Chat Completions semantics.
for _, field := range []string{"stop", "reasoning_effort"} {
- if _, exists := root[field]; exists {
+ if raw, exists := root[field]; exists && !grokChatJSONNull(raw) {
return false, "unsupported_" + field
}
}
+ if raw, exists := root["instructions"]; exists {
+ var instructions string
+ if !grokChatJSONNull(raw) && json.Unmarshal(raw, &instructions) != nil {
+ return false, "invalid_instructions"
+ }
+ }
+ if raw, exists := root["response_format"]; exists {
+ var responseFormat map[string]json.RawMessage
+ if !grokChatJSONNull(raw) && (json.Unmarshal(raw, &responseFormat) != nil || responseFormat == nil) {
+ return false, "invalid_response_format"
+ }
+ }
+ if raw, exists := root["service_tier"]; exists {
+ var serviceTier string
+ if !grokChatJSONNull(raw) && json.Unmarshal(raw, &serviceTier) != nil {
+ return false, "invalid_service_tier"
+ }
+ }
if raw, exists := root["tools"]; exists {
if ok, reason := grokChatFunctionDeclarationsBridgeable(raw); !ok {
return false, reason
@@ -161,9 +188,16 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
return false, reason
}
case "assistant":
- if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_calls"); !ok {
+ if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "reasoning_content", "tool_calls"); !ok {
return false, reason
}
+ reasoningContent := ""
+ if raw, exists := message["reasoning_content"]; exists {
+ if !grokChatJSONNull(raw) && json.Unmarshal(raw, &reasoningContent) != nil {
+ return false, "invalid_reasoning_content"
+ }
+ }
+ hasReasoningContent := strings.TrimSpace(reasoningContent) != ""
toolCallCount := 0
if raw, exists := message["tool_calls"]; exists {
var reason string
@@ -174,20 +208,25 @@ func grokChatResponsesBridgeEligibility(body []byte) (bool, string) {
}
raw, hasContent := message["content"]
if !hasContent || strings.TrimSpace(string(raw)) == "null" {
- if toolCallCount == 0 {
+ if toolCallCount == 0 && !hasReasoningContent {
return false, "non_text_message_content"
}
continue
}
var content string
if json.Unmarshal(raw, &content) == nil {
- if strings.TrimSpace(content) == "" && toolCallCount == 0 {
+ if strings.TrimSpace(content) == "" && toolCallCount == 0 && !hasReasoningContent {
return false, "empty_message_content"
}
continue
}
if ok, reason := grokChatStructuredContentBridgeable(raw); !ok {
- return false, reason
+ // The converter can still emit a standalone reasoning part when
+ // an otherwise empty content array accompanies reasoning_content.
+ // Do not broaden this exception to unsupported/malformed parts.
+ if !hasReasoningContent || reason != "empty_message_content" {
+ return false, reason
+ }
}
case "tool":
if ok, reason := grokChatMessageFieldsBridgeable(message, "role", "content", "tool_call_id"); !ok {
@@ -342,11 +381,17 @@ func grokChatAssistantToolCallsBridgeable(raw json.RawMessage) (int, string) {
}
for field := range call {
switch field {
- case "id", "type", "function":
+ case "id", "type", "function", "index":
default:
return 0, "unsafe_tool_call_field_" + field
}
}
+ if rawIndex, exists := call["index"]; exists {
+ var index *int
+ if json.Unmarshal(rawIndex, &index) != nil || (index != nil && *index < 0) {
+ return 0, "invalid_tool_call_index"
+ }
+ }
var callID string
if rawID, exists := call["id"]; !exists || json.Unmarshal(rawID, &callID) != nil || strings.TrimSpace(callID) == "" {
return 0, "invalid_tool_call_id"
@@ -419,6 +464,10 @@ func grokChatNullOrNone(raw json.RawMessage) bool {
return json.Unmarshal(raw, &value) == nil && strings.EqualFold(strings.TrimSpace(value), "none")
}
+func grokChatJSONNull(raw json.RawMessage) bool {
+ return strings.TrimSpace(string(raw)) == "null"
+}
+
func grokChatNullOrEmptyArray(raw json.RawMessage) bool {
if strings.TrimSpace(string(raw)) == "null" {
return true
@@ -488,6 +537,10 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
}
responsesReq.Model = upstreamModel
responsesReq.Stream = true
+ // Keep Chat and native Responses paths aligned for OpenAI-compatible
+ // service_tier aliases (for example, "fast" -> "priority"). Unknown
+ // values are omitted by the shared normalizer instead of reaching xAI.
+ normalizeResponsesRequestServiceTier(responsesReq)
// These fields are useful to Codex but are not needed by the Grok CLI
// protocol. Keep the bridge request as close as possible to native Grok.
responsesReq.Include = nil
diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go
index 6e39bae15d..188e38f702 100644
--- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go
+++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go
@@ -39,6 +39,31 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":true,"stream_options":{"include_usage":true},"max_completion_tokens":256,"temperature":0.2,"top_p":0.9,"prompt_cache_key":"session","tools":[],"functions":null,"tool_choice":"none"}`,
want: true,
},
+ {
+ name: "Trae compatible fields",
+ body: `{"model":"grok","messages":[{"role":"user","content":"return json"}],"instructions":"Be concise","response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null}`,
+ want: true,
+ },
+ {
+ name: "Trae nullable SDK fields",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":null,"response_format":null,"service_tier":null,"stop":null,"reasoning_effort":null}`,
+ want: true,
+ },
+ {
+ name: "invalid instructions fall back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":{"text":"invalid"}}`,
+ reason: "invalid_instructions",
+ },
+ {
+ name: "invalid response format falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"response_format":"json_object"}`,
+ reason: "invalid_response_format",
+ },
+ {
+ name: "invalid service tier falls back",
+ body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"service_tier":1}`,
+ reason: "invalid_service_tier",
+ },
{
name: "stop falls back",
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stop":"done"}`,
@@ -99,6 +124,26 @@ func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":true}`,
want: true,
},
+ {
+ name: "Trae reasoning and indexed tool history bridges",
+ body: `{"model":"grok","messages":[{"role":"assistant","content":null,"reasoning_content":"I should call lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
+ want: true,
+ },
+ {
+ name: "reasoning only assistant history bridges",
+ body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":"Prior reasoning"},{"role":"user","content":"continue"}]}`,
+ want: true,
+ },
+ {
+ name: "invalid reasoning content falls back",
+ body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":{"text":"invalid"}},{"role":"user","content":"continue"}]}`,
+ reason: "invalid_reasoning_content",
+ },
+ {
+ name: "negative tool call index falls back",
+ body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"index":-1,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{}"}}]}]}`,
+ reason: "invalid_tool_call_index",
+ },
{
name: "unknown tool type falls back",
body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
@@ -286,6 +331,58 @@ func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T)
require.Equal(t, `{"value":"ok"}`, gjson.GetBytes(upstream.lastBody, "input.3.output").String())
}
+func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`)
+ body := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"reasoning_content":"I should use lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
+ c.Set("api_key", &APIKey{ID: 7161})
+
+ account := grokChatBridgeTestAccount(716)
+ account.Credentials["subscription_tier"] = "free"
+ repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
+ accountsByID: map[int64]*Account{account.ID: account},
+ }}
+ upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae_compat", 12288)}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ grokTokenProvider: NewGrokTokenProvider(repo, nil),
+ accountRepo: repo,
+ }
+
+ firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5")
+ extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
+ require.NotEmpty(t, firstTurnIdentity)
+ require.Equal(t, firstTurnIdentity, extendedTurnIdentity)
+
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
+ require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
+ require.Equal(t, 12288, result.Usage.CacheReadInputTokens)
+ require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
+ require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader))
+ require.Equal(t, "Return concise JSON", gjson.GetBytes(upstream.lastBody, "instructions").String())
+ require.Equal(t, "json_object", gjson.GetBytes(upstream.lastBody, "text.format.type").String())
+ require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "stop").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning").Exists())
+ require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "I should use lookup")
+ require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
+ require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
+
+ tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
+ require.Len(t, tools, 3)
+ require.Equal(t, "lookup", 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())
+ require.Equal(t, int64(12288), gjson.Get(recorder.Body.String(), "usage.prompt_tokens_details.cached_tokens").Int())
+}
+
func TestForwardGrokChatViaResponsesStreamingPropagatesCachedUsage(t *testing.T) {
gin.SetMode(gin.TestMode)