openai_gateway_grok_cache_test.go 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167
  1. //go:build unit
  2. package service
  3. import (
  4. "fmt"
  5. "net/http"
  6. "net/http/httptest"
  7. "sync"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. "github.com/tidwall/gjson"
  13. )
  14. func newGrokCacheTestContext(apiKeyID int64) *gin.Context {
  15. recorder := httptest.NewRecorder()
  16. c, _ := gin.CreateTestContext(recorder)
  17. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  18. if apiKeyID > 0 {
  19. c.Set("api_key", &APIKey{ID: apiKeyID, Group: &Group{Platform: PlatformGrok}})
  20. }
  21. return c
  22. }
  23. func TestGrokPreviousResponseSessionSeed(t *testing.T) {
  24. require.Equal(t, "grok-prev-resp:resp_abc123", grokPreviousResponseSessionSeed([]byte(`{"previous_response_id":"resp_abc123"}`)))
  25. require.Empty(t, grokPreviousResponseSessionSeed([]byte(`{"previous_response_id":"msg_abc123"}`)))
  26. require.Empty(t, grokPreviousResponseSessionSeed([]byte(`{"previous_response_id":""}`)))
  27. require.Empty(t, grokPreviousResponseSessionSeed([]byte(`{}`)))
  28. }
  29. func TestResolveGrokCacheIdentityUsesPreviousResponseIDWhenNoOtherSeed(t *testing.T) {
  30. gin.SetMode(gin.TestMode)
  31. c := newGrokCacheTestContext(301)
  32. // No prompt_cache_key / headers / reusable prefix — only previous_response_id.
  33. body := []byte(`{"model":"grok","input":[{"role":"user","content":"follow up"}],"previous_response_id":"resp_chain_001"}`)
  34. got := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
  35. require.NotEmpty(t, got)
  36. // Same previous_response_id → same identity (model already in isolated seed).
  37. again := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
  38. require.Equal(t, got, again)
  39. // Different model → different identity (model scope).
  40. otherModel := resolveGrokCacheIdentity(c, body, "", "grok-4.3")
  41. require.NotEqual(t, got, otherModel)
  42. // prompt_cache_key still wins over previous_response_id.
  43. withCache := []byte(`{"model":"grok","prompt_cache_key":"client-session","previous_response_id":"resp_chain_001","input":[{"role":"user","content":"x"}]}`)
  44. cacheID := resolveGrokCacheIdentity(c, withCache, "", "grok-4.5")
  45. require.NotEmpty(t, cacheID)
  46. require.NotEqual(t, got, cacheID)
  47. }
  48. func TestResolveGrokCacheIdentityStableAcrossAppendOnlyTurns(t *testing.T) {
  49. gin.SetMode(gin.TestMode)
  50. c := newGrokCacheTestContext(101)
  51. round1 := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"role":"user","content":"first question"}]}`)
  52. round2 := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"role":"user","content":"first question"},{"role":"assistant","content":"first answer"},{"role":"user","content":"second question"}]}`)
  53. first := resolveGrokCacheIdentity(c, round1, "", "grok-4.5")
  54. second := resolveGrokCacheIdentity(c, round2, "", "grok-4.5")
  55. require.NotEmpty(t, first)
  56. require.Len(t, first, 36)
  57. require.Equal(t, first, second)
  58. }
  59. func TestResolveGrokCacheIdentityStableAcrossIndependentPromptsWithSamePrefix(t *testing.T) {
  60. gin.SetMode(gin.TestMode)
  61. c := newGrokCacheTestContext(102)
  62. firstBody := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"user","content":"Question A"}]}`)
  63. secondBody := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"user","content":"Question B"}]}`)
  64. first := resolveGrokCacheIdentity(c, firstBody, "", "grok-4.5")
  65. second := resolveGrokCacheIdentity(c, secondBody, "", "grok-4.5")
  66. require.NotEmpty(t, first)
  67. require.Equal(t, first, second)
  68. }
  69. func TestResolveGrokCacheIdentityStablePrefixIsolation(t *testing.T) {
  70. gin.SetMode(gin.TestMode)
  71. baseBody := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question A"}]}`)
  72. differentInstructions := []byte(`{"model":"grok","instructions":"be detailed","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question B"}]}`)
  73. differentSystem := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"lookup"}],"input":[{"role":"system","content":"System B"},{"role":"user","content":"Question B"}]}`)
  74. differentTools := []byte(`{"model":"grok","instructions":"be concise","tools":[{"type":"function","name":"search"}],"input":[{"role":"system","content":"System A"},{"role":"user","content":"Question B"}]}`)
  75. base := resolveGrokCacheIdentity(newGrokCacheTestContext(103), baseBody, "", "grok-4.5")
  76. require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(104), baseBody, "", "grok-4.5"))
  77. require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), baseBody, "", "grok-4.3"))
  78. require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), differentInstructions, "", "grok-4.5"))
  79. require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), differentSystem, "", "grok-4.5"))
  80. require.NotEqual(t, base, resolveGrokCacheIdentity(newGrokCacheTestContext(103), differentTools, "", "grok-4.5"))
  81. }
  82. func TestResolveGrokCacheIdentityFallsBackWhenStablePrefixIsEmpty(t *testing.T) {
  83. gin.SetMode(gin.TestMode)
  84. c := newGrokCacheTestContext(105)
  85. firstBody := []byte(`{"model":"grok","tools":[],"input":"Question A"}`)
  86. secondBody := []byte(`{"model":"grok","tools":[],"input":"Question B"}`)
  87. first := resolveGrokCacheIdentity(c, firstBody, "", "grok-4.5")
  88. second := resolveGrokCacheIdentity(c, secondBody, "", "grok-4.5")
  89. require.NotEmpty(t, first)
  90. require.NotEmpty(t, second)
  91. require.NotEqual(t, first, second)
  92. }
  93. func TestResolveGrokCacheIdentitySkipsUnanchoredFallback(t *testing.T) {
  94. gin.SetMode(gin.TestMode)
  95. c := newGrokCacheTestContext(106)
  96. tests := [][]byte{
  97. []byte(`{"model":"grok"}`),
  98. []byte(`{"model":"grok","messages":[{"role":"assistant","content":"answer"}]}`),
  99. []byte(`{"model":"grok","messages":[{"role":"user","content":""}]}`),
  100. []byte(`{"model":"grok","input":" "}`),
  101. }
  102. for _, body := range tests {
  103. require.Empty(t, resolveGrokCacheIdentity(c, body, "", "grok-4.5"))
  104. }
  105. }
  106. func TestResolveGrokCacheIdentityIsolatesAPIKeyAndMappedModel(t *testing.T) {
  107. gin.SetMode(gin.TestMode)
  108. body := []byte(`{"model":"grok","input":"same prompt"}`)
  109. base := resolveGrokCacheIdentity(newGrokCacheTestContext(201), body, "", "grok-4.5")
  110. otherTenant := resolveGrokCacheIdentity(newGrokCacheTestContext(202), body, "", "grok-4.5")
  111. otherModel := resolveGrokCacheIdentity(newGrokCacheTestContext(201), body, "", "grok-4.3")
  112. require.NotEmpty(t, base)
  113. require.NotEqual(t, base, otherTenant)
  114. require.NotEqual(t, base, otherModel)
  115. }
  116. func TestResolveGrokCacheIdentityUsesAndIsolatesNativeConversationHeader(t *testing.T) {
  117. gin.SetMode(gin.TestMode)
  118. c := newGrokCacheTestContext(301)
  119. c.Request.Header.Set(grokConversationIDHeader, "raw-native-conversation")
  120. body1 := []byte(`{"model":"grok","input":"one"}`)
  121. body2 := []byte(`{"model":"grok","input":"different body that must not replace the explicit session"}`)
  122. first := resolveGrokCacheIdentity(c, body1, "body-cache-key", "grok-4.5")
  123. second := resolveGrokCacheIdentity(c, body2, "another-body-cache-key", "grok-4.5")
  124. require.Equal(t, "raw-native-conversation", (&OpenAIGatewayService{}).ExtractSessionID(c, body1))
  125. require.Equal(t, first, second)
  126. require.NotEqual(t, "raw-native-conversation", first)
  127. require.NotContains(t, first, "raw-native-conversation")
  128. }
  129. func TestResolveGrokCacheIdentityExplicitHeaderPriority(t *testing.T) {
  130. gin.SetMode(gin.TestMode)
  131. body := []byte(`{"model":"grok","prompt_cache_key":"body-key","input":"hi"}`)
  132. c := newGrokCacheTestContext(401)
  133. c.Request.Header.Set(grokConversationIDHeader, "grok-key")
  134. c.Request.Header.Set("conversation_id", "conversation-key")
  135. c.Request.Header.Set("session_id", "session-key")
  136. got := resolveGrokCacheIdentity(c, body, "explicit-argument", "grok-4.5")
  137. onlySession := newGrokCacheTestContext(401)
  138. onlySession.Request.Header.Set("session_id", "session-key")
  139. want := resolveGrokCacheIdentity(onlySession, []byte(`{"model":"grok","input":"unrelated"}`), "", "grok-4.5")
  140. require.Equal(t, want, got)
  141. }
  142. func TestResolveGrokCacheIdentityIDEHeaderPriority(t *testing.T) {
  143. gin.SetMode(gin.TestMode)
  144. body := []byte(`{"model":"grok","prompt_cache_key":"body-key","input":"hi"}`)
  145. headers := []struct {
  146. name string
  147. value string
  148. }{
  149. {name: openCodeSessionAffinityHeader, value: "opencode-affinity"},
  150. {name: openCodeSessionIDHeader, value: "opencode-session-id"},
  151. {name: openCodeNativeSessionHeader, value: "opencode-native-session"},
  152. {name: codeBuddyConversationHeader, value: "codebuddy-conversation"},
  153. {name: grokConversationIDHeader, value: "grok-conversation"},
  154. }
  155. c := newGrokCacheTestContext(402)
  156. for _, header := range headers {
  157. c.Request.Header.Set(header.name, header.value)
  158. }
  159. for _, header := range headers {
  160. got := resolveGrokCacheIdentity(c, body, "explicit-argument", "grok-4.5")
  161. onlyCurrent := newGrokCacheTestContext(402)
  162. onlyCurrent.Request.Header.Set(header.name, header.value)
  163. if header.name == grokConversationIDHeader {
  164. // prompt_cache_key beats X-Grok-Conv-Id: verify the identity is
  165. // derived from the body key, not the per-call conv label.
  166. withOnlyBody := newGrokCacheTestContext(402)
  167. want := resolveGrokCacheIdentity(withOnlyBody, body, "", "grok-4.5")
  168. require.Equal(t, want, got, header.name)
  169. withOnlyConv := newGrokCacheTestContext(402)
  170. withOnlyConv.Request.Header.Set(header.name, header.value)
  171. convOnly := resolveGrokCacheIdentity(withOnlyConv, []byte(`{"model":"grok","input":"unrelated"}`), "", "grok-4.5")
  172. require.NotEqual(t, convOnly, got, "body key must not be shadowed by X-Grok-Conv-Id")
  173. } else {
  174. want := resolveGrokCacheIdentity(onlyCurrent, body, "", "grok-4.5")
  175. require.Equal(t, want, got, header.name)
  176. }
  177. c.Request.Header.Del(header.name)
  178. }
  179. }
  180. // TestResolveGrokCacheIdentitySideCallSharesParentCacheKey locks in the
  181. // grok-build side-call fix: recap-style side-calls (turn-summary /
  182. // title-refresh) send a fresh X-Grok-Conv-Id label but the parent session id
  183. // as body prompt_cache_key. The derived identity must follow the body key so
  184. // side-calls share the main turn's server-side cache prefix instead of
  185. // replaying the full conversation at full price on every call.
  186. func TestResolveGrokCacheIdentitySideCallSharesParentCacheKey(t *testing.T) {
  187. gin.SetMode(gin.TestMode)
  188. parentSession := "6f1c2f46-0f5e-4f9d-9d4e-2f0f1c3d5b7a"
  189. mainTurn := newGrokCacheTestContext(910)
  190. mainTurn.Request.Header.Set(grokConversationIDHeader, parentSession)
  191. mainIdentity := resolveGrokCacheIdentity(mainTurn, []byte(`{"model":"grok-4.6","input":"main conversation"}`), "", "grok-4.6")
  192. require.NotEmpty(t, mainIdentity)
  193. sideCall := newGrokCacheTestContext(910)
  194. sideCall.Request.Header.Set(grokConversationIDHeader, "turn-summary-"+uuidNew())
  195. sideIdentity := resolveGrokCacheIdentity(sideCall, []byte(`{"model":"grok-4.6","prompt_cache_key":"`+parentSession+`","input":"summary replay"}`), "", "grok-4.6")
  196. require.Equal(t, mainIdentity, sideIdentity,
  197. "side-call with parent prompt_cache_key must share the main turn cache identity")
  198. titleRefresh := newGrokCacheTestContext(910)
  199. titleRefresh.Request.Header.Set(grokConversationIDHeader, "title-refresh-"+uuidNew())
  200. titleIdentity := resolveGrokCacheIdentity(titleRefresh, []byte(`{"model":"grok-4.6","prompt_cache_key":"`+parentSession+`","input":"title replay"}`), "", "grok-4.6")
  201. require.Equal(t, mainIdentity, titleIdentity)
  202. }
  203. func uuidNew() string {
  204. return fmt.Sprintf("%08x-%04x-4%03x-9%03x-%012x", 0x1234, 0x5678, 0x9abc, 0xdef0, 0x1234567890ab)
  205. }
  206. func TestExplicitGrokCacheSeedPriority(t *testing.T) {
  207. gin.SetMode(gin.TestMode)
  208. c := newGrokCacheTestContext(403)
  209. headers := []struct {
  210. name string
  211. value string
  212. }{
  213. {name: claudeCodeSessionHeader, value: "claude-session"},
  214. {name: "session_id", value: "generic-session"},
  215. {name: "conversation_id", value: "generic-conversation"},
  216. {name: openCodeSessionAffinityHeader, value: "opencode-affinity"},
  217. {name: openCodeSessionIDHeader, value: "opencode-session-id"},
  218. {name: openCodeNativeSessionHeader, value: "opencode-native-session"},
  219. {name: codeBuddyConversationHeader, value: "codebuddy-conversation"},
  220. {name: grokConversationIDHeader, value: "grok-conversation"},
  221. }
  222. for _, header := range headers {
  223. c.Request.Header.Set(header.name, header.value)
  224. }
  225. body := []byte(`{"model":"grok","prompt_cache_key":"body-key","input":"hi"}`)
  226. for _, header := range headers {
  227. var want string
  228. switch header.name {
  229. case grokConversationIDHeader:
  230. // Client-declared prompt_cache_key outranks X-Grok-Conv-Id: the
  231. // grok-build CLI pairs a fresh per-side-call conv label with the
  232. // stable parent session id in the body field, and the body field
  233. // is the official xAI cache-routing signal.
  234. want = "body-key"
  235. default:
  236. want = header.value
  237. }
  238. require.Equal(t, want, explicitGrokCacheSeed(c, body, "explicit-argument"), header.name)
  239. c.Request.Header.Del(header.name)
  240. }
  241. require.Equal(t, "body-key", explicitGrokCacheSeed(c, body, "explicit-argument"))
  242. require.Equal(t, "explicit-argument", explicitGrokCacheSeed(c, []byte(`{"model":"grok"}`), "explicit-argument"))
  243. }
  244. func TestResolveGrokCacheIdentityIDEHeadersAreStableIsolatedAndOpaque(t *testing.T) {
  245. gin.SetMode(gin.TestMode)
  246. tests := []struct {
  247. name string
  248. header string
  249. }{
  250. {name: "OpenCode affinity", header: openCodeSessionAffinityHeader},
  251. {name: "OpenCode session ID", header: openCodeSessionIDHeader},
  252. {name: "OpenCode native session", header: openCodeNativeSessionHeader},
  253. {name: "CodeBuddy conversation", header: codeBuddyConversationHeader},
  254. }
  255. for index, tt := range tests {
  256. t.Run(tt.name, func(t *testing.T) {
  257. rawSession := "raw-ide-session-" + tt.name
  258. apiKeyID := int64(800 + index)
  259. c := newGrokCacheTestContext(apiKeyID)
  260. c.Request.Header.Set(tt.header, rawSession)
  261. firstBody := []byte(`{"model":"grok","prompt_cache_key":"turn-one-body-key","input":"first turn"}`)
  262. secondBody := []byte(`{"model":"grok","prompt_cache_key":"turn-two-body-key","input":"different second turn"}`)
  263. first := resolveGrokCacheIdentity(c, firstBody, "first-explicit-key", "grok-4.5")
  264. second := resolveGrokCacheIdentity(c, secondBody, "second-explicit-key", "grok-4.5")
  265. require.NotEmpty(t, first)
  266. require.Equal(t, first, second)
  267. require.NotEqual(t, rawSession, first)
  268. require.NotContains(t, first, rawSession)
  269. otherTenant := newGrokCacheTestContext(apiKeyID + 100)
  270. otherTenant.Request.Header.Set(tt.header, rawSession)
  271. require.NotEqual(t, first, resolveGrokCacheIdentity(otherTenant, firstBody, "", "grok-4.5"))
  272. require.NotEqual(t, first, resolveGrokCacheIdentity(c, firstBody, "", "grok-4.3"))
  273. })
  274. }
  275. }
  276. func TestOpenCodeResponsesHeaderAndBodyCacheSignalsConverge(t *testing.T) {
  277. gin.SetMode(gin.TestMode)
  278. const rawSession = "opencode-session-42"
  279. c := newGrokCacheTestContext(901)
  280. c.Request.Header.Set(openCodeSessionAffinityHeader, rawSession)
  281. c.Request.Header.Set(openCodeSessionIDHeader, rawSession)
  282. firstBody := []byte(`{"model":"grok","prompt_cache_key":"opencode-session-42","input":"first turn"}`)
  283. secondBody := []byte(`{"model":"grok","prompt_cache_key":"opencode-session-42","input":"different second turn"}`)
  284. first := resolveGrokCacheIdentity(c, firstBody, "", "grok-4.5")
  285. second := resolveGrokCacheIdentity(c, secondBody, "", "grok-4.5")
  286. bodyOnly := resolveGrokCacheIdentity(newGrokCacheTestContext(901), secondBody, "", "grok-4.5")
  287. require.NotEmpty(t, first)
  288. require.Equal(t, first, second)
  289. require.Equal(t, first, bodyOnly)
  290. patched, err := applyGrokResponsesCacheIdentity(secondBody, secondBody, second, false)
  291. require.NoError(t, err)
  292. require.Equal(t, second, gjson.GetBytes(patched, "prompt_cache_key").String())
  293. headers := make(http.Header)
  294. applyGrokCacheHeaders(headers, second)
  295. require.Equal(t, second, headers.Get(grokConversationIDHeader))
  296. require.NotContains(t, string(patched), rawSession)
  297. }
  298. func TestResolveGrokCacheIdentityPrefersClaudeCodeSession(t *testing.T) {
  299. gin.SetMode(gin.TestMode)
  300. c := newGrokCacheTestContext(701)
  301. c.Request.Header.Set(claudeCodeSessionHeader, "cc-session-abc")
  302. c.Request.Header.Set("session_id", "session-key")
  303. body1 := []byte(`{"model":"grok","input":"turn-1"}`)
  304. body2 := []byte(`{"model":"grok","input":"turn-2-different-user-text"}`)
  305. first := resolveGrokCacheIdentity(c, body1, "", "grok-4.5")
  306. second := resolveGrokCacheIdentity(c, body2, "unrelated-explicit", "grok-4.5")
  307. require.NotEmpty(t, first)
  308. require.Equal(t, first, second, "same Claude Code session must keep stable Grok cache identity across turns")
  309. // metadata.user_id JSON form used by Claude Code clients
  310. metaBody := []byte(`{"model":"grok","metadata":{"user_id":"{\"session_id\":\"meta-session-xyz\"}"},"input":"hi"}`)
  311. metaOnly := newGrokCacheTestContext(702)
  312. metaID := resolveGrokCacheIdentity(metaOnly, metaBody, "", "grok-4.5")
  313. require.NotEmpty(t, metaID)
  314. metaBody2 := []byte(`{"model":"grok","metadata":{"user_id":"{\"session_id\":\"meta-session-xyz\"}"},"input":"later turn"}`)
  315. require.Equal(t, metaID, resolveGrokCacheIdentity(metaOnly, metaBody2, "", "grok-4.5"))
  316. }
  317. func TestResolveGrokCacheIdentityFailsClosedWithoutAPIKeyContext(t *testing.T) {
  318. gin.SetMode(gin.TestMode)
  319. c := newGrokCacheTestContext(0)
  320. c.Request.Header.Set(grokConversationIDHeader, "native-session")
  321. require.Empty(t, resolveGrokCacheIdentity(c, []byte(`{"model":"grok","input":"hi"}`), "", "grok-4.5"))
  322. require.Empty(t, resolveGrokCacheIdentity(nil, []byte(`{"model":"grok","prompt_cache_key":"key"}`), "key", "grok-4.5"))
  323. }
  324. func TestGrokConversationHeaderIsScopedToGrokRequestScheduling(t *testing.T) {
  325. gin.SetMode(gin.TestMode)
  326. body := []byte(`{"model":"grok","prompt_cache_key":"body-session","input":"hi"}`)
  327. grokContext := newGrokCacheTestContext(601)
  328. grokContext.Request.Header.Set(grokConversationIDHeader, "native-grok-session")
  329. require.Equal(t, "native-grok-session", (&OpenAIGatewayService{}).ExtractSessionID(grokContext, body))
  330. openAIContext := newGrokCacheTestContext(601)
  331. openAIContext.Set("api_key", &APIKey{ID: 601, Group: &Group{Platform: PlatformOpenAI}})
  332. openAIContext.Request.Header.Set(grokConversationIDHeader, "must-be-ignored")
  333. require.Equal(t, "body-session", (&OpenAIGatewayService{}).ExtractSessionID(openAIContext, body))
  334. withoutGrokHeader := newGrokCacheTestContext(601)
  335. withoutGrokHeader.Set("api_key", &APIKey{ID: 601, Group: &Group{Platform: PlatformOpenAI}})
  336. require.Equal(t,
  337. (&OpenAIGatewayService{}).GenerateSessionHash(withoutGrokHeader, body),
  338. (&OpenAIGatewayService{}).GenerateSessionHash(openAIContext, body),
  339. )
  340. }
  341. func TestApplyGrokCacheIdentityWritesResponsesBodyAndHeader(t *testing.T) {
  342. sourceBody := []byte(`{"model":"grok-4.5","prompt_cache_key":"raw-client-key"}`)
  343. body, err := applyGrokResponsesCacheIdentity(sourceBody, sourceBody, "isolated-id", true)
  344. require.NoError(t, err)
  345. require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
  346. require.Equal(t, "web_search", gjson.GetBytes(body, "tools.0.type").String())
  347. require.Equal(t, "x_search", gjson.GetBytes(body, "tools.1.type").String())
  348. require.Equal(t, grokFreeCacheDisabledToolChoice, gjson.GetBytes(body, "tool_choice").String())
  349. headers := make(http.Header)
  350. headers.Set(grokConversationIDHeader, "spoofed-client-value")
  351. applyGrokCacheHeaders(headers, "isolated-id")
  352. require.Equal(t, "isolated-id", headers.Get(grokConversationIDHeader))
  353. applyGrokCacheHeaders(headers, "")
  354. require.Empty(t, headers.Get(grokConversationIDHeader))
  355. chatBody, err := stripGrokChatPromptCacheKey(body)
  356. require.NoError(t, err)
  357. require.False(t, gjson.GetBytes(chatBody, "prompt_cache_key").Exists())
  358. unscopedSourceBody := []byte(`{"model":"grok","prompt_cache_key":"raw-client-key"}`)
  359. unscopedBody, err := applyGrokResponsesCacheIdentity(unscopedSourceBody, unscopedSourceBody, "", true)
  360. require.NoError(t, err)
  361. require.False(t, gjson.GetBytes(unscopedBody, "prompt_cache_key").Exists())
  362. require.False(t, gjson.GetBytes(unscopedBody, "tools").Exists())
  363. require.False(t, gjson.GetBytes(unscopedBody, "tool_choice").Exists())
  364. }
  365. func TestGrokFreeMessagesClientToolCacheDefaultsOnForKnownFree(t *testing.T) {
  366. account := healthyGrokOAuthGatewayTestAccount(901, "access-token")
  367. account.Credentials["subscription_tier"] = " FREE "
  368. tests := []struct {
  369. name string
  370. toolChoiceJSON string
  371. wantChoice bool
  372. }{
  373. {name: "missing tool choice"},
  374. {name: "automatic tool choice", toolChoiceJSON: `,"tool_choice":"auto"`, wantChoice: true},
  375. }
  376. for _, tt := range tests {
  377. t.Run(tt.name, func(t *testing.T) {
  378. 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 + `}`)
  379. body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
  380. require.NoError(t, err)
  381. body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
  382. require.NoError(t, err)
  383. require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
  384. tools := gjson.GetBytes(body, "tools").Array()
  385. require.Len(t, tools, 4)
  386. require.Equal(t, "function", tools[0].Get("type").String())
  387. require.Equal(t, "lookup", tools[0].Get("name").String())
  388. require.Equal(t, "function", tools[1].Get("type").String())
  389. require.Equal(t, "save", tools[1].Get("name").String())
  390. require.Equal(t, "web_search", tools[2].Get("type").String())
  391. require.Equal(t, "x_search", tools[3].Get("type").String())
  392. require.Equal(t, tt.wantChoice, gjson.GetBytes(body, "tool_choice").Exists())
  393. })
  394. }
  395. }
  396. func TestGrokFreeMessagesClientToolCacheDefaultsWithMissingAccountSetting(t *testing.T) {
  397. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  398. tests := []struct {
  399. name string
  400. extra map[string]any
  401. }{
  402. {name: "nil extra"},
  403. {name: "empty extra", extra: map[string]any{}},
  404. {name: "unrelated extra", extra: map[string]any{"other_setting": true}},
  405. }
  406. for _, tt := range tests {
  407. t.Run(tt.name, func(t *testing.T) {
  408. account := healthyGrokOAuthGatewayTestAccount(90101, "access-token")
  409. account.Credentials["subscription_tier"] = "free"
  410. account.Extra = tt.extra
  411. patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
  412. require.NoError(t, err)
  413. tools := gjson.GetBytes(patched, "tools").Array()
  414. require.Len(t, tools, 3)
  415. require.Equal(t, "web_search", tools[1].Get("type").String())
  416. require.Equal(t, "x_search", tools[2].Get("type").String())
  417. })
  418. }
  419. }
  420. func TestApplyGrokCacheIdentityAppendsNativeToolsWhenSearchPresent(t *testing.T) {
  421. account := healthyGrokOAuthGatewayTestAccount(901, "access-token")
  422. account.Credentials["subscription_tier"] = " FREE "
  423. // Function tools INCLUDING web_search → convert + complement with x_search.
  424. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup","description":"look up a value","parameters":{"type":"object"}},{"type":"function","name":"web_search","description":"search","parameters":{"type":"object"}}]}`)
  425. body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
  426. require.NoError(t, err)
  427. body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
  428. require.NoError(t, err)
  429. tools := gjson.GetBytes(body, "tools").Array()
  430. require.Len(t, tools, 3, "lookup(function) + web_search(native) + x_search(native)")
  431. require.Equal(t, "function", tools[0].Get("type").String())
  432. require.Equal(t, "lookup", tools[0].Get("name").String())
  433. require.Equal(t, "web_search", tools[1].Get("type").String())
  434. require.Equal(t, "x_search", tools[2].Get("type").String())
  435. }
  436. func TestGrokFreeClientToolCacheAccountOptIn(t *testing.T) {
  437. account := healthyGrokOAuthGatewayTestAccount(9011, "access-token")
  438. account.Credentials["subscription_tier"] = "free"
  439. account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true}
  440. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"read_file","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  441. body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
  442. require.NoError(t, err)
  443. body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
  444. require.NoError(t, err)
  445. tools := gjson.GetBytes(body, "tools").Array()
  446. require.Len(t, tools, 4)
  447. require.Equal(t, "view_image", tools[0].Get("name").String())
  448. require.Equal(t, "read_file", tools[1].Get("name").String())
  449. require.Equal(t, "web_search", tools[2].Get("type").String())
  450. require.Equal(t, "x_search", tools[3].Get("type").String())
  451. }
  452. func TestGrokFreeMessagesClientToolCacheAccountOptOut(t *testing.T) {
  453. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  454. tests := []struct {
  455. name string
  456. value any
  457. }{
  458. {name: "explicit false", value: false},
  459. {name: "string false is malformed", value: "false"},
  460. {name: "numeric value is malformed", value: 1},
  461. {name: "null value is malformed", value: nil},
  462. }
  463. for _, tt := range tests {
  464. t.Run(tt.name, func(t *testing.T) {
  465. account := healthyGrokOAuthGatewayTestAccount(90111, "access-token")
  466. account.Credentials["subscription_tier"] = "free"
  467. account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: tt.value}
  468. patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
  469. require.NoError(t, err)
  470. require.JSONEq(t, string(body), string(patched))
  471. })
  472. }
  473. }
  474. func TestGrokFreeClientToolCacheRequestOptInOverridesAccountOptOut(t *testing.T) {
  475. account := healthyGrokOAuthGatewayTestAccount(9014, "access-token")
  476. account.Credentials["subscription_tier"] = "free"
  477. account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: false}
  478. c := newGrokCacheTestContext(9014)
  479. c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
  480. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  481. body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
  482. require.NoError(t, err)
  483. body, err = applyGrokFreeRequestToolCacheRoute(c, body, intentBody, account, "isolated-id")
  484. require.NoError(t, err)
  485. tools := gjson.GetBytes(body, "tools").Array()
  486. require.Len(t, tools, 3)
  487. require.Equal(t, "view_image", tools[0].Get("name").String())
  488. require.Equal(t, "web_search", tools[1].Get("type").String())
  489. require.Equal(t, "x_search", tools[2].Get("type").String())
  490. }
  491. func TestGrokFreeChatRequestClientToolCacheDefaultsOnWithoutClientFingerprint(t *testing.T) {
  492. account := healthyGrokOAuthGatewayTestAccount(90140, "access-token")
  493. account.Credentials["subscription_tier"] = "free"
  494. c := newGrokCacheTestContext(90140)
  495. c.Request.URL.Path = "/v1/chat/completions"
  496. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  497. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  498. require.NoError(t, err)
  499. tools := gjson.GetBytes(patched, "tools").Array()
  500. require.Len(t, tools, 3)
  501. require.Equal(t, "view_image", tools[0].Get("name").String())
  502. require.Equal(t, "web_search", tools[1].Get("type").String())
  503. require.Equal(t, "x_search", tools[2].Get("type").String())
  504. }
  505. func TestGrokFreeClientToolCacheClaudeDesktopResponsesAutoOptIn(t *testing.T) {
  506. account := healthyGrokOAuthGatewayTestAccount(90141, "access-token")
  507. account.Credentials["subscription_tier"] = "free"
  508. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}},{"type":"function","name":"Edit","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  509. for _, xApp := range []string{"cli", "cli-bg"} {
  510. t.Run(xApp, func(t *testing.T) {
  511. c := newGrokCacheTestContext(90141)
  512. // The desktop marker text is intentionally not required; CC Switch and
  513. // Claude Desktop may change the descriptive User-Agent suffix.
  514. c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, future-desktop, agent-sdk/0.3.215)")
  515. c.Request.Header.Set("X-App", xApp)
  516. c.Request.Header.Set("anthropic-client-platform", "desktop_app")
  517. c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1")
  518. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  519. require.NoError(t, err)
  520. tools := gjson.GetBytes(patched, "tools").Array()
  521. require.Len(t, tools, 4)
  522. require.Equal(t, "Read", tools[0].Get("name").String())
  523. require.Equal(t, "Edit", tools[1].Get("name").String())
  524. require.Equal(t, "web_search", tools[2].Get("type").String())
  525. require.Equal(t, "x_search", tools[3].Get("type").String())
  526. })
  527. }
  528. }
  529. func TestGrokFreeClientToolCacheClaudeDesktopFingerprintRequiresAllSignals(t *testing.T) {
  530. account := healthyGrokOAuthGatewayTestAccount(90142, "access-token")
  531. account.Credentials["subscription_tier"] = "free"
  532. account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: false}
  533. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  534. tests := []struct {
  535. name string
  536. path string
  537. ua string
  538. xApp string
  539. platform string
  540. session string
  541. }{
  542. {
  543. name: "chat path",
  544. path: "/v1/chat/completions",
  545. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  546. xApp: "cli",
  547. platform: "desktop_app",
  548. session: "desktop-session-1",
  549. },
  550. {
  551. name: "compact responses path",
  552. path: "/v1/responses/compact",
  553. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  554. xApp: "cli",
  555. platform: "desktop_app",
  556. session: "desktop-session-1",
  557. },
  558. {
  559. name: "non claude cli user agent",
  560. path: "/v1/responses",
  561. ua: "Mozilla/5.0 (claude-desktop-3p)",
  562. xApp: "cli",
  563. platform: "desktop_app",
  564. session: "desktop-session-1",
  565. },
  566. {
  567. name: "missing x app",
  568. path: "/v1/responses",
  569. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  570. platform: "desktop_app",
  571. session: "desktop-session-1",
  572. },
  573. {
  574. name: "wrong x app",
  575. path: "/v1/responses",
  576. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  577. xApp: "desktop",
  578. platform: "desktop_app",
  579. session: "desktop-session-1",
  580. },
  581. {
  582. name: "missing client platform",
  583. path: "/v1/responses",
  584. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  585. xApp: "cli",
  586. session: "desktop-session-1",
  587. },
  588. {
  589. name: "wrong client platform",
  590. path: "/v1/responses",
  591. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  592. xApp: "cli",
  593. platform: "web",
  594. session: "desktop-session-1",
  595. },
  596. {
  597. name: "missing session header",
  598. path: "/v1/responses",
  599. ua: "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)",
  600. xApp: "cli",
  601. platform: "desktop_app",
  602. },
  603. }
  604. for _, tt := range tests {
  605. t.Run(tt.name, func(t *testing.T) {
  606. c := newGrokCacheTestContext(90142)
  607. c.Request.URL.Path = tt.path
  608. if tt.ua != "" {
  609. c.Request.Header.Set("User-Agent", tt.ua)
  610. }
  611. if tt.xApp != "" {
  612. c.Request.Header.Set("X-App", tt.xApp)
  613. }
  614. if tt.platform != "" {
  615. c.Request.Header.Set("anthropic-client-platform", tt.platform)
  616. }
  617. if tt.session != "" {
  618. c.Request.Header.Set("X-Claude-Code-Session-Id", tt.session)
  619. }
  620. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  621. require.NoError(t, err)
  622. require.JSONEq(t, string(body), string(patched))
  623. })
  624. }
  625. }
  626. func TestGrokFreeClientToolCacheExplicitRequestOptOut(t *testing.T) {
  627. account := healthyGrokOAuthGatewayTestAccount(90144, "access-token")
  628. account.Credentials["subscription_tier"] = "free"
  629. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  630. for _, value := range []string{"0", "false", "no", "off"} {
  631. t.Run(value, func(t *testing.T) {
  632. c := newGrokCacheTestContext(90144)
  633. c.Request.URL.Path = "/v1/chat/completions"
  634. c.Request.Header.Set(grokClientToolCacheOptInHeader, value)
  635. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  636. require.NoError(t, err)
  637. require.JSONEq(t, string(body), string(patched))
  638. })
  639. }
  640. }
  641. func TestGrokFreeClientToolCacheClaudeDesktopAutoOptInDoesNotOverridePaidTier(t *testing.T) {
  642. account := healthyGrokOAuthGatewayTestAccount(90143, "access-token")
  643. account.Credentials["subscription_tier"] = "supergrok"
  644. c := newGrokCacheTestContext(90143)
  645. c.Request.Header.Set("User-Agent", "claude-cli/2.1.215 (external, claude-desktop-3p, agent-sdk/0.3.215)")
  646. c.Request.Header.Set("X-App", "cli")
  647. c.Request.Header.Set("anthropic-client-platform", "desktop_app")
  648. c.Request.Header.Set("X-Claude-Code-Session-Id", "desktop-session-1")
  649. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"Read","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  650. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  651. require.NoError(t, err)
  652. require.JSONEq(t, string(body), string(patched))
  653. }
  654. func TestGrokFreeRequestClientSearchFunctionUsesDefaultAccountPolicy(t *testing.T) {
  655. account := healthyGrokOAuthGatewayTestAccount(9015, "access-token")
  656. account.Credentials["subscription_tier"] = "free"
  657. c := newGrokCacheTestContext(9015)
  658. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"function","name":"web_search","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  659. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  660. require.NoError(t, err)
  661. tools := gjson.GetBytes(patched, "tools").Array()
  662. require.Len(t, tools, 3)
  663. require.Equal(t, "view_image", tools[0].Get("name").String())
  664. require.Equal(t, "web_search", tools[1].Get("type").String())
  665. require.Equal(t, "x_search", tools[2].Get("type").String())
  666. }
  667. func TestGrokFreeRequestToolChoiceNoneUsesSafeCacheRoute(t *testing.T) {
  668. account := healthyGrokOAuthGatewayTestAccount(9016, "access-token")
  669. account.Credentials["subscription_tier"] = "free"
  670. c := newGrokCacheTestContext(9016)
  671. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"none"}`)
  672. patched, err := applyGrokFreeRequestToolCacheRoute(c, body, body, account, "isolated-id")
  673. require.NoError(t, err)
  674. tools := gjson.GetBytes(patched, "tools").Array()
  675. require.Len(t, tools, 3)
  676. require.Equal(t, "view_image", tools[0].Get("name").String())
  677. require.Equal(t, "web_search", tools[1].Get("type").String())
  678. require.Equal(t, "x_search", tools[2].Get("type").String())
  679. require.Equal(t, "none", gjson.GetBytes(patched, "tool_choice").String())
  680. }
  681. func TestApplyGrokCacheIdentityRecognizesResponsesLiteAdditionalTools(t *testing.T) {
  682. intentBody := []byte(`{"model":"grok","input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]},{"type":"message","role":"user","content":"hello"}]}`)
  683. patchedBody := []byte(`{"model":"grok-4.5","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"input":[{"type":"message","role":"user","content":"hello"}]}`)
  684. patched, err := applyGrokResponsesCacheIdentity(patchedBody, intentBody, "isolated-id", true)
  685. require.NoError(t, err)
  686. tools := gjson.GetBytes(patched, "tools").Array()
  687. require.Len(t, tools, 1)
  688. require.Equal(t, "lookup", tools[0].Get("name").String())
  689. require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
  690. require.Equal(t, "isolated-id", gjson.GetBytes(patched, "prompt_cache_key").String())
  691. }
  692. func TestGrokFreeCacheRoutePreservesMixedSupportedToolsWithSearchIntent(t *testing.T) {
  693. account := healthyGrokOAuthGatewayTestAccount(9012, "access-token")
  694. account.Credentials["subscription_tier"] = "free"
  695. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}},{"type":"shell"},{"type":"web_search"}],"tool_choice":"auto"}`)
  696. body, err := applyGrokResponsesCacheIdentity(intentBody, intentBody, "isolated-id", true)
  697. require.NoError(t, err)
  698. body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
  699. require.NoError(t, err)
  700. tools := gjson.GetBytes(body, "tools").Array()
  701. require.Len(t, tools, 4)
  702. require.Equal(t, "function", tools[0].Get("type").String())
  703. require.Equal(t, "shell", tools[1].Get("type").String())
  704. require.Equal(t, "web_search", tools[2].Get("type").String())
  705. require.Equal(t, "x_search", tools[3].Get("type").String())
  706. }
  707. func TestGrokClientToolCacheOptInDoesNotOverridePaidTier(t *testing.T) {
  708. account := healthyGrokOAuthGatewayTestAccount(9013, "access-token")
  709. account.Credentials["subscription_tier"] = "supergrok"
  710. account.Extra = map[string]any{grokClientToolCacheOptInExtraKey: true}
  711. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"view_image","parameters":{"type":"object"}}],"tool_choice":"auto"}`)
  712. patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "isolated-id")
  713. require.NoError(t, err)
  714. require.JSONEq(t, string(body), string(patched))
  715. }
  716. func TestApplyGrokCacheIdentityRequiresPatchedFunctionTools(t *testing.T) {
  717. account := healthyGrokOAuthGatewayTestAccount(902, "access-token")
  718. account.Credentials["subscription_tier"] = "free"
  719. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto"}`)
  720. tests := []struct {
  721. name string
  722. patchedBody string
  723. }{
  724. {name: "missing tools", patchedBody: `{"model":"grok-4.5"}`},
  725. {name: "empty tools", patchedBody: `{"model":"grok-4.5","tools":[]}`},
  726. {name: "native tools only", patchedBody: `{"model":"grok-4.5","tools":[{"type":"web_search"}]}`},
  727. {name: "unexpected patched tool", patchedBody: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"},{"type":"namespace","name":"server"}]}`},
  728. }
  729. for _, tt := range tests {
  730. t.Run(tt.name, func(t *testing.T) {
  731. beforeTools := gjson.Get(tt.patchedBody, "tools")
  732. body, err := applyGrokResponsesCacheIdentity([]byte(tt.patchedBody), intentBody, "isolated-id", true)
  733. require.NoError(t, err)
  734. body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, intentBody, account, "isolated-id")
  735. require.NoError(t, err)
  736. require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
  737. afterTools := gjson.GetBytes(body, "tools")
  738. require.Equal(t, beforeTools.Exists(), afterTools.Exists())
  739. require.Equal(t, beforeTools.Raw, afterTools.Raw)
  740. })
  741. }
  742. }
  743. func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing.T) {
  744. // Include web_search as a function to also cover its conversion to a native tool.
  745. intentBody := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"web_search"}],"tool_choice":"auto"}`)
  746. tests := []struct {
  747. name string
  748. account *Account
  749. wantMix bool
  750. }{
  751. {
  752. name: "free credential tier",
  753. account: func() *Account {
  754. a := healthyGrokOAuthGatewayTestAccount(910, "access-token")
  755. a.Credentials["subscription_tier"] = "free"
  756. return a
  757. }(),
  758. wantMix: true,
  759. },
  760. {
  761. name: "free billing tier",
  762. account: func() *Account {
  763. a := healthyGrokOAuthGatewayTestAccount(911, "access-token")
  764. a.Extra = map[string]any{grokBillingExtraKey: map[string]any{"plan": "FREE"}}
  765. return a
  766. }(),
  767. wantMix: true,
  768. },
  769. {
  770. name: "free successful billing has blank plan",
  771. account: func() *Account {
  772. a := healthyGrokOAuthGatewayTestAccount(9111, "access-token")
  773. a.Extra = map[string]any{grokBillingExtraKey: map[string]any{
  774. "status_code": http.StatusOK,
  775. "source": "billing_probe",
  776. "monthly_updated_at": "2026-07-15T05:00:00Z",
  777. }}
  778. return a
  779. }(),
  780. wantMix: true,
  781. },
  782. {
  783. name: "free rolling token quota",
  784. account: func() *Account {
  785. a := healthyGrokOAuthGatewayTestAccount(9112, "access-token")
  786. a.Extra = map[string]any{grokQuotaSnapshotExtraKey: map[string]any{
  787. "headers_observed": true,
  788. "tokens": map[string]any{"limit": xai.GrokFreeRolling24hTokenLimit},
  789. }}
  790. return a
  791. }(),
  792. wantMix: true,
  793. },
  794. {
  795. name: "legacy free rolling token quota",
  796. account: func() *Account {
  797. a := healthyGrokOAuthGatewayTestAccount(9113, "access-token")
  798. a.Extra = map[string]any{grokQuotaSnapshotExtraKey: map[string]any{
  799. "headers_observed": true,
  800. "tokens": map[string]any{"limit": int64(2_000_000)},
  801. }}
  802. return a
  803. }(),
  804. wantMix: true,
  805. },
  806. {
  807. name: "supergrok remains unchanged",
  808. account: func() *Account {
  809. a := healthyGrokOAuthGatewayTestAccount(912, "access-token")
  810. a.Credentials["subscription_tier"] = "supergrok"
  811. return a
  812. }(),
  813. },
  814. {
  815. name: "paid billing overrides stale free quota",
  816. account: func() *Account {
  817. a := healthyGrokOAuthGatewayTestAccount(9121, "access-token")
  818. a.Extra = map[string]any{
  819. grokBillingExtraKey: map[string]any{"plan": "SuperGrok", "status_code": http.StatusOK},
  820. grokQuotaSnapshotExtraKey: map[string]any{
  821. "headers_observed": true,
  822. "tokens": map[string]any{"limit": int64(2_000_000)},
  823. },
  824. }
  825. return a
  826. }(),
  827. },
  828. {
  829. name: "paid billing overrides stale free credentials",
  830. account: func() *Account {
  831. a := healthyGrokOAuthGatewayTestAccount(9123, "access-token")
  832. a.Credentials["subscription_tier"] = "free"
  833. a.Extra = map[string]any{
  834. grokBillingExtraKey: map[string]any{"plan": "SuperGrok", "status_code": http.StatusOK},
  835. }
  836. return a
  837. }(),
  838. },
  839. {
  840. name: "partial billing without monthly evidence remains unknown",
  841. account: func() *Account {
  842. a := healthyGrokOAuthGatewayTestAccount(9122, "access-token")
  843. a.Extra = map[string]any{grokBillingExtraKey: map[string]any{
  844. "status_code": http.StatusOK,
  845. "source": "billing_probe",
  846. "partial": true,
  847. "failed_windows": []string{"monthly"},
  848. }}
  849. return a
  850. }(),
  851. },
  852. {
  853. name: "unknown tier remains unchanged",
  854. account: healthyGrokOAuthGatewayTestAccount(913, "access-token"),
  855. },
  856. {
  857. name: "api key remains unchanged",
  858. account: &Account{
  859. ID: 914,
  860. Platform: PlatformGrok,
  861. Type: AccountTypeAPIKey,
  862. },
  863. },
  864. }
  865. for _, tt := range tests {
  866. t.Run(tt.name, func(t *testing.T) {
  867. body, err := applyGrokFreeMessagesFunctionToolCacheRoute(intentBody, intentBody, tt.account, "isolated-id")
  868. require.NoError(t, err)
  869. tools := gjson.GetBytes(body, "tools").Array()
  870. if tt.wantMix {
  871. require.Len(t, tools, 3)
  872. require.Equal(t, "web_search", tools[1].Get("type").String())
  873. require.Equal(t, "x_search", tools[2].Get("type").String())
  874. return
  875. }
  876. require.Len(t, tools, 2, "non-free accounts should not get native search injected")
  877. })
  878. }
  879. }
  880. func TestGrokFreeMessagesFunctionToolCacheRouteRequiresIdentity(t *testing.T) {
  881. account := healthyGrokOAuthGatewayTestAccount(915, "access-token")
  882. account.Credentials["subscription_tier"] = "free"
  883. body := []byte(`{"model":"grok","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto"}`)
  884. patched, err := applyGrokFreeMessagesFunctionToolCacheRoute(body, body, account, "")
  885. require.NoError(t, err)
  886. require.JSONEq(t, string(body), string(patched))
  887. require.Len(t, gjson.GetBytes(patched, "tools").Array(), 1)
  888. }
  889. func TestApplyGrokCacheIdentityPreservesIneligibleClientToolFields(t *testing.T) {
  890. tests := []struct {
  891. name string
  892. body string
  893. }{
  894. {
  895. name: "empty tools array",
  896. body: `{"model":"grok","tools":[]}`,
  897. },
  898. {
  899. name: "null tools",
  900. body: `{"model":"grok","tools":null}`,
  901. },
  902. {
  903. name: "tool choice only",
  904. body: `{"model":"grok","tool_choice":{"type":"function","name":"lookup"}}`,
  905. },
  906. {
  907. name: "null tool choice",
  908. body: `{"model":"grok","tool_choice":null}`,
  909. },
  910. {
  911. name: "native tool with auto choice",
  912. body: `{"model":"grok","tools":[{"type":"web_search"}],"tool_choice":"auto"}`,
  913. },
  914. {
  915. name: "function with required choice",
  916. body: `{"model":"grok","tools":[{"type":"function","name":"lookup"}],"tool_choice":"required"}`,
  917. },
  918. {
  919. name: "function with none choice",
  920. body: `{"model":"grok","tools":[{"type":"function","name":"lookup"}],"tool_choice":"none"}`,
  921. },
  922. {
  923. name: "function with specific choice",
  924. body: `{"model":"grok","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"function","name":"lookup"}}`,
  925. },
  926. {
  927. name: "function with object auto choice",
  928. body: `{"model":"grok","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"auto"}}`,
  929. },
  930. {
  931. name: "function mixed with unsupported tool",
  932. body: `{"model":"grok","tools":[{"type":"function","name":"lookup"},{"type":"namespace","name":"client_tools"}],"tool_choice":"auto"}`,
  933. },
  934. {
  935. name: "unsupported tool only",
  936. body: `{"model":"grok","tools":[{"type":"namespace","name":"client_tools"}]}`,
  937. },
  938. {
  939. name: "chat completions function shape",
  940. body: `{"model":"grok","tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto"}`,
  941. },
  942. {
  943. name: "incomplete responses function",
  944. body: `{"model":"grok","tools":[{"type":"function","parameters":{"type":"object"}}]}`,
  945. },
  946. }
  947. for _, tt := range tests {
  948. t.Run(tt.name, func(t *testing.T) {
  949. beforeTools := gjson.Get(tt.body, "tools")
  950. beforeChoice := gjson.Get(tt.body, "tool_choice")
  951. body, err := applyGrokResponsesCacheIdentity([]byte(tt.body), []byte(tt.body), "isolated-id", true)
  952. require.NoError(t, err)
  953. require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
  954. require.Equal(t, beforeTools.Exists(), gjson.GetBytes(body, "tools").Exists())
  955. require.Equal(t, beforeTools.Raw, gjson.GetBytes(body, "tools").Raw)
  956. require.Equal(t, beforeChoice.Exists(), gjson.GetBytes(body, "tool_choice").Exists())
  957. require.Equal(t, beforeChoice.Raw, gjson.GetBytes(body, "tool_choice").Raw)
  958. })
  959. }
  960. }
  961. func TestApplyGrokCacheIdentityUsesPreSanitizationToolIntent(t *testing.T) {
  962. tests := []struct {
  963. name string
  964. intentBody string
  965. }{
  966. {
  967. name: "unsupported tools removed by sanitizer",
  968. intentBody: `{"model":"grok","tools":[{"type":"namespace","name":"client_tools"}]}`,
  969. },
  970. {
  971. name: "tool choice removed with unsupported tool",
  972. intentBody: `{"model":"grok","tools":[{"type":"namespace","name":"client_tools"}],"tool_choice":{"type":"namespace","name":"client_tools"}}`,
  973. },
  974. }
  975. for _, tt := range tests {
  976. t.Run(tt.name, func(t *testing.T) {
  977. // This is the shape apply receives after patchGrokResponsesBody has
  978. // removed unsupported tools and their associated tool_choice.
  979. patchedBody := []byte(`{"model":"grok-4.5","input":"hello"}`)
  980. body, err := applyGrokResponsesCacheIdentity(patchedBody, []byte(tt.intentBody), "isolated-id", true)
  981. require.NoError(t, err)
  982. require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
  983. require.False(t, gjson.GetBytes(body, "tools").Exists())
  984. require.False(t, gjson.GetBytes(body, "tool_choice").Exists())
  985. })
  986. }
  987. }
  988. func TestApplyGrokCacheIdentityWithoutFreeTierRoutingOnlyWritesIdentity(t *testing.T) {
  989. sourceBody := []byte(`{"model":"grok-4.5","input":"hello"}`)
  990. body, err := applyGrokResponsesCacheIdentity(sourceBody, sourceBody, "isolated-id", false)
  991. require.NoError(t, err)
  992. require.Equal(t, "isolated-id", gjson.GetBytes(body, "prompt_cache_key").String())
  993. require.False(t, gjson.GetBytes(body, "tools").Exists())
  994. require.False(t, gjson.GetBytes(body, "tool_choice").Exists())
  995. }
  996. func TestGrokCompactRequestSkipsCacheIdentityAndNativeTools(t *testing.T) {
  997. gin.SetMode(gin.TestMode)
  998. c := newGrokCacheTestContext(701)
  999. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
  1000. body := []byte(`{"model":"grok","input":"compact this","prompt_cache_key":"raw-client-key"}`)
  1001. identity := resolveGrokCacheIdentity(c, body, "", "grok-4.5")
  1002. patched, err := applyGrokResponsesCacheIdentity(body, body, identity, true)
  1003. require.NoError(t, err)
  1004. require.Empty(t, identity)
  1005. require.False(t, gjson.GetBytes(patched, "prompt_cache_key").Exists())
  1006. require.False(t, gjson.GetBytes(patched, "tools").Exists())
  1007. require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
  1008. }
  1009. func TestResolveGrokCacheIdentityConcurrentDeterminism(t *testing.T) {
  1010. gin.SetMode(gin.TestMode)
  1011. const workers = 50
  1012. body := []byte(`{"model":"grok","messages":[{"role":"system","content":"stable"},{"role":"user","content":"hello"}]}`)
  1013. identities := make(chan string, workers)
  1014. var wg sync.WaitGroup
  1015. for i := 0; i < workers; i++ {
  1016. wg.Add(1)
  1017. go func() {
  1018. defer wg.Done()
  1019. identities <- resolveGrokCacheIdentity(newGrokCacheTestContext(501), body, "", "grok-4.5")
  1020. }()
  1021. }
  1022. wg.Wait()
  1023. close(identities)
  1024. var first string
  1025. for identity := range identities {
  1026. if first == "" {
  1027. first = identity
  1028. }
  1029. require.Equal(t, first, identity)
  1030. }
  1031. require.NotEmpty(t, first)
  1032. }