diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 8c18c9e5e..c09302933 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -61,6 +61,12 @@ func (s *OpenAIGatewayService) forwardGrokResponses( 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) + if err != nil { + return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) + } token, _, err := s.getRequestCredential(ctx, c, account) if err != nil { diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index dac9e975e..9b6436e43 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -102,8 +102,8 @@ func isGrokRequestContext(c *gin.Context) bool { // Free OAuth requests without native search tools are routed by xAI to the // non-cacheable build-free model. For otherwise tool-free requests, add the // native tools with tool_choice=none: this selects the cache-capable tier -// without allowing an actual search. Explicit client tools are handled by the -// narrower Messages-only mixed-tools policy below. +// without allowing an actual search. Explicit client function tools are handled by +// applyGrokFreeMessagesFunctionToolCacheRoute (Messages bridge and native Responses). func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity string, injectFreeTierTools bool) ([]byte, error) { identity = strings.TrimSpace(identity) if identity == "" { @@ -263,17 +263,35 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) { toolType := strings.TrimSpace(tool.Get("type").String()) switch toolType { case "function": - if !tool.IsObject() || strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() { + name := strings.TrimSpace(tool.Get("name").String()) + if !tool.IsObject() || name == "" || tool.Get("function").Exists() { return body, nil } + // 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 present[name] { + continue + } + raw, err := json.Marshal(map[string]string{"type": name}) + if err != nil { + return nil, err + } + merged = append(merged, raw) + present[name] = true + continue + } hasFunction = true + merged = append(merged, json.RawMessage(tool.Raw)) case "web_search", "x_search": - // Native tools may already be present when this helper is retried. + if present[toolType] { + continue + } + merged = append(merged, json.RawMessage(tool.Raw)) + present[toolType] = true default: return body, nil } - merged = append(merged, json.RawMessage(tool.Raw)) - present[toolType] = true } if !hasFunction { return body, nil diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 09b3dc062..cc7b30798 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -201,6 +201,11 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( releaseUpstreamCtx() return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } + body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity) + if err != nil { + releaseUpstreamCtx() + return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) + } upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg) } else { upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)