openai_responses_lite_tools_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools(t *testing.T) {
  17. reqBody := map[string]any{
  18. "model": "gpt-5.6-terra",
  19. "tools": []any{
  20. map[string]any{"type": "function", "name": "shell"},
  21. map[string]any{"type": "custom", "name": "exec"},
  22. map[string]any{"type": "tool_search"},
  23. map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
  24. map[string]any{"type": "function", "name": "spawn_agent"},
  25. }},
  26. },
  27. "input": []any{
  28. map[string]any{"type": "message", "role": "user", "content": "hello"},
  29. map[string]any{"type": "additional_tools", "role": "developer", "tools": []any{
  30. map[string]any{"type": "namespace", "name": "image_gen"},
  31. map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
  32. map[string]any{"type": "function", "name": "spawn_agent"},
  33. }},
  34. }},
  35. },
  36. "tool_choice": map[string]any{"type": "namespace", "name": "collaboration"},
  37. }
  38. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  39. require.NoError(t, err)
  40. require.True(t, changed)
  41. tools := reqBody["tools"].([]any)
  42. require.Len(t, tools, 3)
  43. require.Equal(t, "function", tools[0].(map[string]any)["type"])
  44. require.Equal(t, "custom", tools[1].(map[string]any)["type"])
  45. require.Equal(t, "tool_search", tools[2].(map[string]any)["type"])
  46. input := reqBody["input"].([]any)
  47. require.Len(t, input, 2)
  48. additional := input[1].(map[string]any)["tools"].([]any)
  49. require.Len(t, additional, 2)
  50. require.Equal(t, "image_gen", additional[0].(map[string]any)["name"])
  51. require.Equal(t, "collaboration", additional[1].(map[string]any)["name"], "existing namespace must not be duplicated")
  52. require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"])
  53. }
  54. func TestNormalizeOpenAIResponsesLiteTools_PreservesDeferredFlagsWithToolSearch(t *testing.T) {
  55. reqBody := map[string]any{
  56. "tools": []any{
  57. map[string]any{"type": "tool_search"},
  58. map[string]any{"type": "function", "name": "shell", "defer_loading": true},
  59. },
  60. }
  61. _, err := normalizeOpenAIResponsesLiteTools(reqBody)
  62. require.NoError(t, err)
  63. tools := reqBody["tools"].([]any)
  64. require.Equal(t, "tool_search", tools[0].(map[string]any)["type"])
  65. require.Equal(t, true, tools[1].(map[string]any)["defer_loading"])
  66. }
  67. func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) {
  68. reqBody := map[string]any{
  69. "tools": []any{map[string]any{
  70. "type": "namespace",
  71. "name": "collaboration",
  72. "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
  73. }},
  74. "input": []any{map[string]any{
  75. "type": "additional_tools",
  76. "tools": []any{map[string]any{
  77. "type": "namespace",
  78. "name": "collaboration",
  79. "tools": []any{map[string]any{"type": "function", "name": "send_message"}},
  80. }},
  81. }},
  82. }
  83. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  84. require.ErrorContains(t, err, `conflicts with migrated tool type "namespace" name "collaboration"`)
  85. require.False(t, changed)
  86. require.Len(t, reqBody["tools"], 1, "conflicts must not partially remove top-level tools")
  87. }
  88. func TestNormalizeOpenAIResponsesLiteTools_DeduplicatesAcrossAdditionalToolItems(t *testing.T) {
  89. namespace := map[string]any{
  90. "type": "namespace",
  91. "name": "collaboration",
  92. "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
  93. }
  94. reqBody := map[string]any{
  95. "tools": []any{namespace},
  96. "input": []any{
  97. map[string]any{
  98. "type": "additional_tools",
  99. "tools": []any{map[string]any{"type": "custom", "name": "exec"}},
  100. },
  101. map[string]any{
  102. "type": "additional_tools",
  103. "tools": []any{namespace},
  104. },
  105. },
  106. }
  107. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  108. require.NoError(t, err)
  109. require.True(t, changed)
  110. require.NotContains(t, reqBody, "tools")
  111. input := reqBody["input"].([]any)
  112. require.Len(t, input[0].(map[string]any)["tools"], 1)
  113. require.Len(t, input[1].(map[string]any)["tools"], 1)
  114. }
  115. func TestNormalizeOpenAIResponsesLiteTools_ConvertsStringInput(t *testing.T) {
  116. reqBody := map[string]any{
  117. "input": "hello",
  118. "tools": []any{map[string]any{
  119. "type": "namespace",
  120. "name": "collaboration",
  121. }},
  122. }
  123. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  124. require.NoError(t, err)
  125. require.True(t, changed)
  126. require.NotContains(t, reqBody, "tools")
  127. input := reqBody["input"].([]any)
  128. require.Len(t, input, 2)
  129. require.Equal(t, "message", input[0].(map[string]any)["type"])
  130. require.Equal(t, "hello", input[0].(map[string]any)["content"])
  131. require.Equal(t, "additional_tools", input[1].(map[string]any)["type"])
  132. }
  133. func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testing.T) {
  134. reqBody := map[string]any{
  135. "reasoning": map[string]any{"context": "all_turns"},
  136. "tools": []any{
  137. map[string]any{"type": "function", "name": "shell"},
  138. map[string]any{"type": "custom", "name": "exec"},
  139. map[string]any{"type": "tool_search"},
  140. "custom shorthand",
  141. },
  142. }
  143. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  144. require.NoError(t, err)
  145. require.True(t, changed)
  146. require.Len(t, reqBody["tools"], 4)
  147. require.Equal(t, false, reqBody["parallel_tool_calls"])
  148. }
  149. func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testing.T) {
  150. tests := []struct {
  151. name string
  152. body map[string]any
  153. }{
  154. {
  155. name: "top-level tools",
  156. body: map[string]any{
  157. "tools": []any{map[string]any{"type": "function", "name": "shell"}},
  158. "parallel_tool_calls": true,
  159. },
  160. },
  161. {
  162. name: "input additional tools",
  163. body: map[string]any{
  164. "input": []any{map[string]any{
  165. "type": "additional_tools",
  166. "tools": []any{map[string]any{"type": "namespace", "name": "collaboration"}},
  167. }},
  168. },
  169. },
  170. }
  171. for _, tt := range tests {
  172. t.Run(tt.name, func(t *testing.T) {
  173. changed, err := normalizeOpenAIResponsesLiteTools(tt.body)
  174. require.NoError(t, err)
  175. require.True(t, changed)
  176. require.Equal(t, false, tt.body["parallel_tool_calls"])
  177. })
  178. }
  179. }
  180. func TestNormalizeOpenAIResponsesLiteTools_PinsParallelToolCallsWithoutTools(t *testing.T) {
  181. tests := []struct {
  182. name string
  183. parallel any
  184. include bool
  185. wantChanged bool
  186. }{
  187. {name: "字段缺失", wantChanged: true},
  188. {name: "值为 true", parallel: true, include: true, wantChanged: true},
  189. {name: "值为 false", parallel: false, include: true, wantChanged: false},
  190. }
  191. for _, tt := range tests {
  192. t.Run(tt.name, func(t *testing.T) {
  193. reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}}
  194. if tt.include {
  195. reqBody["parallel_tool_calls"] = tt.parallel
  196. }
  197. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  198. require.NoError(t, err)
  199. require.Equal(t, tt.wantChanged, changed)
  200. require.Contains(t, reqBody, "parallel_tool_calls")
  201. require.Equal(t, false, reqBody["parallel_tool_calls"])
  202. })
  203. }
  204. }
  205. func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) {
  206. for _, value := range []any{"false", float64(0), nil, map[string]any{}} {
  207. reqBody := map[string]any{
  208. "tools": []any{map[string]any{"type": "function", "name": "shell"}},
  209. "parallel_tool_calls": value,
  210. }
  211. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  212. require.ErrorContains(t, err, "parallel_tool_calls to be a boolean")
  213. require.False(t, changed)
  214. require.Equal(t, value, reqBody["parallel_tool_calls"])
  215. }
  216. reqBody := map[string]any{"parallel_tool_calls": []any{}}
  217. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  218. require.ErrorContains(t, err, "parallel_tool_calls to be a boolean")
  219. require.False(t, changed)
  220. }
  221. func TestNormalizeOpenAIResponsesLiteTools_ParallelToolCallsIsIdempotent(t *testing.T) {
  222. reqBody := map[string]any{
  223. "reasoning": map[string]any{"context": "all_turns"},
  224. "tools": []any{map[string]any{"type": "function", "name": "shell"}},
  225. "parallel_tool_calls": true,
  226. }
  227. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  228. require.NoError(t, err)
  229. require.True(t, changed)
  230. require.Equal(t, false, reqBody["parallel_tool_calls"])
  231. changed, err = normalizeOpenAIResponsesLiteTools(reqBody)
  232. require.NoError(t, err)
  233. require.False(t, changed)
  234. require.Equal(t, false, reqBody["parallel_tool_calls"])
  235. }
  236. func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) {
  237. tests := []struct {
  238. name string
  239. reasoning any
  240. }{
  241. {name: "missing"},
  242. {name: "missing context", reasoning: map[string]any{"effort": "high"}},
  243. {name: "wrong context", reasoning: map[string]any{"effort": "medium", "context": "current_turn"}},
  244. }
  245. for _, tt := range tests {
  246. t.Run(tt.name, func(t *testing.T) {
  247. reqBody := map[string]any{"input": "hello"}
  248. if tt.reasoning != nil {
  249. reqBody["reasoning"] = tt.reasoning
  250. }
  251. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  252. require.NoError(t, err)
  253. require.True(t, changed)
  254. reasoning := reqBody["reasoning"].(map[string]any)
  255. require.Equal(t, "all_turns", reasoning["context"])
  256. if tt.name != "missing" {
  257. require.Equal(t, tt.reasoning.(map[string]any)["effort"], reasoning["effort"])
  258. }
  259. })
  260. }
  261. }
  262. func TestNormalizeOpenAIResponsesLiteTools_RejectsNonObjectReasoning(t *testing.T) {
  263. reqBody := map[string]any{"reasoning": "high"}
  264. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  265. require.ErrorContains(t, err, "reasoning to be an object")
  266. require.False(t, changed)
  267. require.Equal(t, "high", reqBody["reasoning"])
  268. }
  269. func TestNormalizeOpenAIResponsesLiteTools_RejectsUnsupportedTools(t *testing.T) {
  270. tests := []struct {
  271. name string
  272. tool map[string]any
  273. want string
  274. }{
  275. {name: "hosted web search", tool: map[string]any{"type": "web_search"}, want: `top-level tool type "web_search"`},
  276. {name: "hosted image generation", tool: map[string]any{"type": "image_generation"}, want: `top-level tool type "image_generation"`},
  277. }
  278. for _, tt := range tests {
  279. t.Run(tt.name, func(t *testing.T) {
  280. reqBody := map[string]any{"tools": []any{tt.tool}}
  281. changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
  282. require.ErrorContains(t, err, tt.want)
  283. require.False(t, changed)
  284. require.Len(t, reqBody["tools"], 1, "validation errors must not partially mutate tools")
  285. })
  286. }
  287. }
  288. func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t *testing.T) {
  289. body := []byte(`{
  290. "type":"response.create",
  291. "model":"gpt-5.6-terra",
  292. "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
  293. "input":[{"type":"message","role":"user","content":"hello"}],
  294. "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
  295. "tool_choice":{"type":"namespace","name":"collaboration"}
  296. }`)
  297. updated, changed, err := normalizeOpenAIResponsesLiteToolsPayload(body)
  298. require.NoError(t, err)
  299. require.True(t, changed)
  300. require.Equal(t, "response.create", gjson.GetBytes(updated, "type").String())
  301. require.False(t, gjson.GetBytes(updated, "tools").Exists())
  302. require.Equal(t, "collaboration", gjson.GetBytes(updated, `input.#(type=="additional_tools").tools.0.name`).String())
  303. require.Equal(t, "namespace", gjson.GetBytes(updated, "tool_choice.type").String())
  304. require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists())
  305. require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool())
  306. }
  307. func TestNormalizeOpenAIResponsesLitePayloads_PreserveLargeSequence(t *testing.T) {
  308. body := []byte(`{
  309. "type":"response.create",
  310. "sequence":900719925474099312345,
  311. "tools":[{"type":"function","name":"lookup"}],
  312. "parallel_tool_calls":true
  313. }`)
  314. tests := []struct {
  315. name string
  316. normalize func([]byte) ([]byte, bool, error)
  317. }{
  318. {name: "OAuth-like tools normalization", normalize: normalizeOpenAIResponsesLiteToolsPayload},
  319. {name: "API key parallel normalization", normalize: normalizeOpenAIResponsesLiteParallelToolCallsPayload},
  320. }
  321. for _, tt := range tests {
  322. t.Run(tt.name, func(t *testing.T) {
  323. updated, changed, err := tt.normalize(body)
  324. require.NoError(t, err)
  325. require.True(t, changed)
  326. require.Equal(t, "900719925474099312345", gjson.GetBytes(updated, "sequence").Raw)
  327. require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists())
  328. require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool())
  329. })
  330. }
  331. }
  332. func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) {
  333. reqBody := map[string]any{
  334. "model": "gpt-5.6-terra",
  335. "input": []any{map[string]any{
  336. "type": "additional_tools",
  337. "tools": []any{map[string]any{
  338. "type": "namespace",
  339. "name": "collaboration",
  340. }},
  341. }},
  342. "tool_choice": map[string]any{"type": "namespace", "name": "collaboration"},
  343. }
  344. applyCodexOAuthTransform(reqBody, true, false)
  345. require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"])
  346. }
  347. func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *testing.T) {
  348. gin.SetMode(gin.TestMode)
  349. for _, passthrough := range []bool{false, true} {
  350. name := "managed"
  351. if passthrough {
  352. name = "passthrough"
  353. }
  354. t.Run(name, func(t *testing.T) {
  355. rec := httptest.NewRecorder()
  356. c, _ := gin.CreateTestContext(rec)
  357. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  358. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
  359. c.Request.Header.Set(responsesLiteHeader, "true")
  360. upstream := &httpUpstreamRecorder{resp: &http.Response{
  361. StatusCode: http.StatusOK,
  362. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  363. Body: io.NopCloser(strings.NewReader(
  364. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
  365. "data: [DONE]\n\n",
  366. )),
  367. }}
  368. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  369. account := &Account{
  370. ID: 501, Name: "responses-lite", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  371. Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
  372. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  373. Extra: map[string]any{"openai_passthrough": passthrough},
  374. }
  375. body := []byte(`{
  376. "model":"gpt-5.6-terra","stream":true,"instructions":"test",
  377. "reasoning":{"effort":"high","context":"current_turn"},
  378. "parallel_tool_calls":true,
  379. "tools":[
  380. {"type":"function","name":"shell","parameters":{"type":"object"}},
  381. {"type":"custom","name":"exec"},
  382. {"type":"tool_search"},
  383. {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
  384. ],
  385. "input":[{"type":"message","role":"user","content":"hello"}],
  386. "tool_choice":{"type":"namespace","name":"collaboration"}
  387. }`)
  388. result, err := svc.Forward(context.Background(), c, account, body)
  389. require.NoError(t, err)
  390. require.NotNil(t, result)
  391. require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
  392. require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  393. require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String())
  394. require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
  395. require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, `tools.#(type=="function").name`).String())
  396. require.Equal(t, "exec", gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom").name`).String())
  397. require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="tool_search")`).Exists())
  398. require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools").tools.0.name`).String())
  399. require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String())
  400. require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
  401. require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
  402. require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
  403. badRec := httptest.NewRecorder()
  404. badCtx, _ := gin.CreateTestContext(badRec)
  405. badCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  406. badCtx.Request.Header.Set(responsesLiteHeader, "true")
  407. badUpstream := &httpUpstreamRecorder{}
  408. svc.httpUpstream = badUpstream
  409. result, err = svc.Forward(context.Background(), badCtx, account, []byte(`{"model":"gpt-5.6-terra","tools":[{"type":"function","name":"shell"}],"parallel_tool_calls":"false"}`))
  410. require.ErrorContains(t, err, "parallel_tool_calls to be a boolean")
  411. require.Nil(t, result)
  412. require.Equal(t, http.StatusBadRequest, badRec.Code)
  413. require.Equal(t, "invalid_request_error", gjson.Get(badRec.Body.String(), "error.type").String())
  414. require.Equal(t, "parallel_tool_calls", gjson.Get(badRec.Body.String(), "error.param").String())
  415. require.Contains(t, gjson.Get(badRec.Body.String(), "error.message").String(), "parallel_tool_calls to be a boolean")
  416. require.Nil(t, badUpstream.lastReq)
  417. for _, malformed := range []struct {
  418. body string
  419. wantParam string
  420. }{
  421. {body: `{"model":"gpt-5.6-terra","tools":{}}`, wantParam: "tools"},
  422. {body: `{"model":"gpt-5.6-terra","reasoning":[]}`, wantParam: "reasoning"},
  423. } {
  424. rec := httptest.NewRecorder()
  425. requestCtx, _ := gin.CreateTestContext(rec)
  426. requestCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  427. requestCtx.Request.Header.Set(responsesLiteHeader, "true")
  428. result, err = svc.Forward(context.Background(), requestCtx, account, []byte(malformed.body))
  429. require.Error(t, err)
  430. require.Nil(t, result)
  431. require.Equal(t, http.StatusBadRequest, rec.Code)
  432. require.Equal(t, malformed.wantParam, gjson.Get(rec.Body.String(), "error.param").String())
  433. }
  434. })
  435. }
  436. }
  437. func TestOpenAIGatewayServiceForward_PinsParallelToolCallsForToollessResponsesLite(t *testing.T) {
  438. gin.SetMode(gin.TestMode)
  439. accountCases := []struct {
  440. name string
  441. accountType string
  442. credentials map[string]any
  443. }{
  444. {name: "oauth", accountType: AccountTypeOAuth, credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}},
  445. {name: "apikey", accountType: AccountTypeAPIKey, credentials: map[string]any{"api_key": "sk-test"}},
  446. }
  447. parallelCases := []struct {
  448. name string
  449. field string
  450. }{
  451. {name: "字段缺失"},
  452. {name: "值为 true", field: `,"parallel_tool_calls":true`},
  453. {name: "值为 false", field: `,"parallel_tool_calls":false`},
  454. }
  455. for _, accountCase := range accountCases {
  456. for _, passthrough := range []bool{false, true} {
  457. mode := "managed"
  458. if passthrough {
  459. mode = "passthrough"
  460. }
  461. for _, parallelCase := range parallelCases {
  462. name := accountCase.name + "/" + mode + "/" + parallelCase.name
  463. t.Run(name, func(t *testing.T) {
  464. rec := httptest.NewRecorder()
  465. c, _ := gin.CreateTestContext(rec)
  466. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  467. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
  468. c.Request.Header.Set(responsesLiteHeader, "true")
  469. upstream := &httpUpstreamRecorder{resp: &http.Response{
  470. StatusCode: http.StatusOK,
  471. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  472. Body: io.NopCloser(strings.NewReader(
  473. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
  474. "data: [DONE]\n\n",
  475. )),
  476. }}
  477. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  478. account := &Account{
  479. ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: accountCase.accountType,
  480. Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
  481. Credentials: accountCase.credentials,
  482. Extra: map[string]any{"openai_passthrough": passthrough},
  483. }
  484. body := []byte(`{
  485. "model":"gpt-5.6-terra","stream":true,"instructions":"test",
  486. "reasoning":{"effort":"high","context":"current_turn"},
  487. "input":[{"type":"message","role":"user","content":"hello"}]` + parallelCase.field + `
  488. }`)
  489. result, err := svc.Forward(context.Background(), c, account, body)
  490. require.NoError(t, err)
  491. require.NotNil(t, result)
  492. require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
  493. require.Equal(t, gjson.False, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Type, string(upstream.lastBody))
  494. })
  495. }
  496. }
  497. }
  498. }
  499. func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) {
  500. gin.SetMode(gin.TestMode)
  501. for _, passthrough := range []bool{false, true} {
  502. name := "managed"
  503. if passthrough {
  504. name = "passthrough"
  505. }
  506. t.Run(name, func(t *testing.T) {
  507. rec := httptest.NewRecorder()
  508. c, _ := gin.CreateTestContext(rec)
  509. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
  510. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
  511. c.Request.Header.Set(responsesLiteHeader, "true")
  512. upstream := &httpUpstreamRecorder{resp: &http.Response{
  513. StatusCode: http.StatusOK,
  514. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  515. Body: io.NopCloser(strings.NewReader(
  516. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
  517. "data: [DONE]\n\n",
  518. )),
  519. }}
  520. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  521. account := &Account{
  522. ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  523. Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
  524. Credentials: map[string]any{"api_key": "sk-test"},
  525. Extra: map[string]any{"openai_passthrough": passthrough},
  526. }
  527. body := []byte(`{
  528. "model":"gpt-5.6-terra","stream":true,"instructions":"test",
  529. "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
  530. "parallel_tool_calls":true,
  531. "input":[{"type":"message","role":"user","content":"hello"}]
  532. }`)
  533. result, err := svc.Forward(context.Background(), c, account, body)
  534. require.NoError(t, err)
  535. require.NotNil(t, result)
  536. require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
  537. require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray())
  538. require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
  539. require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
  540. })
  541. }
  542. }