openai_gateway_grok_chat_bridge_test.go 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "errors"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strconv"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. "github.com/tidwall/gjson"
  18. )
  19. func TestGrokChatResponsesBridgeEligibility(t *testing.T) {
  20. t.Parallel()
  21. tests := []struct {
  22. name string
  23. body string
  24. want bool
  25. reason string
  26. }{
  27. {
  28. name: "plain text chat",
  29. body: `{"model":"grok","messages":[{"role":"system","content":"concise"},{"role":"user","content":"hi"}],"stream":false}`,
  30. want: true,
  31. },
  32. {
  33. name: "safe generation options",
  34. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":true,"stream_options":{"include_usage":true},"max_completion_tokens":256,"temperature":0.2,"top_p":0.9,"prompt_cache_key":"session","tools":[],"functions":null,"tool_choice":"none"}`,
  35. want: true,
  36. },
  37. {
  38. name: "Trae compatible fields",
  39. body: `{"model":"grok","messages":[{"role":"user","content":"return json"}],"instructions":"Be concise","response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null}`,
  40. want: true,
  41. },
  42. {
  43. name: "Trae nullable SDK fields",
  44. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":null,"response_format":null,"service_tier":null,"stop":null,"reasoning_effort":null}`,
  45. want: true,
  46. },
  47. {
  48. name: "invalid instructions fall back",
  49. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"instructions":{"text":"invalid"}}`,
  50. reason: "invalid_instructions",
  51. },
  52. {
  53. name: "invalid response format falls back",
  54. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"response_format":"json_object"}`,
  55. reason: "invalid_response_format",
  56. },
  57. {
  58. name: "invalid service tier falls back",
  59. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"service_tier":1}`,
  60. reason: "invalid_service_tier",
  61. },
  62. {
  63. name: "stop falls back",
  64. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"stop":"done"}`,
  65. reason: "unsupported_stop",
  66. },
  67. {
  68. name: "developer role falls back",
  69. body: `{"model":"grok","messages":[{"role":"developer","content":"rules"},{"role":"user","content":"hi"}]}`,
  70. reason: "unsupported_message_role_developer",
  71. },
  72. {
  73. name: "image content is bridgeable",
  74. body: `{"model":"grok","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QQ=="}}]}]}`,
  75. want: true,
  76. },
  77. {
  78. name: "text and image parts are bridgeable",
  79. body: `{"model":"grok","messages":[{"role":"user","content":[{"type":"text","text":"what is this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QQ=="}}]}]}`,
  80. want: true,
  81. },
  82. {
  83. name: "text only parts are bridgeable",
  84. body: `{"model":"grok","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`,
  85. want: true,
  86. },
  87. {
  88. name: "unknown content part falls back",
  89. body: `{"model":"grok","messages":[{"role":"user","content":[{"type":"input_audio","input_audio":{"data":"AA=="}}]}]}`,
  90. reason: "unsupported_content_part_input_audio",
  91. },
  92. {
  93. name: "empty content array falls back",
  94. body: `{"model":"grok","messages":[{"role":"user","content":[]}]}`,
  95. reason: "empty_message_content",
  96. },
  97. {
  98. name: "function tools bridge",
  99. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"},"strict":false}}]}`,
  100. want: true,
  101. },
  102. {
  103. name: "legacy functions fall back",
  104. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"functions":[{"name":"lookup","parameters":{"type":"object"}}]}`,
  105. reason: "unsupported_functions",
  106. },
  107. {
  108. name: "automatic tool choice bridges",
  109. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"auto"}`,
  110. want: true,
  111. },
  112. {
  113. name: "required tool choice without tools falls back",
  114. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[],"tool_choice":"required"}`,
  115. reason: "required_tool_choice_without_tools",
  116. },
  117. {
  118. name: "tool history bridges",
  119. body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":"auto","parallel_tool_calls":true}`,
  120. want: true,
  121. },
  122. {
  123. name: "Trae reasoning and indexed tool history bridges",
  124. body: `{"model":"grok","messages":[{"role":"assistant","content":null,"reasoning_content":"I should call lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"summarize"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
  125. want: true,
  126. },
  127. {
  128. name: "reasoning only assistant history bridges",
  129. body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":"Prior reasoning"},{"role":"user","content":"continue"}]}`,
  130. want: true,
  131. },
  132. {
  133. name: "invalid reasoning content falls back",
  134. body: `{"model":"grok","messages":[{"role":"assistant","reasoning_content":{"text":"invalid"}},{"role":"user","content":"continue"}]}`,
  135. reason: "invalid_reasoning_content",
  136. },
  137. {
  138. name: "negative tool call index falls back",
  139. body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"index":-1,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{}"}}]}]}`,
  140. reason: "invalid_tool_call_index",
  141. },
  142. {
  143. name: "unknown tool type falls back",
  144. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{"name":"lookup","parameters":{"type":"object"}}}]}`,
  145. reason: "unsupported_tool_type",
  146. },
  147. {
  148. name: "missing tool schema falls back",
  149. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`,
  150. reason: "invalid_tool_function_parameters",
  151. },
  152. {
  153. name: "named tool choice falls back",
  154. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}}}`,
  155. reason: "unsupported_tool_choice",
  156. },
  157. {
  158. name: "invalid tool call arguments fall back",
  159. body: `{"model":"grok","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{"}}]}]}`,
  160. reason: "invalid_tool_call_arguments",
  161. },
  162. {
  163. name: "tool result without call id falls back",
  164. body: `{"model":"grok","messages":[{"role":"tool","content":"ok"}]}`,
  165. reason: "invalid_tool_call_id",
  166. },
  167. {
  168. name: "non boolean parallel tool calls falls back",
  169. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"parallel_tool_calls":"true"}`,
  170. reason: "invalid_parallel_tool_calls",
  171. },
  172. {
  173. name: "reasoning effort falls back because conversion adds summary",
  174. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`,
  175. reason: "unsupported_reasoning_effort",
  176. },
  177. {
  178. name: "both token limits fall back",
  179. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"max_tokens":256,"max_completion_tokens":256}`,
  180. reason: "conflicting_max_tokens",
  181. },
  182. {
  183. name: "empty message falls back",
  184. body: `{"model":"grok","messages":[{"role":"assistant","content":""},{"role":"user","content":"hi"}]}`,
  185. reason: "empty_message_content",
  186. },
  187. {
  188. name: "empty tool history falls back",
  189. body: `{"model":"grok","messages":[{"role":"assistant","content":"","tool_calls":[]}]}`,
  190. reason: "empty_message_content",
  191. },
  192. {
  193. name: "unknown field falls back",
  194. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"seed":7}`,
  195. reason: "unknown_field_seed",
  196. },
  197. {
  198. name: "small max tokens falls back because conversion clamps it",
  199. body: `{"model":"grok","messages":[{"role":"user","content":"hi"}],"max_tokens":32}`,
  200. reason: "unsafe_max_tokens",
  201. },
  202. }
  203. for _, tt := range tests {
  204. t.Run(tt.name, func(t *testing.T) {
  205. got, reason := grokChatResponsesBridgeEligibility([]byte(tt.body))
  206. require.Equal(t, tt.want, got)
  207. require.Equal(t, tt.reason, reason)
  208. })
  209. }
  210. }
  211. func TestGrokChatResponsesRuntimeEligibility(t *testing.T) {
  212. t.Parallel()
  213. require.True(t, grokChatResponsesRuntimeEligible("grok-4.5", "isolated-id"))
  214. require.True(t, grokChatResponsesRuntimeEligible("grok-4.6", "isolated-id"))
  215. require.True(t, grokChatResponsesRuntimeEligible("grok-4.6-latest", "isolated-id"))
  216. require.False(t, grokChatResponsesRuntimeEligible("grok-4.3", "isolated-id"))
  217. require.False(t, grokChatResponsesRuntimeEligible("grok-4.5-build-free", "isolated-id"))
  218. require.False(t, grokChatResponsesRuntimeEligible("grok-4.5", ""))
  219. require.False(t, grokChatResponsesRuntimeEligible("grok-4.6", ""))
  220. }
  221. func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing.T) {
  222. gin.SetMode(gin.TestMode)
  223. body := []byte(`{"model":"grok","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hi"}],"stream":false,"prompt_cache_key":"stable-session","tools":[],"functions":null,"tool_choice":"none"}`)
  224. recorder := httptest.NewRecorder()
  225. c, _ := gin.CreateTestContext(recorder)
  226. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  227. c.Set("api_key", &APIKey{ID: 7101})
  228. account := grokChatBridgeTestAccount(71)
  229. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  230. accountsByID: map[int64]*Account{account.ID: account},
  231. }}
  232. upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_cache", 9856)}
  233. svc := &OpenAIGatewayService{
  234. httpUpstream: upstream,
  235. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  236. accountRepo: repo,
  237. }
  238. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  239. require.NoError(t, err)
  240. require.NotNil(t, result)
  241. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  242. require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
  243. require.Equal(t, "grok-4.6", result.UpstreamModel)
  244. require.Equal(t, 9908, result.Usage.InputTokens)
  245. require.Equal(t, 12, result.Usage.OutputTokens)
  246. require.Equal(t, 9856, result.Usage.CacheReadInputTokens)
  247. identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()
  248. require.NotEmpty(t, identity)
  249. require.NotEqual(t, "stable-session", identity)
  250. require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader))
  251. require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
  252. require.Equal(t, "x_search", gjson.GetBytes(upstream.lastBody, "tools.1.type").String())
  253. require.Equal(t, grokFreeCacheDisabledToolChoice, gjson.GetBytes(upstream.lastBody, "tool_choice").String())
  254. require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
  255. require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
  256. require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.1.role").String())
  257. require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists())
  258. require.False(t, gjson.GetBytes(upstream.lastBody, "include").Exists())
  259. require.False(t, gjson.GetBytes(upstream.lastBody, "store").Exists())
  260. require.Equal(t, http.StatusOK, recorder.Code)
  261. require.Equal(t, "cached ok", gjson.Get(recorder.Body.String(), "choices.0.message.content").String())
  262. require.Equal(t, int64(9856), gjson.Get(recorder.Body.String(), "usage.prompt_tokens_details.cached_tokens").Int())
  263. require.NotNil(t, repo.updates[account.ID][grokQuotaSnapshotExtraKey])
  264. }
  265. func TestForwardGrokChatViaResponsesNonStreamingRejectsCompletedResponseWithoutUsage(t *testing.T) {
  266. gin.SetMode(gin.TestMode)
  267. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false,"prompt_cache_key":"stable-session"}`)
  268. recorder := httptest.NewRecorder()
  269. c, _ := gin.CreateTestContext(recorder)
  270. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  271. c.Set("api_key", &APIKey{ID: 7102})
  272. account := grokChatBridgeTestAccount(72)
  273. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  274. accountsByID: map[int64]*Account{account.ID: account},
  275. }}
  276. upstreamBody := strings.Join([]string{
  277. `data: {"type":"response.output_text.delta","sequence_number":0,"delta":"ok"}`,
  278. "",
  279. `data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_missing_usage","object":"response","model":"grok-4.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`,
  280. "",
  281. }, "\n")
  282. upstream := &httpUpstreamRecorder{resp: &http.Response{
  283. StatusCode: http.StatusOK,
  284. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  285. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  286. }}
  287. svc := &OpenAIGatewayService{
  288. httpUpstream: upstream,
  289. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  290. accountRepo: repo,
  291. }
  292. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  293. require.Nil(t, result)
  294. var failoverErr *UpstreamFailoverError
  295. require.ErrorAs(t, err, &failoverErr)
  296. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  297. require.Equal(t, grokMissingUsageErrorCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String())
  298. require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client")
  299. require.Empty(t, recorder.Body.String())
  300. }
  301. func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *testing.T) {
  302. gin.SetMode(gin.TestMode)
  303. const conversationID = "codebuddy-session-42"
  304. tests := []struct {
  305. name string
  306. requestID string
  307. messageID string
  308. body []byte
  309. }{
  310. {
  311. name: "first turn",
  312. requestID: "request-one",
  313. messageID: "message-one",
  314. body: []byte(`{"model":"grok","messages":[{"role":"user","content":"first question"}],"stream":false,"tools":[]}`),
  315. },
  316. {
  317. name: "later turn with tools",
  318. requestID: "request-two",
  319. messageID: "message-two",
  320. body: []byte(`{"model":"grok","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"different later question"}],"stream":false,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}}}}}],"tool_choice":"auto"}`),
  321. },
  322. }
  323. var stableIdentity string
  324. for index, tt := range tests {
  325. t.Run(tt.name, func(t *testing.T) {
  326. recorder := httptest.NewRecorder()
  327. c, _ := gin.CreateTestContext(recorder)
  328. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(tt.body))
  329. c.Request.Header.Set(codeBuddyConversationHeader, conversationID)
  330. c.Request.Header.Set("X-Conversation-Request-ID", tt.requestID)
  331. c.Request.Header.Set("X-Conversation-Message-ID", tt.messageID)
  332. c.Request.Header.Set("X-Request-ID", "generic-"+tt.requestID)
  333. c.Set("api_key", &APIKey{ID: 7111})
  334. identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.6")
  335. require.NotEmpty(t, identity)
  336. if index == 0 {
  337. stableIdentity = identity
  338. } else {
  339. require.Equal(t, stableIdentity, identity)
  340. }
  341. require.NotContains(t, identity, conversationID)
  342. account := grokChatBridgeTestAccount(int64(711 + index))
  343. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  344. accountsByID: map[int64]*Account{account.ID: account},
  345. }}
  346. upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_codebuddy_"+strconv.Itoa(index), 4096)}
  347. svc := &OpenAIGatewayService{
  348. httpUpstream: upstream,
  349. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  350. accountRepo: repo,
  351. }
  352. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, tt.body, "", "")
  353. require.NoError(t, err)
  354. require.NotNil(t, result)
  355. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  356. require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
  357. require.Equal(t, identity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
  358. require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader))
  359. })
  360. }
  361. }
  362. func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) {
  363. gin.SetMode(gin.TestMode)
  364. firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`)
  365. body := []byte(`{"model":"grok","messages":[{"role":"system","content":"Be concise"},{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"stream":false,"prompt_cache_key":"trae-session","tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"strict":false}}],"tool_choice":"auto","parallel_tool_calls":true}`)
  366. recorder := httptest.NewRecorder()
  367. c, _ := gin.CreateTestContext(recorder)
  368. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  369. c.Request.Header.Set(grokClientToolCacheOptInHeader, "prefer-cache")
  370. c.Set("api_key", &APIKey{ID: 7151})
  371. account := grokChatBridgeTestAccount(715)
  372. account.Credentials["subscription_tier"] = "free"
  373. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  374. accountsByID: map[int64]*Account{account.ID: account},
  375. }}
  376. upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae", 8192)}
  377. svc := &OpenAIGatewayService{
  378. httpUpstream: upstream,
  379. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  380. accountRepo: repo,
  381. }
  382. firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6")
  383. extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6")
  384. require.NotEmpty(t, firstTurnIdentity)
  385. require.Equal(t, firstTurnIdentity, extendedTurnIdentity)
  386. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  387. require.NoError(t, err)
  388. require.NotNil(t, result)
  389. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  390. require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
  391. require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
  392. require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader))
  393. tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
  394. require.Len(t, tools, 3)
  395. require.Equal(t, "function", tools[0].Get("type").String())
  396. require.Equal(t, "lookup", tools[0].Get("name").String())
  397. require.Equal(t, "Lookup a value", tools[0].Get("description").String())
  398. require.Equal(t, "string", tools[0].Get("parameters.properties.key.type").String())
  399. require.True(t, tools[0].Get("strict").Exists())
  400. require.False(t, tools[0].Get("strict").Bool())
  401. require.Equal(t, "web_search", tools[1].Get("type").String())
  402. require.Equal(t, "x_search", tools[2].Get("type").String())
  403. require.Equal(t, "auto", gjson.GetBytes(upstream.lastBody, "tool_choice").String())
  404. require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
  405. require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
  406. require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.2.call_id").String())
  407. require.Equal(t, "lookup", gjson.GetBytes(upstream.lastBody, "input.2.name").String())
  408. require.Equal(t, `{"key":"alpha"}`, gjson.GetBytes(upstream.lastBody, "input.2.arguments").String())
  409. require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
  410. require.Equal(t, "call_lookup", gjson.GetBytes(upstream.lastBody, "input.3.call_id").String())
  411. require.Equal(t, `{"value":"ok"}`, gjson.GetBytes(upstream.lastBody, "input.3.output").String())
  412. }
  413. func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *testing.T) {
  414. gin.SetMode(gin.TestMode)
  415. firstTurnBody := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`)
  416. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"Find alpha"},{"role":"assistant","content":null,"reasoning_content":"I should use lookup","tool_calls":[{"index":0,"id":"call_lookup","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"alpha\"}"}}]},{"role":"tool","tool_call_id":"call_lookup","content":"{\"value\":\"ok\"}"},{"role":"user","content":"Summarize"}],"instructions":"Return concise JSON","stream":false,"response_format":{"type":"json_object"},"service_tier":"fast","stop":null,"reasoning_effort":null,"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup a value","parameters":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}}}],"tool_choice":"auto","parallel_tool_calls":true}`)
  417. recorder := httptest.NewRecorder()
  418. c, _ := gin.CreateTestContext(recorder)
  419. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  420. c.Set("api_key", &APIKey{ID: 7161})
  421. account := grokChatBridgeTestAccount(716)
  422. account.Credentials["subscription_tier"] = "free"
  423. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  424. accountsByID: map[int64]*Account{account.ID: account},
  425. }}
  426. upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_trae_compat", 12288)}
  427. svc := &OpenAIGatewayService{
  428. httpUpstream: upstream,
  429. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  430. accountRepo: repo,
  431. }
  432. firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6")
  433. extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6")
  434. require.NotEmpty(t, firstTurnIdentity)
  435. require.Equal(t, firstTurnIdentity, extendedTurnIdentity)
  436. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  437. require.NoError(t, err)
  438. require.NotNil(t, result)
  439. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  440. require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
  441. require.Equal(t, 12288, result.Usage.CacheReadInputTokens)
  442. require.Equal(t, extendedTurnIdentity, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
  443. require.Equal(t, extendedTurnIdentity, upstream.lastReq.Header.Get(grokConversationIDHeader))
  444. require.Equal(t, "Return concise JSON", gjson.GetBytes(upstream.lastBody, "instructions").String())
  445. require.Equal(t, "json_object", gjson.GetBytes(upstream.lastBody, "text.format.type").String())
  446. require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String())
  447. require.False(t, gjson.GetBytes(upstream.lastBody, "stop").Exists())
  448. require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning").Exists())
  449. require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content").String(), "<thinking>I should use lookup</thinking>")
  450. require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
  451. require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
  452. tools := gjson.GetBytes(upstream.lastBody, "tools").Array()
  453. require.Len(t, tools, 3)
  454. require.Equal(t, "lookup", tools[0].Get("name").String())
  455. require.Equal(t, "web_search", tools[1].Get("type").String())
  456. require.Equal(t, "x_search", tools[2].Get("type").String())
  457. require.Equal(t, int64(12288), gjson.Get(recorder.Body.String(), "usage.prompt_tokens_details.cached_tokens").Int())
  458. }
  459. func TestForwardGrokChatViaResponsesStreamingPropagatesCachedUsage(t *testing.T) {
  460. gin.SetMode(gin.TestMode)
  461. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":true}`)
  462. recorder := httptest.NewRecorder()
  463. c, _ := gin.CreateTestContext(recorder)
  464. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  465. c.Set("api_key", &APIKey{ID: 7201})
  466. account := grokChatBridgeTestAccount(72)
  467. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  468. accountsByID: map[int64]*Account{account.ID: account},
  469. }}
  470. upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_grok_chat_stream", 4096)}
  471. svc := &OpenAIGatewayService{
  472. httpUpstream: upstream,
  473. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  474. accountRepo: repo,
  475. }
  476. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  477. require.NoError(t, err)
  478. require.NotNil(t, result)
  479. require.True(t, result.Stream)
  480. require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint)
  481. require.Equal(t, 4096, result.Usage.CacheReadInputTokens)
  482. require.Contains(t, recorder.Header().Get("Content-Type"), "text/event-stream")
  483. require.Contains(t, recorder.Body.String(), `"content":"cached ok"`)
  484. require.Contains(t, recorder.Body.String(), `"cached_tokens":4096`)
  485. require.Contains(t, recorder.Body.String(), "data: [DONE]")
  486. }
  487. func TestForwardGrokChatRuntimeGateFallsBackToRaw(t *testing.T) {
  488. gin.SetMode(gin.TestMode)
  489. tests := []struct {
  490. name string
  491. setAPIKey bool
  492. mappedModel string
  493. wantUpstream string
  494. }{
  495. {name: "missing cache identity", wantUpstream: "grok-4.6"},
  496. {name: "non cache capable mapped model", setAPIKey: true, mappedModel: "grok-4.3", wantUpstream: "grok-4.3"},
  497. }
  498. for index, tt := range tests {
  499. t.Run(tt.name, func(t *testing.T) {
  500. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false}`)
  501. recorder := httptest.NewRecorder()
  502. c, _ := gin.CreateTestContext(recorder)
  503. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  504. if tt.setAPIKey {
  505. c.Set("api_key", &APIKey{ID: int64(7301 + index)})
  506. }
  507. account := grokChatBridgeTestAccount(int64(73 + index))
  508. if tt.mappedModel != "" {
  509. account.Credentials["model_mapping"] = map[string]any{"grok": tt.mappedModel}
  510. }
  511. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  512. accountsByID: map[int64]*Account{account.ID: account},
  513. }}
  514. upstream := &httpUpstreamRecorder{resp: &http.Response{
  515. StatusCode: http.StatusOK,
  516. Header: http.Header{"Content-Type": []string{"application/json"}},
  517. Body: io.NopCloser(strings.NewReader(
  518. `{"id":"chat_raw","object":"chat.completion","model":"` + tt.wantUpstream + `","choices":[{"index":0,"message":{"role":"assistant","content":"raw ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`,
  519. )),
  520. }}
  521. svc := &OpenAIGatewayService{
  522. httpUpstream: upstream,
  523. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  524. accountRepo: repo,
  525. }
  526. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  527. require.NoError(t, err)
  528. require.NotNil(t, result)
  529. require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String())
  530. require.Equal(t, grokChatRawEndpoint, result.UpstreamEndpoint)
  531. require.Equal(t, tt.wantUpstream, result.UpstreamModel)
  532. require.False(t, gjson.GetBytes(upstream.lastBody, "tools").Exists())
  533. require.Equal(t, "raw ok", gjson.Get(recorder.Body.String(), "choices.0.message.content").String())
  534. })
  535. }
  536. }
  537. func TestForwardGrokChatViaResponses429UsesGrokRateLimitPolicy(t *testing.T) {
  538. gin.SetMode(gin.TestMode)
  539. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false}`)
  540. recorder := httptest.NewRecorder()
  541. c, _ := gin.CreateTestContext(recorder)
  542. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  543. c.Set("api_key", &APIKey{ID: 7501})
  544. account := grokChatBridgeTestAccount(75)
  545. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  546. accountsByID: map[int64]*Account{account.ID: account},
  547. }}
  548. upstream := &httpUpstreamRecorder{resp: &http.Response{
  549. StatusCode: http.StatusTooManyRequests,
  550. Header: http.Header{
  551. "Content-Type": []string{"application/json"},
  552. "Retry-After": []string{"45"},
  553. },
  554. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
  555. }}
  556. svc := &OpenAIGatewayService{
  557. httpUpstream: upstream,
  558. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  559. accountRepo: repo,
  560. }
  561. before := time.Now()
  562. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  563. require.Error(t, err)
  564. require.Nil(t, result)
  565. var failoverErr *UpstreamFailoverError
  566. require.True(t, errors.As(err, &failoverErr))
  567. require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
  568. require.Equal(t, "45", failoverErr.ResponseHeaders.Get("Retry-After"))
  569. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  570. require.Equal(t, grokChatResponsesEndpoint, GetActualOpenAIUpstreamEndpoint(c))
  571. require.Equal(t, 1, repo.rateLimitedCalls)
  572. require.Zero(t, repo.tempUnschedCalls)
  573. require.WithinDuration(t, before.Add(45*time.Second), repo.lastRateLimitResetAt, time.Second)
  574. require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
  575. }
  576. func TestForwardGrokRawChat429PreservesRetryAfter(t *testing.T) {
  577. gin.SetMode(gin.TestMode)
  578. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false,"stop":"done"}`)
  579. recorder := httptest.NewRecorder()
  580. c, _ := gin.CreateTestContext(recorder)
  581. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  582. c.Set("api_key", &APIKey{ID: 7551})
  583. account := grokChatBridgeTestAccount(755)
  584. account.Credentials["expires_at"] = time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
  585. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  586. accountsByID: map[int64]*Account{account.ID: account},
  587. }}
  588. upstream := &httpUpstreamRecorder{resp: &http.Response{
  589. StatusCode: http.StatusTooManyRequests,
  590. Header: http.Header{
  591. "Content-Type": []string{"application/json"},
  592. "Retry-After": []string{"45"},
  593. },
  594. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
  595. }}
  596. svc := &OpenAIGatewayService{
  597. httpUpstream: upstream,
  598. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  599. accountRepo: repo,
  600. }
  601. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  602. require.Error(t, err)
  603. require.Nil(t, result)
  604. var failoverErr *UpstreamFailoverError
  605. require.ErrorAs(t, err, &failoverErr)
  606. require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
  607. require.Equal(t, "45", failoverErr.ResponseHeaders.Get("Retry-After"))
  608. require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String())
  609. }
  610. func TestForwardGrokRawChatErrorRecordsActualEndpoint(t *testing.T) {
  611. gin.SetMode(gin.TestMode)
  612. body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false,"stop":"done"}`)
  613. recorder := httptest.NewRecorder()
  614. c, _ := gin.CreateTestContext(recorder)
  615. c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body))
  616. c.Set("api_key", &APIKey{ID: 7601})
  617. account := grokChatBridgeTestAccount(76)
  618. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  619. accountsByID: map[int64]*Account{account.ID: account},
  620. }}
  621. upstream := &httpUpstreamRecorder{resp: &http.Response{
  622. StatusCode: http.StatusBadRequest,
  623. Header: http.Header{"Content-Type": []string{"application/json"}},
  624. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"bad request"}}`)),
  625. }}
  626. svc := &OpenAIGatewayService{
  627. httpUpstream: upstream,
  628. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  629. accountRepo: repo,
  630. }
  631. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  632. require.Error(t, err)
  633. require.Nil(t, result)
  634. require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String())
  635. require.Equal(t, grokChatRawEndpoint, GetActualOpenAIUpstreamEndpoint(c))
  636. }
  637. func grokChatBridgeTestAccount(id int64) *Account {
  638. return &Account{
  639. ID: id,
  640. Name: "grok-cache-bridge",
  641. Platform: PlatformGrok,
  642. Type: AccountTypeOAuth,
  643. Status: StatusActive,
  644. Schedulable: true,
  645. Concurrency: 1,
  646. Credentials: map[string]any{
  647. "access_token": "access-token",
  648. "refresh_token": "refresh-token",
  649. "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
  650. "base_url": xai.DefaultCLIBaseURL,
  651. },
  652. }
  653. }
  654. func grokChatBridgeCompletedResponse(responseID string, cachedTokens int) *http.Response {
  655. body := strings.Join([]string{
  656. `data: {"type":"response.output_text.delta","sequence_number":0,"delta":"cached ok"}`,
  657. "",
  658. `data: {"type":"response.completed","sequence_number":1,"response":{"id":"` + responseID + `","object":"response","model":"grok-4.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"cached ok"}]}],"usage":{"input_tokens":9908,"output_tokens":12,"total_tokens":9920,"input_tokens_details":{"cached_tokens":` + strconv.Itoa(cachedTokens) + `}}}}`,
  659. "",
  660. }, "\n")
  661. return &http.Response{
  662. StatusCode: http.StatusOK,
  663. Header: http.Header{
  664. "Content-Type": []string{"text/event-stream"},
  665. "Xai-Request-Id": []string{responseID + "-request"},
  666. "X-Ratelimit-Limit-Requests": []string{"10"},
  667. "X-Ratelimit-Remaining-Requests": []string{"9"},
  668. },
  669. Body: io.NopCloser(strings.NewReader(body)),
  670. }
  671. }