| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- package service
- import (
- "encoding/json"
- "fmt"
- "net/http"
- "regexp"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- )
- const (
- grokConversationIDHeader = "X-Grok-Conv-Id"
- claudeCodeSessionHeader = "X-Claude-Code-Session-Id"
- grokClientToolCacheOptInHeader = "X-Sub2API-Grok-Client-Tool-Cache"
- grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
- grokFreeCacheDisabledToolChoice = "none"
- grokClientToolCacheOptInExtraKey = "grok_client_tool_cache_enabled"
- )
- // Claude Code metadata.user_id often ends with _session_<uuid>.
- var claudeCodeSessionSuffixPattern = regexp.MustCompile(`_session_([a-f0-9-]+)$`)
- // extractClaudeCodeSessionID resolves the Claude Code conversation id from
- // headers or Anthropic/OpenAI-compatible payload metadata.
- func extractClaudeCodeSessionID(c *gin.Context, body []byte) string {
- if c != nil {
- if seed := strings.TrimSpace(c.GetHeader(claudeCodeSessionHeader)); seed != "" {
- return seed
- }
- }
- return extractClaudeCodeSessionIDFromPayload(body)
- }
- func extractClaudeCodeSessionIDFromPayload(body []byte) string {
- if len(body) == 0 {
- return ""
- }
- userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String())
- if userID == "" {
- return ""
- }
- if matches := claudeCodeSessionSuffixPattern.FindStringSubmatch(userID); len(matches) >= 2 {
- return matches[1]
- }
- // Claude Code may embed JSON: {"session_id":"..."}
- if len(userID) > 0 && userID[0] == '{' {
- if sid := strings.TrimSpace(gjson.Get(userID, "session_id").String()); sid != "" {
- return sid
- }
- }
- return ""
- }
- // resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity
- // for xAI's server-side prompt cache. The returned value is safe to expose to
- // the upstream: it never contains the client's raw session identifier.
- //
- // A valid downstream API key is required. This intentionally fails closed on
- // internal probes and incomplete request contexts instead of creating a cache
- // identity that could be shared by unrelated tenants.
- func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstreamModel string) string {
- apiKeyID := getAPIKeyIDFromContext(c)
- if apiKeyID <= 0 {
- return ""
- }
- // /responses/compact rejects tool_choice and does not represent a normal
- // conversation turn. Keep both cache identity and Free-tier routing
- // augmentation out of this path.
- if isOpenAIResponsesCompactPath(c) {
- return ""
- }
- model := strings.ToLower(strings.TrimSpace(upstreamModel))
- if model == "" {
- return ""
- }
- seed := explicitGrokCacheSeed(c, body, explicitKey)
- if seed == "" {
- seed = deriveOpenAIStablePrefixSessionSeed(body)
- if seed == "" {
- // A model alone is too broad for cache routing. Preserve the
- // existing first-user-derived identity when no reusable prefix is
- // available so unrelated prompts do not share one tenant-wide key.
- seed = deriveOpenAIAnchoredContentSessionSeed(body)
- }
- }
- if seed == "" {
- return ""
- }
- // generateSessionUUID hashes the whole seed before formatting it as a UUID.
- // Include a versioned namespace so this identity cannot collide with other
- // upstream session identifiers derived by sub2api.
- isolatedSeed := fmt.Sprintf("grok-prompt-cache:v1:%d:%s:%s", apiKeyID, model, seed)
- return generateSessionUUID(isolatedSeed)
- }
- func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) string {
- // Claude Code session is the most stable multi-turn identity for
- // /v1/messages → Grok bridges. Prefer it over generic session headers so
- // prompt cache routing follows the gateway's existing cache affinity rules.
- seed := extractClaudeCodeSessionID(c, body)
- if seed == "" {
- seed = explicitOpenAIHeaderSessionID(c)
- }
- // Client-declared prompt_cache_key outranks X-Grok-Conv-Id. The
- // grok-build CLI sets this field on recap-style side-calls
- // (turn-summary/title-refresh) to the *parent* session id so the
- // side-call shares the main turn's server-side cache prefix. Its
- // X-Grok-Conv-Id header, in contrast, carries a fresh per-call label
- // ("turn-summary-<uuid>"); preferring the header there fragments the
- // cache identity per side-call and forces a full-price replay of the
- // entire conversation (~300K+ tokens each time). The body field is the
- // official xAI cache-routing signal — respect it when present.
- if seed == "" && len(body) > 0 {
- seed = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
- }
- if seed == "" && c != nil {
- seed = strings.TrimSpace(c.GetHeader(grokConversationIDHeader))
- }
- if seed == "" {
- seed = strings.TrimSpace(explicitKey)
- }
- // previous_response_id is last-resort: multi-turn Responses without an
- // explicit session still share one cache identity (model is already in the
- // isolated seed). Message ids are rejected by the seed helper.
- if seed == "" && len(body) > 0 {
- seed = grokPreviousResponseSessionSeed(body)
- }
- return seed
- }
- func isGrokRequestContext(c *gin.Context) bool {
- if c == nil {
- return false
- }
- if c.Request != nil {
- if platform, ok := ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
- return platform == PlatformGrok
- }
- }
- v, exists := c.Get("api_key")
- if !exists {
- return false
- }
- apiKey, ok := v.(*APIKey)
- return ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == PlatformGrok
- }
- // applyGrokResponsesCacheIdentity writes the cache routing identity into an
- // xAI Responses request. Existing client values are deliberately replaced by
- // the tenant-isolated value to prevent collisions on shared OAuth accounts.
- //
- // 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 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 == "" {
- if gjson.GetBytes(body, "prompt_cache_key").Exists() {
- return sjson.DeleteBytes(body, "prompt_cache_key")
- }
- return body, nil
- }
- out, err := sjson.SetBytes(body, "prompt_cache_key", identity)
- if err != nil {
- return nil, err
- }
- if !injectFreeTierTools {
- return out, nil
- }
- // 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 hasGrokResponsesToolIntent(intentSourceBody) {
- return out, nil
- }
- out, err = sjson.SetRawBytes(out, "tools", []byte(grokFreeCacheNativeToolsJSON))
- if err != nil {
- return nil, err
- }
- 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 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, _ := grokClientToolCacheAccountPolicy(account)
- 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, accountPolicyExplicit := grokClientToolCacheAccountPolicy(account)
- 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 && !accountPolicyExplicit && !requestOptOut && isGrokClaudeDesktopResponsesCacheRequest(c) {
- allowPureClientTools = true
- }
- // A function merely named web_search/x_search is still a client function.
- // 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
- // 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
- }
- intentTools := gjson.GetBytes(intentSourceBody, "tools")
- intentToolChoice := gjson.GetBytes(intentSourceBody, "tool_choice")
- if !isGrokFreeCacheFunctionToolIntent(intentTools, intentToolChoice) {
- return body, nil
- }
- 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)
- }
- // isKnownGrokFreeAccount recognizes free-tier Grok accounts, used for
- // Free cache routing / media free_tier blocks (broader than soft-gate).
- // Soft-gate uses isExplicitGrokFreeOAuthAccount (exact "free" only).
- func isKnownGrokFreeAccount(account *Account) bool {
- if account == nil || !account.IsGrokOAuth() {
- return false
- }
- // Live access-token JWT wins over stale billing/credential snapshots
- // so a downgrade to free is visible as soon as the AT is refreshed.
- if jwtTier := xai.SubscriptionTierFromJWT(account.GetCredential("access_token")); jwtTier != "" {
- return isGrokFreeSubscriptionTier(jwtTier)
- }
- freeSignal := false
- paidSignal := false
- inferredFreeSignal := false
- if billing, err := grokBillingSnapshotFromExtra(account.Extra); err == nil && billing != nil {
- if tier := strings.TrimSpace(billing.Plan); tier != "" {
- if isGrokFreeSubscriptionTier(tier) {
- freeSignal = true
- } else if !isGrokUnknownSubscriptionTier(tier) {
- paidSignal = true
- }
- }
- // Usage % or a monthly dollar cap is evidence of a paid plan.
- if billing.UsagePercent != nil || billing.UsedPercent != nil ||
- (billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0) {
- paidSignal = true
- }
- // Empty plan + successful monthly observation → inferred free (no paid plan/limit).
- if strings.TrimSpace(billing.MonthlyUpdatedAt) != "" ||
- (billing.StatusCode >= http.StatusOK && billing.StatusCode < http.StatusMultipleChoices &&
- !billing.Partial && len(billing.FailedWindows) == 0) {
- inferredFreeSignal = true
- }
- }
- if snapshot, err := grokQuotaSnapshotFromExtra(account.Extra); err == nil && snapshot != nil {
- if tier := strings.TrimSpace(snapshot.SubscriptionTier); tier != "" {
- if isGrokFreeSubscriptionTier(tier) {
- freeSignal = true
- } else if !isGrokUnknownSubscriptionTier(tier) {
- paidSignal = true
- }
- }
- if snapshot.Tokens != nil && snapshot.Tokens.Limit != nil &&
- xai.IsGrokFreeRolling24hTokenLimit(*snapshot.Tokens.Limit) {
- inferredFreeSignal = true
- }
- }
- // Only credentials subscription_tier is authoritative here (not plan_type / extra keys).
- if tier := strings.TrimSpace(account.GetCredential("subscription_tier")); tier != "" {
- if isGrokFreeSubscriptionTier(tier) {
- freeSignal = true
- } else if !isGrokUnknownSubscriptionTier(tier) {
- paidSignal = true
- }
- }
- // Explicit paid evidence always wins over an inferred Free signal.
- return !paidSignal && (freeSignal || inferredFreeSignal)
- }
- func isGrokFreeSubscriptionTier(tier string) bool {
- switch xai.NormalizeSubscriptionTier(tier) {
- case "free", "x_basic":
- return true
- default:
- return false
- }
- }
- func isGrokUnknownSubscriptionTier(tier string) bool {
- switch strings.ToLower(strings.TrimSpace(tier)) {
- case "", "unknown", "n/a", "none":
- return true
- default:
- return false
- }
- }
- func isGrokFreeCacheFunctionToolIntent(tools, toolChoice gjson.Result) bool {
- if !tools.IsArray() {
- return false
- }
- items := tools.Array()
- if len(items) == 0 {
- return false
- }
- for _, tool := range items {
- if !tool.IsObject() {
- return false
- }
- 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
- }
- 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
- }
- items := tools.Array()
- 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)
- hasCompanionTool := false
- for _, tool := range items {
- toolType := strings.TrimSpace(tool.Get("type").String())
- switch toolType {
- case "function":
- 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") && allowFunctionSearch {
- 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
- if allowPureClientTools {
- hasCompanionTool = true
- }
- continue
- }
- 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] {
- continue
- }
- merged = append(merged, json.RawMessage(tool.Raw))
- present[toolType] = true
- default:
- if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
- return body, nil
- }
- hasCompanionTool = true
- merged = append(merged, json.RawMessage(tool.Raw))
- }
- }
- 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 !allowPureClientTools && !present["web_search"] && !present["x_search"] {
- return body, nil
- }
- for _, toolType := range []string{"web_search", "x_search"} {
- if present[toolType] {
- continue
- }
- raw, err := json.Marshal(map[string]string{"type": toolType})
- if err != nil {
- return nil, err
- }
- merged = append(merged, raw)
- }
- encoded, err := json.Marshal(merged)
- if err != nil {
- return nil, err
- }
- return sjson.SetRawBytes(body, "tools", encoded)
- }
- // applyGrokCacheHeaders applies the documented Chat Completions conversation
- // routing header. The request is built from a fresh header map, so client
- // supplied x-grok headers cannot override this server-derived value.
- func applyGrokCacheHeaders(headers http.Header, identity string) {
- if headers == nil {
- return
- }
- identity = strings.TrimSpace(identity)
- if identity == "" {
- headers.Del(grokConversationIDHeader)
- return
- }
- headers.Set(grokConversationIDHeader, identity)
- }
- // stripGrokChatPromptCacheKey removes the Responses-only body field after it
- // has been used as an identity seed. Chat Completions routes cache by header.
- func stripGrokChatPromptCacheKey(body []byte) ([]byte, error) {
- if !gjson.GetBytes(body, "prompt_cache_key").Exists() {
- return body, nil
- }
- return sjson.DeleteBytes(body, "prompt_cache_key")
- }
|