openai_gateway_grok_cache.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "regexp"
  7. "strings"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  9. "github.com/gin-gonic/gin"
  10. "github.com/tidwall/gjson"
  11. "github.com/tidwall/sjson"
  12. )
  13. const (
  14. grokConversationIDHeader = "X-Grok-Conv-Id"
  15. claudeCodeSessionHeader = "X-Claude-Code-Session-Id"
  16. grokClientToolCacheOptInHeader = "X-Sub2API-Grok-Client-Tool-Cache"
  17. grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
  18. grokFreeCacheDisabledToolChoice = "none"
  19. grokClientToolCacheOptInExtraKey = "grok_client_tool_cache_enabled"
  20. )
  21. // Claude Code metadata.user_id often ends with _session_<uuid>.
  22. var claudeCodeSessionSuffixPattern = regexp.MustCompile(`_session_([a-f0-9-]+)$`)
  23. // extractClaudeCodeSessionID resolves the Claude Code conversation id from
  24. // headers or Anthropic/OpenAI-compatible payload metadata.
  25. func extractClaudeCodeSessionID(c *gin.Context, body []byte) string {
  26. if c != nil {
  27. if seed := strings.TrimSpace(c.GetHeader(claudeCodeSessionHeader)); seed != "" {
  28. return seed
  29. }
  30. }
  31. return extractClaudeCodeSessionIDFromPayload(body)
  32. }
  33. func extractClaudeCodeSessionIDFromPayload(body []byte) string {
  34. if len(body) == 0 {
  35. return ""
  36. }
  37. userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String())
  38. if userID == "" {
  39. return ""
  40. }
  41. if matches := claudeCodeSessionSuffixPattern.FindStringSubmatch(userID); len(matches) >= 2 {
  42. return matches[1]
  43. }
  44. // Claude Code may embed JSON: {"session_id":"..."}
  45. if len(userID) > 0 && userID[0] == '{' {
  46. if sid := strings.TrimSpace(gjson.Get(userID, "session_id").String()); sid != "" {
  47. return sid
  48. }
  49. }
  50. return ""
  51. }
  52. // resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity
  53. // for xAI's server-side prompt cache. The returned value is safe to expose to
  54. // the upstream: it never contains the client's raw session identifier.
  55. //
  56. // A valid downstream API key is required. This intentionally fails closed on
  57. // internal probes and incomplete request contexts instead of creating a cache
  58. // identity that could be shared by unrelated tenants.
  59. func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstreamModel string) string {
  60. apiKeyID := getAPIKeyIDFromContext(c)
  61. if apiKeyID <= 0 {
  62. return ""
  63. }
  64. // /responses/compact rejects tool_choice and does not represent a normal
  65. // conversation turn. Keep both cache identity and Free-tier routing
  66. // augmentation out of this path.
  67. if isOpenAIResponsesCompactPath(c) {
  68. return ""
  69. }
  70. model := strings.ToLower(strings.TrimSpace(upstreamModel))
  71. if model == "" {
  72. return ""
  73. }
  74. seed := explicitGrokCacheSeed(c, body, explicitKey)
  75. if seed == "" {
  76. seed = deriveOpenAIStablePrefixSessionSeed(body)
  77. if seed == "" {
  78. // A model alone is too broad for cache routing. Preserve the
  79. // existing first-user-derived identity when no reusable prefix is
  80. // available so unrelated prompts do not share one tenant-wide key.
  81. seed = deriveOpenAIAnchoredContentSessionSeed(body)
  82. }
  83. }
  84. if seed == "" {
  85. return ""
  86. }
  87. // generateSessionUUID hashes the whole seed before formatting it as a UUID.
  88. // Include a versioned namespace so this identity cannot collide with other
  89. // upstream session identifiers derived by sub2api.
  90. isolatedSeed := fmt.Sprintf("grok-prompt-cache:v1:%d:%s:%s", apiKeyID, model, seed)
  91. return generateSessionUUID(isolatedSeed)
  92. }
  93. func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) string {
  94. // Claude Code session is the most stable multi-turn identity for
  95. // /v1/messages → Grok bridges. Prefer it over generic session headers so
  96. // prompt cache routing follows the gateway's existing cache affinity rules.
  97. seed := extractClaudeCodeSessionID(c, body)
  98. if seed == "" {
  99. seed = explicitOpenAIHeaderSessionID(c)
  100. }
  101. // Client-declared prompt_cache_key outranks X-Grok-Conv-Id. The
  102. // grok-build CLI sets this field on recap-style side-calls
  103. // (turn-summary/title-refresh) to the *parent* session id so the
  104. // side-call shares the main turn's server-side cache prefix. Its
  105. // X-Grok-Conv-Id header, in contrast, carries a fresh per-call label
  106. // ("turn-summary-<uuid>"); preferring the header there fragments the
  107. // cache identity per side-call and forces a full-price replay of the
  108. // entire conversation (~300K+ tokens each time). The body field is the
  109. // official xAI cache-routing signal — respect it when present.
  110. if seed == "" && len(body) > 0 {
  111. seed = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
  112. }
  113. if seed == "" && c != nil {
  114. seed = strings.TrimSpace(c.GetHeader(grokConversationIDHeader))
  115. }
  116. if seed == "" {
  117. seed = strings.TrimSpace(explicitKey)
  118. }
  119. // previous_response_id is last-resort: multi-turn Responses without an
  120. // explicit session still share one cache identity (model is already in the
  121. // isolated seed). Message ids are rejected by the seed helper.
  122. if seed == "" && len(body) > 0 {
  123. seed = grokPreviousResponseSessionSeed(body)
  124. }
  125. return seed
  126. }
  127. func isGrokRequestContext(c *gin.Context) bool {
  128. if c == nil {
  129. return false
  130. }
  131. if c.Request != nil {
  132. if platform, ok := ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
  133. return platform == PlatformGrok
  134. }
  135. }
  136. v, exists := c.Get("api_key")
  137. if !exists {
  138. return false
  139. }
  140. apiKey, ok := v.(*APIKey)
  141. return ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == PlatformGrok
  142. }
  143. // applyGrokResponsesCacheIdentity writes the cache routing identity into an
  144. // xAI Responses request. Existing client values are deliberately replaced by
  145. // the tenant-isolated value to prevent collisions on shared OAuth accounts.
  146. //
  147. // Free OAuth requests without native search tools are routed by xAI to the
  148. // non-cacheable build-free model. For otherwise tool-free requests, add the
  149. // native tools with tool_choice=none: this selects the cache-capable tier
  150. // without allowing an actual search. Explicit client function tools are handled by
  151. // applyGrokFreeMessagesFunctionToolCacheRoute (Messages bridge and native Responses).
  152. func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity string, injectFreeTierTools bool) ([]byte, error) {
  153. identity = strings.TrimSpace(identity)
  154. if identity == "" {
  155. if gjson.GetBytes(body, "prompt_cache_key").Exists() {
  156. return sjson.DeleteBytes(body, "prompt_cache_key")
  157. }
  158. return body, nil
  159. }
  160. out, err := sjson.SetBytes(body, "prompt_cache_key", identity)
  161. if err != nil {
  162. return nil, err
  163. }
  164. if !injectFreeTierTools {
  165. return out, nil
  166. }
  167. // Inspect the pre-sanitization source. patchGrokResponsesBody may remove an
  168. // unsupported client tool and its tool_choice; that must not turn an
  169. // explicit client tool intent into an eligible native-tool request.
  170. if hasGrokResponsesToolIntent(intentSourceBody) {
  171. return out, nil
  172. }
  173. out, err = sjson.SetRawBytes(out, "tools", []byte(grokFreeCacheNativeToolsJSON))
  174. if err != nil {
  175. return nil, err
  176. }
  177. return sjson.SetBytes(out, "tool_choice", grokFreeCacheDisabledToolChoice)
  178. }
  179. func hasGrokResponsesToolIntent(body []byte) bool {
  180. if gjson.GetBytes(body, "tools").Exists() || gjson.GetBytes(body, "tool_choice").Exists() {
  181. return true
  182. }
  183. input := gjson.GetBytes(body, "input")
  184. if !input.IsArray() {
  185. return false
  186. }
  187. for _, item := range input.Array() {
  188. if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
  189. continue
  190. }
  191. tools := item.Get("tools")
  192. if !tools.Exists() || !tools.IsArray() || len(tools.Array()) > 0 {
  193. return true
  194. }
  195. }
  196. return false
  197. }
  198. // applyGrokFreeMessagesFunctionToolCacheRoute enables xAI's cache-capable
  199. // mixed-tools route only for known Free accounts. Pure client tools default to
  200. // the cache-capable route so an intermediate sub2api does not need to preserve
  201. // client-specific opt-in headers. Operators can explicitly disable this per
  202. // account when native search tools would change the desired behavior (#4486).
  203. func applyGrokFreeMessagesFunctionToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) {
  204. allowPureClientTools, _ := grokClientToolCacheAccountPolicy(account)
  205. return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, true)
  206. }
  207. // applyGrokFreeRequestToolCacheRoute also accepts a request-scoped opt-in. The
  208. // sub2api header is consumed locally because buildGrokResponsesRequest only
  209. // forwards the explicitly supported OpenAI-Beta header from downstream.
  210. func applyGrokFreeRequestToolCacheRoute(c *gin.Context, body, intentSourceBody []byte, account *Account, cacheIdentity string) ([]byte, error) {
  211. allowPureClientTools, accountPolicyExplicit := grokClientToolCacheAccountPolicy(account)
  212. requestOptOut := false
  213. if c != nil {
  214. switch strings.ToLower(strings.TrimSpace(c.GetHeader(grokClientToolCacheOptInHeader))) {
  215. case "1", "true", "yes", "on", "prefer-cache":
  216. allowPureClientTools = true
  217. case "0", "false", "no", "off":
  218. allowPureClientTools = false
  219. requestOptOut = true
  220. }
  221. }
  222. if !allowPureClientTools && !accountPolicyExplicit && !requestOptOut && isGrokClaudeDesktopResponsesCacheRequest(c) {
  223. allowPureClientTools = true
  224. }
  225. // A function merely named web_search/x_search is still a client function.
  226. // Known Free OAuth accounts use the cache route by default; a request-scoped
  227. // opt-in may override an account opt-out, while an explicit request opt-out
  228. // always wins. The legacy Claude fingerprint remains only as a compatibility
  229. // fallback when no account policy has been recorded (#4486).
  230. return applyGrokFreeToolCacheRoute(body, intentSourceBody, account, cacheIdentity, allowPureClientTools, allowPureClientTools)
  231. }
  232. // grokClientToolCacheAccountPolicy is intentionally strict for configured
  233. // values: only a JSON boolean is accepted. A missing key defaults on solely for
  234. // accounts positively identified as Grok Free OAuth; paid, API-key, and unknown
  235. // accounts remain fail-closed.
  236. func grokClientToolCacheAccountPolicy(account *Account) (enabled, explicit bool) {
  237. if !isKnownGrokFreeAccount(account) {
  238. return false, false
  239. }
  240. if account.Extra == nil {
  241. return true, false
  242. }
  243. value, exists := account.Extra[grokClientToolCacheOptInExtraKey]
  244. if !exists {
  245. return true, false
  246. }
  247. enabled, valid := value.(bool)
  248. if !valid {
  249. return false, true
  250. }
  251. return enabled, true
  252. }
  253. // isGrokClaudeDesktopResponsesCacheRequest recognizes the strict wire
  254. // fingerprint emitted when Claude Desktop's local agent is translated by
  255. // CC Switch into an OpenAI Responses request. Requiring every independent
  256. // signal prevents a generic Claude-compatible client (or the Chat bridge)
  257. // from silently opting into the mixed native/client tool route.
  258. func isGrokClaudeDesktopResponsesCacheRequest(c *gin.Context) bool {
  259. if c == nil || c.Request == nil || c.Request.URL == nil || isOpenAIResponsesCompactPath(c) {
  260. return false
  261. }
  262. path := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/")
  263. if !strings.HasSuffix(path, "/responses") {
  264. return false
  265. }
  266. if !claudeCodeUAPattern.MatchString(strings.TrimSpace(c.GetHeader("User-Agent"))) {
  267. return false
  268. }
  269. switch strings.ToLower(strings.TrimSpace(c.GetHeader("X-App"))) {
  270. case "cli", "cli-bg":
  271. default:
  272. return false
  273. }
  274. if !strings.EqualFold(strings.TrimSpace(c.GetHeader("anthropic-client-platform")), "desktop_app") {
  275. return false
  276. }
  277. return strings.TrimSpace(c.GetHeader("X-Claude-Code-Session-Id")) != ""
  278. }
  279. func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account, cacheIdentity string, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) {
  280. if strings.TrimSpace(cacheIdentity) == "" || !isKnownGrokFreeAccount(account) {
  281. return body, nil
  282. }
  283. intentTools := gjson.GetBytes(intentSourceBody, "tools")
  284. intentToolChoice := gjson.GetBytes(intentSourceBody, "tool_choice")
  285. if !isGrokFreeCacheFunctionToolIntent(intentTools, intentToolChoice) {
  286. return body, nil
  287. }
  288. if intentToolChoice.Type == gjson.String && strings.TrimSpace(intentToolChoice.String()) == grokFreeCacheDisabledToolChoice {
  289. // Adding native cache-routing tools cannot change behavior when the
  290. // client has explicitly disabled all tool execution.
  291. return appendGrokFreeCacheNativeToolsWithPolicy(body, true, false)
  292. }
  293. return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch)
  294. }
  295. // isKnownGrokFreeAccount recognizes free-tier Grok accounts, used for
  296. // Free cache routing / media free_tier blocks (broader than soft-gate).
  297. // Soft-gate uses isExplicitGrokFreeOAuthAccount (exact "free" only).
  298. func isKnownGrokFreeAccount(account *Account) bool {
  299. if account == nil || !account.IsGrokOAuth() {
  300. return false
  301. }
  302. // Live access-token JWT wins over stale billing/credential snapshots
  303. // so a downgrade to free is visible as soon as the AT is refreshed.
  304. if jwtTier := xai.SubscriptionTierFromJWT(account.GetCredential("access_token")); jwtTier != "" {
  305. return isGrokFreeSubscriptionTier(jwtTier)
  306. }
  307. freeSignal := false
  308. paidSignal := false
  309. inferredFreeSignal := false
  310. if billing, err := grokBillingSnapshotFromExtra(account.Extra); err == nil && billing != nil {
  311. if tier := strings.TrimSpace(billing.Plan); tier != "" {
  312. if isGrokFreeSubscriptionTier(tier) {
  313. freeSignal = true
  314. } else if !isGrokUnknownSubscriptionTier(tier) {
  315. paidSignal = true
  316. }
  317. }
  318. // Usage % or a monthly dollar cap is evidence of a paid plan.
  319. if billing.UsagePercent != nil || billing.UsedPercent != nil ||
  320. (billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0) {
  321. paidSignal = true
  322. }
  323. // Empty plan + successful monthly observation → inferred free (no paid plan/limit).
  324. if strings.TrimSpace(billing.MonthlyUpdatedAt) != "" ||
  325. (billing.StatusCode >= http.StatusOK && billing.StatusCode < http.StatusMultipleChoices &&
  326. !billing.Partial && len(billing.FailedWindows) == 0) {
  327. inferredFreeSignal = true
  328. }
  329. }
  330. if snapshot, err := grokQuotaSnapshotFromExtra(account.Extra); err == nil && snapshot != nil {
  331. if tier := strings.TrimSpace(snapshot.SubscriptionTier); tier != "" {
  332. if isGrokFreeSubscriptionTier(tier) {
  333. freeSignal = true
  334. } else if !isGrokUnknownSubscriptionTier(tier) {
  335. paidSignal = true
  336. }
  337. }
  338. if snapshot.Tokens != nil && snapshot.Tokens.Limit != nil &&
  339. xai.IsGrokFreeRolling24hTokenLimit(*snapshot.Tokens.Limit) {
  340. inferredFreeSignal = true
  341. }
  342. }
  343. // Only credentials subscription_tier is authoritative here (not plan_type / extra keys).
  344. if tier := strings.TrimSpace(account.GetCredential("subscription_tier")); tier != "" {
  345. if isGrokFreeSubscriptionTier(tier) {
  346. freeSignal = true
  347. } else if !isGrokUnknownSubscriptionTier(tier) {
  348. paidSignal = true
  349. }
  350. }
  351. // Explicit paid evidence always wins over an inferred Free signal.
  352. return !paidSignal && (freeSignal || inferredFreeSignal)
  353. }
  354. func isGrokFreeSubscriptionTier(tier string) bool {
  355. switch xai.NormalizeSubscriptionTier(tier) {
  356. case "free", "x_basic":
  357. return true
  358. default:
  359. return false
  360. }
  361. }
  362. func isGrokUnknownSubscriptionTier(tier string) bool {
  363. switch strings.ToLower(strings.TrimSpace(tier)) {
  364. case "", "unknown", "n/a", "none":
  365. return true
  366. default:
  367. return false
  368. }
  369. }
  370. func isGrokFreeCacheFunctionToolIntent(tools, toolChoice gjson.Result) bool {
  371. if !tools.IsArray() {
  372. return false
  373. }
  374. items := tools.Array()
  375. if len(items) == 0 {
  376. return false
  377. }
  378. for _, tool := range items {
  379. if !tool.IsObject() {
  380. return false
  381. }
  382. toolType := strings.TrimSpace(tool.Get("type").String())
  383. if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
  384. return false
  385. }
  386. if toolType == "function" {
  387. // Responses function declarations keep name at the top level. Reject
  388. // Chat Completions' nested function shape and incomplete declarations.
  389. if strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
  390. return false
  391. }
  392. }
  393. }
  394. if !toolChoice.Exists() {
  395. return true
  396. }
  397. if toolChoice.Type != gjson.String {
  398. return false
  399. }
  400. switch strings.TrimSpace(toolChoice.String()) {
  401. case "auto", grokFreeCacheDisabledToolChoice:
  402. return true
  403. default:
  404. return false
  405. }
  406. }
  407. func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
  408. return appendGrokFreeCacheNativeTools(body, false)
  409. }
  410. func appendGrokFreeCacheNativeTools(body []byte, allowPureClientTools bool) ([]byte, error) {
  411. return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, true)
  412. }
  413. func appendGrokFreeCacheNativeToolsWithPolicy(body []byte, allowPureClientTools, allowFunctionSearch bool) ([]byte, error) {
  414. tools := gjson.GetBytes(body, "tools")
  415. if !tools.Exists() || !tools.IsArray() {
  416. return body, nil
  417. }
  418. items := tools.Array()
  419. if len(items) == 0 {
  420. return body, nil
  421. }
  422. hasNativeSearch := false
  423. for _, tool := range items {
  424. switch strings.TrimSpace(tool.Get("type").String()) {
  425. case "web_search", "x_search":
  426. hasNativeSearch = true
  427. }
  428. }
  429. if !allowPureClientTools && !allowFunctionSearch && !hasNativeSearch {
  430. return body, nil
  431. }
  432. merged := make([]json.RawMessage, 0, len(items)+2)
  433. present := make(map[string]bool, 2)
  434. hasCompanionTool := false
  435. for _, tool := range items {
  436. toolType := strings.TrimSpace(tool.Get("type").String())
  437. switch toolType {
  438. case "function":
  439. name := strings.TrimSpace(tool.Get("name").String())
  440. if !tool.IsObject() || name == "" || tool.Get("function").Exists() {
  441. return body, nil
  442. }
  443. // Grok Build may declare search as function tools. Convert to native
  444. // entries so Free OAuth stays cache-capable without duplicate names.
  445. if (name == "web_search" || name == "x_search") && allowFunctionSearch {
  446. if present[name] {
  447. continue
  448. }
  449. raw, err := json.Marshal(map[string]string{"type": name})
  450. if err != nil {
  451. return nil, err
  452. }
  453. merged = append(merged, raw)
  454. present[name] = true
  455. if allowPureClientTools {
  456. hasCompanionTool = true
  457. }
  458. continue
  459. }
  460. if name == "web_search" || name == "x_search" {
  461. // Keep the client function intact and avoid adding a same-named
  462. // native tool unless conversion was explicitly enabled.
  463. present[name] = true
  464. }
  465. hasCompanionTool = true
  466. merged = append(merged, json.RawMessage(tool.Raw))
  467. case "web_search", "x_search":
  468. if present[toolType] {
  469. continue
  470. }
  471. merged = append(merged, json.RawMessage(tool.Raw))
  472. present[toolType] = true
  473. default:
  474. if _, ok := grokResponsesSupportedToolTypes[toolType]; !ok {
  475. return body, nil
  476. }
  477. hasCompanionTool = true
  478. merged = append(merged, json.RawMessage(tool.Raw))
  479. }
  480. }
  481. if !hasCompanionTool {
  482. return body, nil
  483. }
  484. // Only complement missing native search tools when the request already contains
  485. // at least one search tool (native or function-form). Pure client function tools
  486. // (e.g. view_image) must not trigger injection to avoid biasing model tool
  487. // selection (#4486).
  488. if !allowPureClientTools && !present["web_search"] && !present["x_search"] {
  489. return body, nil
  490. }
  491. for _, toolType := range []string{"web_search", "x_search"} {
  492. if present[toolType] {
  493. continue
  494. }
  495. raw, err := json.Marshal(map[string]string{"type": toolType})
  496. if err != nil {
  497. return nil, err
  498. }
  499. merged = append(merged, raw)
  500. }
  501. encoded, err := json.Marshal(merged)
  502. if err != nil {
  503. return nil, err
  504. }
  505. return sjson.SetRawBytes(body, "tools", encoded)
  506. }
  507. // applyGrokCacheHeaders applies the documented Chat Completions conversation
  508. // routing header. The request is built from a fresh header map, so client
  509. // supplied x-grok headers cannot override this server-derived value.
  510. func applyGrokCacheHeaders(headers http.Header, identity string) {
  511. if headers == nil {
  512. return
  513. }
  514. identity = strings.TrimSpace(identity)
  515. if identity == "" {
  516. headers.Del(grokConversationIDHeader)
  517. return
  518. }
  519. headers.Set(grokConversationIDHeader, identity)
  520. }
  521. // stripGrokChatPromptCacheKey removes the Responses-only body field after it
  522. // has been used as an identity seed. Chat Completions routes cache by header.
  523. func stripGrokChatPromptCacheKey(body []byte) ([]byte, error) {
  524. if !gjson.GetBytes(body, "prompt_cache_key").Exists() {
  525. return body, nil
  526. }
  527. return sjson.DeleteBytes(body, "prompt_cache_key")
  528. }