openai_gateway_grok_tool_protocol_test.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. //go:build unit
  2. package service
  3. import (
  4. "bufio"
  5. "bytes"
  6. "context"
  7. "encoding/json"
  8. "fmt"
  9. "io"
  10. "net/http"
  11. "net/http/httptest"
  12. "strings"
  13. "testing"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  17. "github.com/gin-gonic/gin"
  18. "github.com/stretchr/testify/require"
  19. "github.com/tidwall/gjson"
  20. )
  21. func TestPatchGrokResponsesBodyWithClientToolsLowersCodexProtocol(t *testing.T) {
  22. t.Parallel()
  23. body := grokClientToolProtocolRequest(false)
  24. patched, mapping, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5")
  25. require.NoError(t, err)
  26. require.True(t, json.Valid(patched))
  27. require.True(t, mapping.CustomTools["apply_patch"])
  28. require.True(t, mapping.ToolSearch)
  29. require.Equal(t, "collaboration", mapping.NamespaceTools["collaboration__send_message"].Namespace)
  30. require.Equal(t, "send_message", mapping.NamespaceTools["collaboration__send_message"].Name)
  31. tools := gjson.GetBytes(patched, "tools").Array()
  32. require.Len(t, tools, 3)
  33. require.Equal(t, "function", tools[0].Get("type").String())
  34. require.Equal(t, "apply_patch", tools[0].Get("name").String())
  35. require.Equal(t, "string", tools[0].Get("parameters.properties.input.type").String())
  36. require.False(t, tools[0].Get("format").Exists())
  37. require.Equal(t, "function", tools[1].Get("type").String())
  38. require.Equal(t, "tool_search", tools[1].Get("name").String())
  39. require.Equal(t, "function", tools[2].Get("type").String())
  40. require.Equal(t, "collaboration__send_message", tools[2].Get("name").String())
  41. require.False(t, gjson.GetBytes(patched, `tools.#(type=="custom")`).Exists())
  42. require.False(t, gjson.GetBytes(patched, `tools.#(type=="namespace")`).Exists())
  43. require.False(t, gjson.GetBytes(patched, `tools.#(type=="tool_search")`).Exists())
  44. require.Equal(t, "function", gjson.GetBytes(patched, "tool_choice.type").String())
  45. require.Equal(t, "apply_patch", gjson.GetBytes(patched, "tool_choice.name").String())
  46. require.Equal(t, "function_call", gjson.GetBytes(patched, "input.0.type").String())
  47. require.JSONEq(t, `{"input":"*** Begin Patch"}`, gjson.GetBytes(patched, "input.0.arguments").String())
  48. require.False(t, gjson.GetBytes(patched, "input.0.input").Exists())
  49. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.1.type").String())
  50. require.Equal(t, "function_call", gjson.GetBytes(patched, "input.2.type").String())
  51. require.Equal(t, "tool_search", gjson.GetBytes(patched, "input.2.name").String())
  52. require.JSONEq(t, `{"query":"github"}`, gjson.GetBytes(patched, "input.2.arguments").String())
  53. require.False(t, gjson.GetBytes(patched, "input.2.execution").Exists())
  54. require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.3.type").String())
  55. require.JSONEq(t, `{"groups":["github"]}`, gjson.GetBytes(patched, "input.3.output").String())
  56. require.Equal(t, "function_call", gjson.GetBytes(patched, "input.4.type").String())
  57. require.Equal(t, "collaboration__send_message", gjson.GetBytes(patched, "input.4.name").String())
  58. require.False(t, gjson.GetBytes(patched, "input.4.namespace").Exists())
  59. }
  60. func TestPatchGrokResponsesBodyWithClientToolsLowersDiscoveredToolsOutput(t *testing.T) {
  61. t.Parallel()
  62. body := []byte(`{
  63. "model":"grok-4.5",
  64. "tools":[{"type":"tool_search"}],
  65. "input":[
  66. {"type":"tool_search_call","id":"tsc_fixture","call_id":"call_fixture","arguments":{"query":"subagent"},"execution":"client","status":"completed"},
  67. {"type":"tool_search_output","id":"tso_fixture","call_id":"call_fixture","execution":"client","status":"completed","tools":[
  68. {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
  69. {"type":"namespace","name":"multi_agent_v1","tools":[
  70. {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
  71. {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}}
  72. ]}
  73. ]}
  74. ]
  75. }`)
  76. patched, mapping, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5")
  77. require.NoError(t, err)
  78. require.True(t, mapping.ToolSearch)
  79. require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
  80. require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, mapping.NamespaceTools["multi_agent_v1__wait_agent"])
  81. output := gjson.GetBytes(patched, "input.1.output").String()
  82. require.JSONEq(t, `[
  83. {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
  84. {"type":"namespace","name":"multi_agent_v1","tools":[
  85. {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
  86. {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}}
  87. ]}
  88. ]`, output)
  89. require.JSONEq(t, `{
  90. "model":"grok-4.5",
  91. "tools":[
  92. {"type":"function","name":"tool_search","description":"Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}},
  93. {"type":"function","name":"codex_app__load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}},
  94. {"type":"function","name":"multi_agent_v1__spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
  95. {"type":"function","name":"multi_agent_v1__wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}}
  96. ],
  97. "input":[
  98. {"type":"function_call","call_id":"call_fixture","name":"tool_search","arguments":"{\"query\":\"subagent\"}"},
  99. {"type":"function_call_output","call_id":"call_fixture","output":`+string(mustMarshalJSONForTest(t, output))+`}
  100. ]
  101. }`, string(patched))
  102. }
  103. func mustMarshalJSONForTest(t *testing.T, value string) []byte {
  104. t.Helper()
  105. encoded, err := json.Marshal(value)
  106. require.NoError(t, err)
  107. return encoded
  108. }
  109. func TestPatchGrokResponsesBodyWithClientToolsRewritesEveryToolChoice(t *testing.T) {
  110. t.Parallel()
  111. tests := []struct {
  112. name string
  113. choice string
  114. wantName string
  115. wantType string
  116. wantNoNS bool
  117. }{
  118. {
  119. name: "custom",
  120. choice: `{"type":"custom","name":"apply_patch"}`,
  121. wantName: "apply_patch",
  122. wantType: "function",
  123. },
  124. {
  125. name: "tool search",
  126. choice: `{"type":"tool_search"}`,
  127. wantName: "tool_search",
  128. wantType: "function",
  129. },
  130. {
  131. name: "namespace function",
  132. choice: `{"type":"function","namespace":"collaboration","name":"send_message"}`,
  133. wantName: "collaboration__send_message",
  134. wantType: "function",
  135. wantNoNS: true,
  136. },
  137. }
  138. for _, tt := range tests {
  139. t.Run(tt.name, func(t *testing.T) {
  140. t.Parallel()
  141. body := []byte(fmt.Sprintf(`{
  142. "model":"grok","input":"hello",
  143. "tools":[
  144. {"type":"custom","name":"apply_patch"},
  145. {"type":"tool_search"},
  146. {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"send_message","parameters":{"type":"object"}}]}
  147. ],
  148. "tool_choice":%s
  149. }`, tt.choice))
  150. patched, _, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5")
  151. require.NoError(t, err)
  152. require.Equal(t, tt.wantType, gjson.GetBytes(patched, "tool_choice.type").String())
  153. require.Equal(t, tt.wantName, gjson.GetBytes(patched, "tool_choice.name").String())
  154. if tt.wantNoNS {
  155. require.False(t, gjson.GetBytes(patched, "tool_choice.namespace").Exists())
  156. }
  157. })
  158. }
  159. }
  160. func TestPatchGrokResponsesBodyWithClientToolsRejectsTrailingJSONDocument(t *testing.T) {
  161. t.Parallel()
  162. body := []byte(`{"model":"grok","input":"hello","tools":[{"type":"custom","name":"apply_patch"}]} {"ignored":true}`)
  163. patched, mapping, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5")
  164. require.Error(t, err)
  165. require.Contains(t, strings.ToLower(err.Error()), "invalid json")
  166. require.Nil(t, patched)
  167. require.Empty(t, mapping.CustomTools)
  168. }
  169. func TestClearGrokResponsesClientToolMappingRemovesStaleContextState(t *testing.T) {
  170. recorder := httptest.NewRecorder()
  171. c, _ := gin.CreateTestContext(recorder)
  172. setGrokResponsesClientToolMapping(c, apicompat.ResponsesClientToolMapping{
  173. CustomTools: map[string]bool{"stale_tool": true},
  174. })
  175. _, seeded := grokResponsesClientToolMapping(c)
  176. require.True(t, seeded)
  177. clearGrokResponsesClientToolMapping(c)
  178. _, remains := grokResponsesClientToolMapping(c)
  179. require.False(t, remains)
  180. }
  181. func TestForwardGrokResponsesClientToolNameConflictReturns400(t *testing.T) {
  182. gin.SetMode(gin.TestMode)
  183. body := []byte(`{
  184. "model":"grok","stream":false,"input":"hello",
  185. "tools":[
  186. {"type":"custom","name":"duplicate"},
  187. {"type":"function","name":"duplicate","parameters":{"type":"object"}}
  188. ]
  189. }`)
  190. recorder := httptest.NewRecorder()
  191. c, _ := gin.CreateTestContext(recorder)
  192. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  193. upstream := &httpUpstreamRecorder{}
  194. svc := &OpenAIGatewayService{httpUpstream: upstream}
  195. account := grokProtocolAPIKeyAccount(7101)
  196. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
  197. require.Error(t, err)
  198. require.Nil(t, result)
  199. require.Equal(t, http.StatusBadRequest, recorder.Code)
  200. require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
  201. require.Equal(t, "tools", gjson.Get(recorder.Body.String(), "error.param").String())
  202. require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "conflicts")
  203. require.Empty(t, upstream.requests, "an ambiguous request must not reach xAI")
  204. }
  205. func TestForwardGrokResponsesMalformedToolSearchOutputReturns400BeforeUpstream(t *testing.T) {
  206. gin.SetMode(gin.TestMode)
  207. body := []byte(`{
  208. "model":"grok","stream":false,
  209. "tools":[{"type":"tool_search"}],
  210. "input":[{"type":"tool_search_output","status":"completed"}]
  211. }`)
  212. recorder := httptest.NewRecorder()
  213. c, _ := gin.CreateTestContext(recorder)
  214. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  215. upstream := &httpUpstreamRecorder{}
  216. svc := &OpenAIGatewayService{httpUpstream: upstream}
  217. account := grokProtocolAPIKeyAccount(7103)
  218. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
  219. require.Error(t, err)
  220. require.Nil(t, result)
  221. require.Equal(t, http.StatusBadRequest, recorder.Code)
  222. require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
  223. require.Equal(t, "tools", gjson.Get(recorder.Body.String(), "error.param").String())
  224. require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "call_id")
  225. require.Empty(t, upstream.requests, "malformed lowered output must not reach xAI")
  226. }
  227. func TestForwardGrokResponsesOAuthRestoresClientToolsNonStreaming(t *testing.T) {
  228. gin.SetMode(gin.TestMode)
  229. body := grokClientToolProtocolRequest(false)
  230. recorder := httptest.NewRecorder()
  231. c, _ := gin.CreateTestContext(recorder)
  232. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  233. c.Request.Header.Set("Content-Type", "application/json")
  234. c.Set("api_key", &APIKey{ID: 7102})
  235. account := grokProtocolOAuthAccount(7102)
  236. repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
  237. accountsByID: map[int64]*Account{account.ID: account},
  238. }}
  239. upstream := &httpUpstreamRecorder{resp: &http.Response{
  240. StatusCode: http.StatusOK,
  241. Header: http.Header{
  242. "Content-Type": []string{"application/json"},
  243. "Xai-Request-Id": []string{"protocol-oauth"},
  244. },
  245. Body: io.NopCloser(strings.NewReader(`{
  246. "id":"resp_protocol_oauth","object":"response","model":"grok-4.5","status":"completed",
  247. "output":[
  248. {"type":"function_call","id":"item_custom","call_id":"call_custom","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}","namespace":"must_not_leak"},
  249. {"type":"function_call","id":"item_search","call_id":"call_search","name":"tool_search","arguments":"{\"query\":\"github\"}"},
  250. {"type":"function_call","id":"item_namespace","call_id":"call_namespace","name":"collaboration__send_message","arguments":"{\"target\":\"root\"}"}
  251. ],
  252. "usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}
  253. }`)),
  254. }}
  255. svc := &OpenAIGatewayService{
  256. httpUpstream: upstream,
  257. grokTokenProvider: NewGrokTokenProvider(repo, nil),
  258. accountRepo: repo,
  259. }
  260. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
  261. require.NoError(t, err)
  262. require.NotNil(t, result)
  263. require.False(t, result.Stream)
  264. require.Equal(t, "resp_protocol_oauth", result.ResponseID)
  265. require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
  266. require.Equal(t, "Bearer oauth-protocol-token", upstream.lastReq.Header.Get("Authorization"))
  267. assertGrokProtocolRequestLowered(t, upstream.lastBody)
  268. response := recorder.Body.Bytes()
  269. require.Equal(t, "custom_tool_call", gjson.GetBytes(response, "output.0.type").String())
  270. require.Equal(t, "*** Begin Patch", gjson.GetBytes(response, "output.0.input").String())
  271. require.False(t, gjson.GetBytes(response, "output.0.arguments").Exists())
  272. require.False(t, gjson.GetBytes(response, "output.0.namespace").Exists())
  273. require.Equal(t, "tool_search_call", gjson.GetBytes(response, "output.1.type").String())
  274. require.Equal(t, "client", gjson.GetBytes(response, "output.1.execution").String())
  275. require.Equal(t, "github", gjson.GetBytes(response, "output.1.arguments.query").String())
  276. require.False(t, gjson.GetBytes(response, "output.1.name").Exists())
  277. require.Equal(t, "function_call", gjson.GetBytes(response, "output.2.type").String())
  278. require.Equal(t, "collaboration", gjson.GetBytes(response, "output.2.namespace").String())
  279. require.Equal(t, "send_message", gjson.GetBytes(response, "output.2.name").String())
  280. }
  281. func TestForwardGrokResponsesAPIKeyRestoresClientToolsFromSSEForNonStreamingRequest(t *testing.T) {
  282. gin.SetMode(gin.TestMode)
  283. body := grokClientToolProtocolRequest(false)
  284. recorder := httptest.NewRecorder()
  285. c, _ := gin.CreateTestContext(recorder)
  286. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  287. c.Request.Header.Set("Content-Type", "application/json")
  288. upstream := &httpUpstreamRecorder{resp: &http.Response{
  289. StatusCode: http.StatusOK,
  290. Header: http.Header{
  291. "Content-Type": []string{"text/event-stream"},
  292. "Xai-Request-Id": []string{"protocol-api-key-sse-nonstream"},
  293. },
  294. Body: io.NopCloser(strings.NewReader(grokProtocolUpstreamSSE())),
  295. }}
  296. svc := &OpenAIGatewayService{httpUpstream: upstream}
  297. account := grokProtocolAPIKeyAccount(7104)
  298. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now())
  299. require.NoError(t, err)
  300. require.NotNil(t, result)
  301. require.False(t, result.Stream)
  302. require.Equal(t, "resp_protocol_stream", result.ResponseID)
  303. assertGrokProtocolRequestLowered(t, upstream.lastBody)
  304. response := recorder.Body.Bytes()
  305. require.True(t, json.Valid(response))
  306. require.Equal(t, "custom_tool_call", gjson.GetBytes(response, "output.0.type").String())
  307. require.Equal(t, "*** Begin Patch", gjson.GetBytes(response, "output.0.input").String())
  308. require.Equal(t, "tool_search_call", gjson.GetBytes(response, "output.1.type").String())
  309. require.Equal(t, "client", gjson.GetBytes(response, "output.1.execution").String())
  310. require.Equal(t, "collaboration", gjson.GetBytes(response, "output.2.namespace").String())
  311. require.Equal(t, "send_message", gjson.GetBytes(response, "output.2.name").String())
  312. }
  313. func TestForwardGrokResponsesAPIKeyRestoresClientToolsStreaming(t *testing.T) {
  314. gin.SetMode(gin.TestMode)
  315. body := grokClientToolProtocolRequest(true)
  316. recorder := httptest.NewRecorder()
  317. c, _ := gin.CreateTestContext(recorder)
  318. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  319. c.Request.Header.Set("Content-Type", "application/json")
  320. upstream := &httpUpstreamRecorder{resp: &http.Response{
  321. StatusCode: http.StatusOK,
  322. Header: http.Header{
  323. "Content-Type": []string{"text/event-stream"},
  324. "Xai-Request-Id": []string{"protocol-api-key"},
  325. },
  326. Body: io.NopCloser(strings.NewReader(grokProtocolUpstreamSSE())),
  327. }}
  328. svc := &OpenAIGatewayService{httpUpstream: upstream}
  329. account := grokProtocolAPIKeyAccount(7103)
  330. result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", true, time.Now())
  331. require.NoError(t, err)
  332. require.NotNil(t, result)
  333. require.True(t, result.Stream)
  334. require.Equal(t, "resp_protocol_stream", result.ResponseID)
  335. require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String())
  336. require.Equal(t, "Bearer xai-protocol-key", upstream.lastReq.Header.Get("Authorization"))
  337. assertGrokProtocolRequestLowered(t, upstream.lastBody)
  338. frames := parseGrokProtocolSSEFrames(t, recorder.Body.String())
  339. require.NotEmpty(t, frames)
  340. for index, frame := range frames {
  341. require.Equal(t, frame.event, gjson.GetBytes(frame.data, "type").String(), "SSE event field must follow the restored data.type")
  342. require.Equal(t, 40+index, int(gjson.GetBytes(frame.data, "sequence_number").Int()), "sequence_number must be continuous after suppressed and expanded events")
  343. }
  344. created := requireGrokProtocolFrame(t, frames, "response.created", "", "")
  345. require.True(t, gjson.GetBytes(created.data, "upstream_extension.preserved").Bool())
  346. customAdded := requireGrokProtocolFrame(t, frames, "response.output_item.added", "item.type", "custom_tool_call")
  347. require.Equal(t, "apply_patch", gjson.GetBytes(customAdded.data, "item.name").String())
  348. customInputDelta := requireGrokProtocolFrame(t, frames, "response.custom_tool_call_input.delta", "", "")
  349. require.Equal(t, "*** Begin Patch", gjson.GetBytes(customInputDelta.data, "delta").String())
  350. customInputDone := requireGrokProtocolFrame(t, frames, "response.custom_tool_call_input.done", "", "")
  351. require.Equal(t, "*** Begin Patch", gjson.GetBytes(customInputDone.data, "input").String())
  352. customDone := requireGrokProtocolFrame(t, frames, "response.output_item.done", "item.type", "custom_tool_call")
  353. require.Equal(t, "*** Begin Patch", gjson.GetBytes(customDone.data, "item.input").String())
  354. namespaceAdded := requireGrokProtocolFrame(t, frames, "response.output_item.added", "item.namespace", "collaboration")
  355. require.Equal(t, "send_message", gjson.GetBytes(namespaceAdded.data, "item.name").String())
  356. namespaceDone := requireGrokProtocolFrame(t, frames, "response.output_item.done", "item.namespace", "collaboration")
  357. require.Equal(t, "send_message", gjson.GetBytes(namespaceDone.data, "item.name").String())
  358. namespaceArgumentsDone := requireGrokProtocolFrame(t, frames, "response.function_call_arguments.done", "name", "send_message")
  359. require.Equal(t, "response.function_call_arguments.done", gjson.GetBytes(namespaceArgumentsDone.data, "type").String())
  360. require.False(t, gjson.GetBytes(namespaceArgumentsDone.data, "namespace").Exists())
  361. searchAdded := requireGrokProtocolFrame(t, frames, "response.output_item.added", "item.type", "tool_search_call")
  362. require.Equal(t, "client", gjson.GetBytes(searchAdded.data, "item.execution").String())
  363. searchDone := requireGrokProtocolFrame(t, frames, "response.output_item.done", "item.type", "tool_search_call")
  364. require.Equal(t, "github", gjson.GetBytes(searchDone.data, "item.arguments.query").String())
  365. for _, frame := range frames {
  366. itemID := gjson.GetBytes(frame.data, "item_id").String()
  367. if itemID == "item_custom" || itemID == "item_search" {
  368. require.NotContains(t, frame.event, "function_call_arguments", "client-only proxy argument events must not leak")
  369. }
  370. }
  371. completed := requireGrokProtocolFrame(t, frames, "response.completed", "", "")
  372. require.Equal(t, "custom_tool_call", gjson.GetBytes(completed.data, "response.output.0.type").String())
  373. require.Equal(t, "tool_search_call", gjson.GetBytes(completed.data, "response.output.1.type").String())
  374. require.Equal(t, "collaboration", gjson.GetBytes(completed.data, "response.output.2.namespace").String())
  375. }
  376. func TestGrokResponsesClientToolStreamBodyFlushesFrameBeforeEOF(t *testing.T) {
  377. sourceReader, sourceWriter := io.Pipe()
  378. body := newGrokResponsesClientToolStreamBody(sourceReader, apicompat.ResponsesClientToolMapping{
  379. CustomTools: map[string]bool{"apply_patch": true},
  380. }, defaultMaxLineSize)
  381. defer func() { _ = body.Close() }()
  382. defer func() { _ = sourceWriter.Close() }()
  383. type readResult struct {
  384. frame string
  385. err error
  386. }
  387. read := make(chan readResult, 1)
  388. go func() {
  389. reader := bufio.NewReader(body)
  390. var frame strings.Builder
  391. for {
  392. line, err := reader.ReadString('\n')
  393. if err != nil {
  394. read <- readResult{err: err}
  395. return
  396. }
  397. frame.WriteString(line)
  398. if strings.TrimSpace(line) == "" {
  399. read <- readResult{frame: frame.String()}
  400. return
  401. }
  402. }
  403. }()
  404. firstFrame := "event: response.created\n" +
  405. `data: {"type":"response.created","sequence_number":0,"response":{"id":"flush-before-eof"}}` + "\n\n"
  406. _, err := sourceWriter.Write([]byte(firstFrame))
  407. require.NoError(t, err)
  408. select {
  409. case result := <-read:
  410. require.NoError(t, result.err)
  411. require.Contains(t, result.frame, "flush-before-eof")
  412. require.Contains(t, result.frame, "event: response.created")
  413. case <-time.After(3 * time.Second):
  414. t.Fatal("first transformed SSE frame was not flushed while the upstream connection remained open")
  415. }
  416. }
  417. type grokProtocolSSEFrame struct {
  418. event string
  419. data []byte
  420. }
  421. func grokClientToolProtocolRequest(stream bool) []byte {
  422. return []byte(fmt.Sprintf(`{
  423. "model":"grok","stream":%t,
  424. "tools":[
  425. {"type":"custom","name":"apply_patch","description":"apply a patch","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}},
  426. {"type":"tool_search"},
  427. {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"send_message","description":"send a message","parameters":{"type":"object","properties":{"target":{"type":"string"}}}}]}
  428. ],
  429. "tool_choice":{"type":"custom","name":"apply_patch"},
  430. "input":[
  431. {"type":"custom_tool_call","id":"old_custom","call_id":"old_custom_call","name":"apply_patch","input":"*** Begin Patch"},
  432. {"type":"custom_tool_call_output","call_id":"old_custom_call","output":"Done!"},
  433. {"type":"tool_search_call","id":"old_search","call_id":"old_search_call","arguments":{"query":"github"},"execution":"client"},
  434. {"type":"tool_search_output","call_id":"old_search_call","output":{"groups":["github"]}},
  435. {"type":"function_call","id":"old_namespace","call_id":"old_namespace_call","namespace":"collaboration","name":"send_message","arguments":"{\"target\":\"root\"}"},
  436. {"type":"function_call_output","call_id":"old_namespace_call","output":"ok"},
  437. {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
  438. ]
  439. }`, stream))
  440. }
  441. func grokProtocolOAuthAccount(id int64) *Account {
  442. return &Account{
  443. ID: id, Name: "grok-oauth-protocol", Platform: PlatformGrok, Type: AccountTypeOAuth,
  444. Status: StatusActive, Schedulable: true, Concurrency: 1,
  445. Credentials: map[string]any{
  446. "access_token": "oauth-protocol-token", "refresh_token": "refresh-token",
  447. "expires_at": time.Now().Add(2 * grokTokenRefreshSkew).UTC().Format(time.RFC3339),
  448. "base_url": xai.DefaultCLIBaseURL, "subscription_tier": "supergrok",
  449. },
  450. }
  451. }
  452. func grokProtocolAPIKeyAccount(id int64) *Account {
  453. return &Account{
  454. ID: id, Name: "grok-api-key-protocol", Platform: PlatformGrok, Type: AccountTypeAPIKey,
  455. Status: StatusActive, Schedulable: true, Concurrency: 1,
  456. Credentials: map[string]any{"api_key": "xai-protocol-key", "base_url": "https://api.x.ai/v1"},
  457. }
  458. }
  459. func assertGrokProtocolRequestLowered(t *testing.T, body []byte) {
  460. t.Helper()
  461. require.True(t, json.Valid(body))
  462. require.False(t, gjson.GetBytes(body, `tools.#(type=="custom")`).Exists())
  463. require.False(t, gjson.GetBytes(body, `tools.#(type=="namespace")`).Exists())
  464. require.False(t, gjson.GetBytes(body, `tools.#(type=="tool_search")`).Exists())
  465. require.True(t, gjson.GetBytes(body, `tools.#(name=="apply_patch")`).Exists())
  466. require.True(t, gjson.GetBytes(body, `tools.#(name=="tool_search")`).Exists())
  467. require.True(t, gjson.GetBytes(body, `tools.#(name=="collaboration__send_message")`).Exists())
  468. require.Equal(t, "function", gjson.GetBytes(body, "tool_choice.type").String())
  469. require.Equal(t, "apply_patch", gjson.GetBytes(body, "tool_choice.name").String())
  470. require.Equal(t, "function_call", gjson.GetBytes(body, "input.0.type").String())
  471. require.Equal(t, "function_call_output", gjson.GetBytes(body, "input.1.type").String())
  472. require.Equal(t, "function_call", gjson.GetBytes(body, "input.2.type").String())
  473. require.Equal(t, "tool_search", gjson.GetBytes(body, "input.2.name").String())
  474. require.Equal(t, "function_call_output", gjson.GetBytes(body, "input.3.type").String())
  475. require.Equal(t, "collaboration__send_message", gjson.GetBytes(body, "input.4.name").String())
  476. require.False(t, gjson.GetBytes(body, "input.4.namespace").Exists())
  477. }
  478. func grokProtocolUpstreamSSE() string {
  479. events := []string{
  480. `{"type":"response.created","sequence_number":40,"response":{"id":"resp_protocol_stream","model":"grok-4.5"},"upstream_extension":{"preserved":true}}`,
  481. `{"type":"response.output_item.added","sequence_number":41,"output_index":0,"item":{"type":"function_call","id":"item_custom","call_id":"call_custom","name":"apply_patch","arguments":"","status":"in_progress"}}`,
  482. `{"type":"response.function_call_arguments.delta","sequence_number":42,"output_index":0,"item_id":"item_custom","delta":"{\"input\":\"*** Begin"}`,
  483. `{"type":"response.function_call_arguments.delta","sequence_number":43,"output_index":0,"item_id":"item_custom","delta":" Patch\"}"}`,
  484. `{"type":"response.function_call_arguments.done","sequence_number":44,"output_index":0,"item_id":"item_custom","call_id":"call_custom","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}`,
  485. `{"type":"response.output_item.done","sequence_number":45,"output_index":0,"item":{"type":"function_call","id":"item_custom","call_id":"call_custom","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}","status":"completed"}}`,
  486. `{"type":"response.output_item.added","sequence_number":46,"output_index":1,"item":{"type":"function_call","id":"item_namespace","call_id":"call_namespace","name":"collaboration__send_message","arguments":"","status":"in_progress"}}`,
  487. `{"type":"response.function_call_arguments.done","sequence_number":47,"output_index":1,"item_id":"item_namespace","call_id":"call_namespace","name":"collaboration__send_message","arguments":"{\"target\":\"root\"}"}`,
  488. `{"type":"response.output_item.done","sequence_number":48,"output_index":1,"item":{"type":"function_call","id":"item_namespace","call_id":"call_namespace","name":"collaboration__send_message","arguments":"{\"target\":\"root\"}","status":"completed"}}`,
  489. `{"type":"response.output_item.added","sequence_number":49,"output_index":2,"item":{"type":"function_call","id":"item_search","call_id":"call_search","name":"tool_search","arguments":"","status":"in_progress"}}`,
  490. `{"type":"response.function_call_arguments.delta","sequence_number":50,"output_index":2,"item_id":"item_search","delta":"{\"query\":\"github\"}"}`,
  491. `{"type":"response.function_call_arguments.done","sequence_number":51,"output_index":2,"item_id":"item_search","call_id":"call_search","name":"tool_search","arguments":"{\"query\":\"github\"}"}`,
  492. `{"type":"response.output_item.done","sequence_number":52,"output_index":2,"item":{"type":"function_call","id":"item_search","call_id":"call_search","name":"tool_search","arguments":"{\"query\":\"github\"}","status":"completed"}}`,
  493. `{"type":"response.completed","sequence_number":53,"response":{"id":"resp_protocol_stream","object":"response","model":"grok-4.5","status":"completed","output":[{"type":"function_call","id":"item_custom","call_id":"call_custom","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"},{"type":"function_call","id":"item_search","call_id":"call_search","name":"tool_search","arguments":"{\"query\":\"github\"}"},{"type":"function_call","id":"item_namespace","call_id":"call_namespace","name":"collaboration__send_message","arguments":"{\"target\":\"root\"}"}],"usage":{"input_tokens":11,"output_tokens":4,"total_tokens":15}}}`,
  494. }
  495. var out strings.Builder
  496. for _, event := range events {
  497. typ := gjson.Get(event, "type").String()
  498. fmt.Fprintf(&out, "event: %s\ndata: %s\n\n", typ, event)
  499. }
  500. return out.String()
  501. }
  502. func parseGrokProtocolSSEFrames(t *testing.T, body string) []grokProtocolSSEFrame {
  503. t.Helper()
  504. var frames []grokProtocolSSEFrame
  505. event := ""
  506. for _, rawLine := range strings.Split(body, "\n") {
  507. line := strings.TrimSuffix(rawLine, "\r")
  508. if value, ok := extractOpenAISSEEventLine(line); ok {
  509. event = strings.TrimSpace(value)
  510. continue
  511. }
  512. data, ok := extractOpenAISSEDataLine(line)
  513. if !ok || strings.TrimSpace(data) == "[DONE]" {
  514. continue
  515. }
  516. require.NotEmpty(t, event, "every data frame from this upstream should retain an event field")
  517. require.JSONEq(t, data, data)
  518. frames = append(frames, grokProtocolSSEFrame{event: event, data: []byte(data)})
  519. event = ""
  520. }
  521. return frames
  522. }
  523. func requireGrokProtocolFrame(t *testing.T, frames []grokProtocolSSEFrame, eventType, path, value string) grokProtocolSSEFrame {
  524. t.Helper()
  525. for _, frame := range frames {
  526. if frame.event != eventType {
  527. continue
  528. }
  529. if path == "" || gjson.GetBytes(frame.data, path).String() == value {
  530. return frame
  531. }
  532. }
  533. t.Fatalf("missing SSE frame event=%q %s=%q", eventType, path, value)
  534. return grokProtocolSSEFrame{}
  535. }