openai_responses_rejected_field_retry_test.go 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strconv"
  10. "strings"
  11. "testing"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *testing.T) {
  19. initialBody := []byte(`{"model":"gpt-5.5"}`)
  20. state := newOpenAIResponsesRejectedFieldRetryState(initialBody)
  21. require.False(t, state.Allow(initialBody))
  22. for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
  23. nextBody := []byte(fmt.Sprintf(`{"model":"gpt-5.5","variant":%d}`, attempt))
  24. require.True(t, state.Allow(nextBody))
  25. require.False(t, state.Allow(nextBody))
  26. }
  27. require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`)))
  28. }
  29. func TestOpenAIResponsesRejectedFieldRetryStateForRequestAllowsSameTransformAcrossAccounts(t *testing.T) {
  30. gin.SetMode(gin.TestMode)
  31. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  32. initialBody := []byte(`{"model":"gpt-5.5","truncation":"auto"}`)
  33. retryBody := []byte(`{"model":"gpt-5.5"}`)
  34. accountA := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
  35. require.True(t, accountA.Allow(retryBody))
  36. require.False(t, accountA.Allow(retryBody), "one account must not repeat the same transform")
  37. accountB := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
  38. require.NotSame(t, accountA, accountB)
  39. require.Same(t, accountA.budget, accountB.budget)
  40. require.True(t, accountB.Allow(retryBody), "a failover account must be allowed to apply the same transform")
  41. }
  42. func TestOpenAIResponsesRejectedFieldRetryStateForRequestSharesBoundedBudgetAcrossAccounts(t *testing.T) {
  43. gin.SetMode(gin.TestMode)
  44. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  45. for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
  46. state := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(fmt.Sprintf(`{"account":%d}`, attempt)))
  47. require.True(t, state.Allow([]byte(`{"same":"retry"}`)))
  48. }
  49. overflow := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(`{"account":"overflow"}`))
  50. require.False(t, overflow.Allow([]byte(`{"new":"retry"}`)))
  51. }
  52. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) {
  53. tests := []struct {
  54. name string
  55. body []byte
  56. responseBody []byte
  57. }{
  58. {
  59. name: "namespace belongs to message",
  60. body: []byte(`{"input":[{"type":"message","namespace":"keep"}]}`),
  61. responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"input[0].namespace"}}`),
  62. },
  63. {
  64. name: "max output tokens only mentioned",
  65. body: []byte(`{"max_output_tokens":4096}`),
  66. responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"max_output_tokens must be positive","param":"max_output_tokens"}}`),
  67. },
  68. {
  69. name: "structured param overrides namespace mention",
  70. body: []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"}]}`),
  71. responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"tools"}}`),
  72. },
  73. {
  74. name: "nested max output tokens param is not top level",
  75. body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`),
  76. responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`),
  77. },
  78. {
  79. name: "structured target conflicts with message target",
  80. body: []byte(`{"max_output_tokens":4096,"truncation":"auto"}`),
  81. responseBody: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: truncation.","param":"max_output_tokens"}}`),
  82. },
  83. }
  84. for _, tt := range tests {
  85. t.Run(tt.name, func(t *testing.T) {
  86. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
  87. require.NoError(t, err)
  88. require.False(t, changed)
  89. require.Nil(t, retryBody)
  90. })
  91. }
  92. }
  93. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRepairsAutomationMissingRootType(t *testing.T) {
  94. body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"object","properties":{}}]}}]}`)
  95. responseBody := []byte(`{"error":{"code":"invalid_function_parameters","message":"Invalid schema for function 'automation_update': got 'type: \"None\"'.","param":"tools[0].parameters"}}`)
  96. retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  97. require.NoError(t, err)
  98. require.True(t, changed)
  99. require.Equal(t, "tool parameter root type rejection", reason)
  100. require.Equal(t, "object", gjson.GetBytes(retryBody, "tools.0.parameters.type").String())
  101. }
  102. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotGuessAutomationRootType(t *testing.T) {
  103. body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"}]}}]}`)
  104. tests := []string{
  105. `{"error":{"code":"invalid_function_parameters","message":"got type: \"None\"","param":"metadata.parameters"}}`,
  106. `{"error":{"code":"invalid_request_error","message":"got type: \"None\"","param":"tools[0].parameters"}}`,
  107. `{"error":{"code":"invalid_function_parameters","message":"expected an object","param":"tools[0].parameters"}}`,
  108. }
  109. for _, response := range tests {
  110. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, []byte(response))
  111. require.NoError(t, err)
  112. require.False(t, changed)
  113. require.Nil(t, retryBody)
  114. }
  115. }
  116. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) {
  117. body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
  118. responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`)
  119. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  120. require.NoError(t, err)
  121. require.True(t, changed)
  122. require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String())
  123. require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists())
  124. }
  125. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsNamespacePathToRejectionPhrase(t *testing.T) {
  126. body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
  127. responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0].namespace is supported; Unknown parameter: input[1].namespace."}}`)
  128. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  129. require.NoError(t, err)
  130. require.True(t, changed)
  131. require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String())
  132. require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists())
  133. }
  134. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotTreatMaxOutputTokensSuggestionAsRejection(t *testing.T) {
  135. body := []byte(`{"max_tokens":4096,"max_output_tokens":2048}`)
  136. responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: max_tokens. Use max_output_tokens instead."}}`)
  137. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  138. require.NoError(t, err)
  139. require.False(t, changed)
  140. require.Nil(t, retryBody)
  141. }
  142. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRejectionPhrase(t *testing.T) {
  143. body := []byte(`{"max_output_tokens":2048}`)
  144. responseBody := []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens."}}`)
  145. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  146. require.NoError(t, err)
  147. require.True(t, changed)
  148. require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists())
  149. }
  150. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactIndexedStatus(t *testing.T) {
  151. body := []byte(`{"input":[{"type":"message","status":"keep","content":"one"},{"type":"reasoning","status":"remove","summary":[]}]}`)
  152. responses := []struct {
  153. name string
  154. body []byte
  155. }{
  156. {
  157. name: "structured param",
  158. body: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`),
  159. },
  160. {
  161. name: "message param",
  162. body: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: input[1].status."}}`),
  163. },
  164. }
  165. for _, tt := range responses {
  166. t.Run(tt.name, func(t *testing.T) {
  167. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.body)
  168. require.NoError(t, err)
  169. require.True(t, changed)
  170. require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.status").String())
  171. require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
  172. })
  173. }
  174. }
  175. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyNormalizesExactNullContent(t *testing.T) {
  176. tests := []struct {
  177. name string
  178. body []byte
  179. wantChange bool
  180. wantValue string
  181. wantExists bool
  182. }{
  183. {
  184. name: "message becomes empty string",
  185. body: []byte(`{"input":[{"type":"message","role":"assistant","content":null}]}`),
  186. wantChange: true,
  187. wantValue: "",
  188. wantExists: true,
  189. },
  190. {
  191. name: "reasoning content is removed",
  192. body: []byte(`{"input":[{"type":"reasoning","content":null,"summary":[]}]}`),
  193. wantChange: true,
  194. wantExists: false,
  195. },
  196. {
  197. name: "unknown item is unchanged",
  198. body: []byte(`{"input":[{"type":"future_item","content":null}]}`),
  199. wantChange: false,
  200. },
  201. {
  202. name: "non null content is unchanged",
  203. body: []byte(`{"input":[{"type":"message","content":"keep"}]}`),
  204. wantChange: false,
  205. },
  206. }
  207. responseBody := []byte(`{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`)
  208. for _, tt := range tests {
  209. t.Run(tt.name, func(t *testing.T) {
  210. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, responseBody)
  211. require.NoError(t, err)
  212. require.Equal(t, tt.wantChange, changed)
  213. if !tt.wantChange {
  214. require.Nil(t, retryBody)
  215. return
  216. }
  217. content := gjson.GetBytes(retryBody, "input.0.content")
  218. require.Equal(t, tt.wantExists, content.Exists())
  219. if tt.wantExists {
  220. require.Equal(t, tt.wantValue, content.String())
  221. require.Equal(t, gjson.String, content.Type)
  222. }
  223. })
  224. }
  225. }
  226. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactReasoningContentAboveMaximumZero(t *testing.T) {
  227. body := []byte(`{"input":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"remove"}],"summary":[]},{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`)
  228. responseBody := []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content","type":"invalid_request_error"}}`)
  229. retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  230. require.NoError(t, err)
  231. require.True(t, changed)
  232. require.Equal(t, "indexed reasoning content maximum-length rejection", reason)
  233. require.False(t, gjson.GetBytes(retryBody, "input.0.content").Exists())
  234. require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.1.content.0.text").String())
  235. }
  236. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeReasoningMaximumZeroMutations(t *testing.T) {
  237. tests := []struct {
  238. name string
  239. body []byte
  240. responseBody []byte
  241. }{
  242. {
  243. name: "message content is not reasoning",
  244. body: []byte(`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`),
  245. responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
  246. },
  247. {
  248. name: "structured param and message disagree",
  249. body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]},{"type":"reasoning","content":[{"text":"keep too"}]}]}`),
  250. responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[1].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
  251. },
  252. {
  253. name: "different error code",
  254. body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]}]}`),
  255. responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
  256. },
  257. }
  258. for _, tt := range tests {
  259. t.Run(tt.name, func(t *testing.T) {
  260. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
  261. require.NoError(t, err)
  262. require.False(t, changed)
  263. require.Nil(t, retryBody)
  264. })
  265. }
  266. }
  267. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
  268. body := []byte(`{"model":"gpt-5.5","truncation":"auto","input":"keep"}`)
  269. responses := [][]byte{
  270. []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
  271. []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: truncation."}}`),
  272. }
  273. for _, responseBody := range responses {
  274. retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  275. require.NoError(t, err)
  276. require.True(t, changed)
  277. require.Equal(t, "truncation parameter rejection", reason)
  278. require.False(t, gjson.GetBytes(retryBody, "truncation").Exists())
  279. require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
  280. }
  281. }
  282. func TestOpenAIGatewayService_APIKeyRetriesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
  283. body := []byte(`{"model":"gpt-5.5","stream":false,"truncation":"auto","input":"keep"}`)
  284. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  285. newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
  286. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  287. }}
  288. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  289. context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
  290. )
  291. require.NoError(t, err)
  292. require.NotNil(t, result)
  293. require.Len(t, upstream.bodies, 2)
  294. require.Equal(t, "auto", gjson.GetBytes(upstream.bodies[0], "truncation").String())
  295. require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists())
  296. require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input").String())
  297. }
  298. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeIndexedMutations(t *testing.T) {
  299. tests := []struct {
  300. name string
  301. body []byte
  302. responseBody []byte
  303. }{
  304. {
  305. name: "nested status path",
  306. body: []byte(`{"input":[{"type":"message","content":{"status":"keep"}}]}`),
  307. responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.status.","param":"input[0].content.status"}}`),
  308. },
  309. {
  310. name: "status index out of bounds",
  311. body: []byte(`{"input":[{"type":"message","status":"keep"}]}`),
  312. responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[4].status.","param":"input[4].status"}}`),
  313. },
  314. {
  315. name: "status path only mentioned",
  316. body: []byte(`{"input":[{"type":"message","status":"keep"}]}`),
  317. responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"input[0].status must be completed","param":"input[0].status"}}`),
  318. },
  319. {
  320. name: "content param and message disagree",
  321. body: []byte(`{"input":[{"type":"message","content":null},{"type":"message","content":null}]}`),
  322. responseBody: []byte(`{"error":{"code":"invalid_type","message":"Invalid type for input[1].content: got null instead.","param":"input[0].content"}}`),
  323. },
  324. {
  325. name: "content error only mentions null",
  326. body: []byte(`{"input":[{"type":"message","content":null}]}`),
  327. responseBody: []byte(`{"error":{"code":"invalid_type","message":"content cannot be null","param":"input[0].content"}}`),
  328. },
  329. }
  330. for _, tt := range tests {
  331. t.Run(tt.name, func(t *testing.T) {
  332. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
  333. require.NoError(t, err)
  334. require.False(t, changed)
  335. require.Nil(t, retryBody)
  336. })
  337. }
  338. }
  339. func TestOpenAIGatewayService_OAuthRetriesExactRejectedStatus(t *testing.T) {
  340. body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"test","input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`)
  341. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  342. newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`),
  343. newOpenAIRejectedFieldTestResponse(http.StatusOK, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"),
  344. }}
  345. upstream.responses[1].Header.Set("Content-Type", "text/event-stream")
  346. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  347. context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIOAuthNamespaceTestAccount(), body,
  348. )
  349. require.NoError(t, err)
  350. require.NotNil(t, result)
  351. require.Len(t, upstream.bodies, 2)
  352. require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String())
  353. require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists())
  354. }
  355. func TestOpenAIGatewayService_APIKeyRetriesExactRejectedNullMessageContent(t *testing.T) {
  356. body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"message","role":"assistant","content":null},{"type":"message","role":"user","content":"continue"}]}`)
  357. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  358. newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`),
  359. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  360. }}
  361. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  362. context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
  363. )
  364. require.NoError(t, err)
  365. require.NotNil(t, result)
  366. require.Len(t, upstream.bodies, 2)
  367. require.Equal(t, gjson.Null, gjson.GetBytes(upstream.bodies[0], "input.0.content").Type)
  368. require.Equal(t, gjson.String, gjson.GetBytes(upstream.bodies[1], "input.0.content").Type)
  369. require.Equal(t, "continue", gjson.GetBytes(upstream.bodies[1], "input.1.content").String())
  370. }
  371. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesModelRejectedPromptCacheBreakpoint(t *testing.T) {
  372. tests := []struct {
  373. name string
  374. body []byte
  375. responseBody []byte
  376. removedPath string
  377. preserved string
  378. reason string
  379. }{
  380. {
  381. name: "top level",
  382. body: []byte(`{"model":"gpt-5.6-sol","prompt_cache_breakpoint":{"type":"message_start"},"input":"hello"}`),
  383. responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
  384. removedPath: "prompt_cache_breakpoint",
  385. preserved: "input",
  386. reason: "prompt_cache_breakpoint parameter rejection",
  387. },
  388. {
  389. name: "indexed path from message",
  390. body: []byte(`{"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_start"}},{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`),
  391. responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[1].prompt_cache_breakpoint is not supported on this model"}}`),
  392. removedPath: "input.1.prompt_cache_breakpoint",
  393. preserved: "input.0.prompt_cache_breakpoint",
  394. reason: "indexed prompt_cache_breakpoint parameter rejection",
  395. },
  396. }
  397. for _, tt := range tests {
  398. t.Run(tt.name, func(t *testing.T) {
  399. retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
  400. require.NoError(t, err)
  401. require.True(t, changed)
  402. require.Equal(t, tt.reason, reason)
  403. require.False(t, gjson.GetBytes(retryBody, tt.removedPath).Exists())
  404. require.True(t, gjson.GetBytes(retryBody, tt.preserved).Exists())
  405. })
  406. }
  407. }
  408. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousPromptCacheBreakpointErrors(t *testing.T) {
  409. body := []byte(`{"prompt_cache_breakpoint":{"type":"message_start"},"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`)
  410. tests := []struct {
  411. name string
  412. responseBody []byte
  413. }{
  414. {
  415. name: "structured param disagrees",
  416. responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
  417. },
  418. {
  419. name: "index out of bounds",
  420. responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[4].prompt_cache_breakpoint is not supported on this model","param":"input[4].prompt_cache_breakpoint"}}`),
  421. },
  422. }
  423. for _, tt := range tests {
  424. t.Run(tt.name, func(t *testing.T) {
  425. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.responseBody)
  426. require.NoError(t, err)
  427. require.False(t, changed)
  428. require.Nil(t, retryBody)
  429. })
  430. }
  431. }
  432. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyAcceptsEitherCacheModelRejectionSignal(t *testing.T) {
  433. tests := []struct {
  434. name string
  435. responseBody []byte
  436. }{
  437. {
  438. name: "invalid parameter code",
  439. responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"This optional cache hint cannot be used here","param":"prompt_cache_breakpoint"}}`),
  440. },
  441. {
  442. name: "model rejection message",
  443. responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
  444. },
  445. }
  446. for _, tt := range tests {
  447. t.Run(tt.name, func(t *testing.T) {
  448. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, []byte(`{"prompt_cache_breakpoint":true,"input":"keep"}`), tt.responseBody)
  449. require.NoError(t, err)
  450. require.True(t, changed)
  451. require.False(t, gjson.GetBytes(retryBody, "prompt_cache_breakpoint").Exists())
  452. require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
  453. })
  454. }
  455. }
  456. func TestOpenAIResponsesRejectedFieldRetryStateAllowsPromptCacheBreakpointVariantOnce(t *testing.T) {
  457. body := []byte(`{"input":[{"prompt_cache_breakpoint":{"type":"message_start"}}]}`)
  458. responseBody := []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"input[0].prompt_cache_breakpoint"}}`)
  459. state := newOpenAIResponsesRejectedFieldRetryState(body)
  460. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  461. require.NoError(t, err)
  462. require.True(t, changed)
  463. require.True(t, state.Allow(retryBody))
  464. require.False(t, state.Allow(retryBody))
  465. }
  466. func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward(t *testing.T) {
  467. body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
  468. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  469. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  470. }}
  471. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  472. context.Background(),
  473. newOpenAIRejectedFieldTestContext(body),
  474. newOpenAIRejectedFieldTestAccount(),
  475. body,
  476. )
  477. require.NoError(t, err)
  478. require.NotNil(t, result)
  479. require.Len(t, upstream.bodies, 1)
  480. require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
  481. require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
  482. }
  483. func TestOpenAIGatewayServiceProactivelyStripsCrossProviderReasoningContent(t *testing.T) {
  484. body := []byte(`{"model":"gpt-5.5","stream":false,"store":true,"input":[` +
  485. `{"type":"message","role":"user","content":"one"},` +
  486. `{"type":"message","role":"assistant","content":"two"},` +
  487. `{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
  488. `{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
  489. `{"type":"message","role":"user","content":"five"},` +
  490. `{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}` +
  491. `]}`)
  492. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  493. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  494. }}
  495. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  496. context.Background(),
  497. newOpenAIRejectedFieldTestContext(body),
  498. newOpenAIRejectedFieldTestAccount(),
  499. body,
  500. )
  501. require.NoError(t, err)
  502. require.NotNil(t, result)
  503. require.Len(t, upstream.bodies, 1, "reasoning content should be normalized before the first upstream request")
  504. require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.5.type").String())
  505. require.False(t, gjson.GetBytes(upstream.bodies[0], "input.5.content").Exists())
  506. require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[0], "input.5.summary.0.text").String())
  507. }
  508. func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
  509. accounts := []struct {
  510. name string
  511. account *Account
  512. }{
  513. {name: "oauth", account: newOpenAIOAuthNamespaceTestAccount()},
  514. {name: "apikey", account: newOpenAIRejectedFieldTestAccount()},
  515. }
  516. for _, tt := range accounts {
  517. for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
  518. t.Run(tt.name+path, func(t *testing.T) {
  519. body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"test","input":[{"type":"message","role":"user","namespace":"remove","content":[{"type":"input_text","text":"hello","namespace":"nested-keep"}]}]}`)
  520. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  521. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_namespace_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  522. }}
  523. c := newOpenAIRejectedFieldTestContext(body)
  524. c.Request.URL.Path = path
  525. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  526. context.Background(),
  527. c,
  528. tt.account,
  529. body,
  530. )
  531. require.NoError(t, err)
  532. require.NotNil(t, result)
  533. require.Len(t, upstream.bodies, 1, "namespace must be removed before the first upstream request")
  534. require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
  535. require.Equal(t, "nested-keep", gjson.GetBytes(upstream.bodies[0], "input.0.content.0.namespace").String())
  536. })
  537. }
  538. }
  539. }
  540. func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) {
  541. body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":4096,"input":[{"type":"message","role":"user","content":{"max_output_tokens":"keep"}}]}`)
  542. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  543. newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens","type":"invalid_request_error"}}`),
  544. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  545. }}
  546. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  547. context.Background(),
  548. newOpenAIRejectedFieldTestContext(body),
  549. newOpenAIRejectedFieldTestAccount(),
  550. body,
  551. )
  552. require.NoError(t, err)
  553. require.NotNil(t, result)
  554. require.Len(t, upstream.bodies, 2)
  555. require.Equal(t, int64(4096), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
  556. require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
  557. require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String())
  558. }
  559. func TestOpenAIGatewayService_ComposesProactiveNamespaceStripWithRejectedFieldRetry(t *testing.T) {
  560. body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
  561. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  562. newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`),
  563. newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
  564. }}
  565. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  566. context.Background(),
  567. newOpenAIRejectedFieldTestContext(body),
  568. newOpenAIRejectedFieldTestAccount(),
  569. body,
  570. )
  571. require.NoError(t, err)
  572. require.NotNil(t, result)
  573. require.Len(t, upstream.bodies, 2)
  574. for _, forwardedBody := range upstream.bodies {
  575. require.False(t, gjson.GetBytes(forwardedBody, "input.0.namespace").Exists())
  576. require.False(t, gjson.GetBytes(forwardedBody, "input.1.namespace").Exists())
  577. }
  578. require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
  579. require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
  580. }
  581. func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
  582. return &OpenAIGatewayService{
  583. cfg: &config.Config{Security: config.SecurityConfig{
  584. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  585. }},
  586. httpUpstream: upstream,
  587. }
  588. }
  589. func newOpenAIRejectedFieldTestContext(body []byte) *gin.Context {
  590. gin.SetMode(gin.TestMode)
  591. recorder := httptest.NewRecorder()
  592. c, _ := gin.CreateTestContext(recorder)
  593. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  594. c.Request.Header.Set("Content-Type", "application/json")
  595. c.Request.Header.Set("User-Agent", "curl/8.0")
  596. return c
  597. }
  598. func newOpenAIRejectedFieldTestAccount() *Account {
  599. return &Account{
  600. ID: 5107,
  601. Name: "responses-compatible",
  602. Platform: PlatformOpenAI,
  603. Type: AccountTypeAPIKey,
  604. Concurrency: 1,
  605. Credentials: map[string]any{
  606. "api_key": "sk-test",
  607. "base_url": "https://compat.example",
  608. },
  609. Extra: map[string]any{
  610. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
  611. openai_compat.ExtraKeyResponsesSupported: true,
  612. },
  613. Status: StatusActive,
  614. Schedulable: true,
  615. }
  616. }
  617. func newOpenAIOAuthNamespaceTestAccount() *Account {
  618. return &Account{
  619. ID: 5108,
  620. Name: "openai-oauth-namespace",
  621. Platform: PlatformOpenAI,
  622. Type: AccountTypeOAuth,
  623. Concurrency: 1,
  624. Credentials: map[string]any{
  625. "access_token": "oauth-token",
  626. "chatgpt_account_id": "chatgpt-account",
  627. },
  628. Status: StatusActive,
  629. Schedulable: true,
  630. }
  631. }
  632. func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response {
  633. return &http.Response{
  634. StatusCode: status,
  635. Header: http.Header{"Content-Type": []string{"application/json"}},
  636. Body: io.NopCloser(strings.NewReader(body)),
  637. }
  638. }
  639. // A replayed conversation carries many items of the same type, each with a
  640. // status the upstream schema rejects. One rejection must clear all of them:
  641. // clearing one index per round trip exhausts the bounded retry budget.
  642. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) {
  643. input := make([]string, 0, 12)
  644. for i := 0; i < 10; i++ {
  645. input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`)
  646. }
  647. input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`)
  648. body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`)
  649. responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`)
  650. retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  651. require.NoError(t, err)
  652. require.True(t, changed)
  653. require.NotEmpty(t, reason)
  654. for i := 0; i < 10; i++ {
  655. require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(),
  656. "every tool_search_output must lose its status in a single retry, index %d did not", i)
  657. require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(),
  658. "unrelated fields must survive")
  659. }
  660. require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(),
  661. "a different item type keeps its status: the rejection only proves this type has none")
  662. }
  663. // The rejected item may carry no type to match on.
  664. func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) {
  665. body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`)
  666. responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`)
  667. retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
  668. require.NoError(t, err)
  669. require.True(t, changed)
  670. require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String())
  671. require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
  672. }