openai_gateway_service_hotpath_test.go 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. func TestOpenAIRequestView_ExtractsRawScalars(t *testing.T) {
  16. view := newOpenAIRequestView([]byte(`{"model":" gpt-5 ","stream":true,"prompt_cache_key":" ses-1 ","previous_response_id":" resp-1 ","service_tier":" fast ","reasoning":{"effort":" medium "}}`))
  17. require.Equal(t, "gpt-5", view.Model)
  18. require.True(t, view.Stream)
  19. require.Equal(t, "ses-1", view.PromptCacheKey)
  20. require.Equal(t, "resp-1", view.PreviousResponseID)
  21. require.Equal(t, "fast", view.ServiceTier)
  22. require.Equal(t, "medium", view.ReasoningEffort)
  23. }
  24. func TestOpenAIRequestView_ExtractsFieldsAfterLargeInput(t *testing.T) {
  25. body := []byte(`{"model":"gpt-5","input":[{"content":"` + strings.Repeat("payload", 1024) + `"}],"stream":true,"prompt_cache_key":"session-1","previous_response_id":"resp-1","service_tier":"flex","reasoning":{"effort":"high"}}`)
  26. view := newOpenAIRequestView(body)
  27. require.Equal(t, "gpt-5", view.Model)
  28. require.True(t, view.Stream)
  29. require.Equal(t, "session-1", view.PromptCacheKey)
  30. require.Equal(t, "resp-1", view.PreviousResponseID)
  31. require.Equal(t, "flex", view.ServiceTier)
  32. require.Equal(t, "high", view.ReasoningEffort)
  33. }
  34. func TestOpenAIRequestView_KeepsFirstDuplicateField(t *testing.T) {
  35. view := newOpenAIRequestView([]byte(`{"model":"gpt-5","model":"gpt-5.1","reasoning":{"effort":"low"},"reasoning":{"effort":"high"}}`))
  36. require.Equal(t, "gpt-5", view.Model)
  37. require.Equal(t, "low", view.ReasoningEffort)
  38. }
  39. func TestOpenAIRequestView_KeepsLenientPrefixExtraction(t *testing.T) {
  40. view := newOpenAIRequestView([]byte(`{"model":"gpt-5","stream":true,"input":[`))
  41. require.Equal(t, "gpt-5", view.Model)
  42. require.True(t, view.Stream)
  43. }
  44. func TestOpenAIRequestView_DecodeKeepsFullMapBehavior(t *testing.T) {
  45. view := newOpenAIRequestView([]byte(`{"model":"gpt-5","stream":true,"input":[{"type":"message","content":"hi"}]}`))
  46. reqBody, err := view.Decode(nil)
  47. require.NoError(t, err)
  48. require.Equal(t, "gpt-5", reqBody["model"])
  49. require.IsType(t, []any{}, reqBody["input"])
  50. }
  51. func TestOpenAIRequestView_ApplyPatches(t *testing.T) {
  52. view := newOpenAIRequestView([]byte(`{"model":"gpt-5","previous_response_id":"resp_1","reasoning":{"effort":"minimal"},"input":[{"type":"message","content":"hi"}]}`))
  53. view.MarkPatchSet("model", "gpt-5.1")
  54. view.MarkPatchDelete("previous_response_id")
  55. view.MarkPatchSet("reasoning.effort", "none")
  56. patched, err := view.ApplyPatches()
  57. require.NoError(t, err)
  58. require.JSONEq(t, `{"model":"gpt-5.1","reasoning":{"effort":"none"},"input":[{"type":"message","content":"hi"}]}`, string(patched))
  59. }
  60. func TestOpenAIRequestView_RejectsEscapedPatchPath(t *testing.T) {
  61. view := newOpenAIRequestView([]byte(`{"metadata":{"user.id":"old"}}`))
  62. view.MarkPatchSet(`metadata.user\.id`, "new")
  63. require.False(t, view.HasPatches())
  64. _, err := view.ApplyPatches()
  65. require.Error(t, err)
  66. }
  67. func TestOpenAIRequestView_ApplyPatchesDisabled(t *testing.T) {
  68. view := newOpenAIRequestView([]byte(`{"model":"gpt-5"}`))
  69. view.MarkPatchSet("model", "gpt-5.1")
  70. view.DisablePatches()
  71. _, err := view.ApplyPatches()
  72. require.Error(t, err)
  73. }
  74. func TestOpenAIRequestView_HasPatches(t *testing.T) {
  75. view := newOpenAIRequestView([]byte(`{"model":"gpt-5"}`))
  76. require.False(t, view.HasPatches())
  77. view.MarkPatchSet("model", "gpt-5.1")
  78. require.True(t, view.HasPatches())
  79. view.DisablePatches()
  80. require.False(t, view.HasPatches())
  81. }
  82. func TestOpenAIGatewayService_Forward_APIKeyMissingInstructionsKeepsLargeInputRaw(t *testing.T) {
  83. gin.SetMode(gin.TestMode)
  84. upstream := &httpUpstreamRecorder{
  85. resp: &http.Response{
  86. StatusCode: http.StatusOK,
  87. Header: http.Header{"Content-Type": []string{"application/json"}},
  88. Body: io.NopCloser(strings.NewReader(
  89. `{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  90. )),
  91. },
  92. }
  93. cfg := &config.Config{}
  94. cfg.Security.URLAllowlist.Enabled = false
  95. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  96. account := &Account{
  97. ID: 1,
  98. Name: "openai-apikey",
  99. Platform: PlatformOpenAI,
  100. Type: AccountTypeAPIKey,
  101. Concurrency: 1,
  102. Credentials: map[string]any{
  103. "api_key": "sk-test",
  104. "base_url": "https://example.com",
  105. },
  106. Extra: map[string]any{"use_responses_api": true},
  107. }
  108. rec := httptest.NewRecorder()
  109. c, _ := gin.CreateTestContext(rec)
  110. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  111. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  112. body := []byte(`{"model":"gpt-5","stream":false,"reasoning":{"effort":"minimal"},"input":[{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}`)
  113. result, err := svc.Forward(context.Background(), c, account, body)
  114. require.NoError(t, err)
  115. require.NotNil(t, result)
  116. require.NotNil(t, upstream.lastReq)
  117. expectedBody := `{"model":"gpt-5","stream":false,"reasoning":{"effort":"none"},"input":[{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}`
  118. require.JSONEq(t, expectedBody, string(upstream.lastBody))
  119. require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists())
  120. require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.lastBody, "input.0.content.0.nonce").Raw)
  121. }
  122. func TestOpenAIGatewayService_Forward_DecodedMutationKeepsLaterFieldDeletes(t *testing.T) {
  123. gin.SetMode(gin.TestMode)
  124. upstream := &httpUpstreamRecorder{
  125. resp: &http.Response{
  126. StatusCode: http.StatusOK,
  127. Header: http.Header{"Content-Type": []string{"application/json"}},
  128. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  129. },
  130. }
  131. cfg := &config.Config{}
  132. cfg.Security.URLAllowlist.Enabled = false
  133. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  134. account := &Account{
  135. ID: 2,
  136. Name: "openai-apikey",
  137. Platform: PlatformOpenAI,
  138. Type: AccountTypeAPIKey,
  139. Concurrency: 1,
  140. Credentials: map[string]any{
  141. "api_key": "sk-test",
  142. "base_url": "https://example.com",
  143. },
  144. Extra: map[string]any{"use_responses_api": true},
  145. }
  146. rec := httptest.NewRecorder()
  147. c, _ := gin.CreateTestContext(rec)
  148. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  149. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  150. body := []byte(`{"model":"gpt-5.4","stream":false,"max_completion_tokens":12,"tools":[{"type":"image_generation","format":"png"}],"input":[{"type":"message","content":"draw"}]}`)
  151. result, err := svc.Forward(context.Background(), c, account, body)
  152. require.NoError(t, err)
  153. require.NotNil(t, result)
  154. require.False(t, gjson.GetBytes(upstream.lastBody, "max_completion_tokens").Exists())
  155. require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.format").Exists())
  156. require.Equal(t, "png", gjson.GetBytes(upstream.lastBody, "tools.0.output_format").String())
  157. }
  158. // #4417:/v1/responses 原生转发路径需将 Chat-Completions 风格的 max_tokens 归一化为
  159. // max_output_tokens,并移除兼容上游不接受的 prompt_cache_options。
  160. func TestOpenAIGatewayService_Forward_NormalizesMaxTokensAndStripsPromptCacheOptions(t *testing.T) {
  161. gin.SetMode(gin.TestMode)
  162. runForward := func(t *testing.T, body []byte) []byte {
  163. t.Helper()
  164. upstream := &httpUpstreamRecorder{
  165. resp: &http.Response{
  166. StatusCode: http.StatusOK,
  167. Header: http.Header{"Content-Type": []string{"application/json"}},
  168. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  169. },
  170. }
  171. cfg := &config.Config{}
  172. cfg.Security.URLAllowlist.Enabled = false
  173. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  174. account := &Account{
  175. ID: 4,
  176. Name: "openai-apikey",
  177. Platform: PlatformOpenAI,
  178. Type: AccountTypeAPIKey,
  179. Concurrency: 1,
  180. Credentials: map[string]any{
  181. "api_key": "sk-test",
  182. "base_url": "https://example.com",
  183. },
  184. Extra: map[string]any{"openai_responses_supported": true},
  185. }
  186. rec := httptest.NewRecorder()
  187. c, _ := gin.CreateTestContext(rec)
  188. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  189. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  190. result, err := svc.Forward(context.Background(), c, account, body)
  191. require.NoError(t, err)
  192. require.NotNil(t, result)
  193. return upstream.lastBody
  194. }
  195. t.Run("max_tokens 归一化为 max_output_tokens 并移除 prompt_cache_options", func(t *testing.T) {
  196. out := runForward(t, []byte(`{"model":"gpt-5.4","stream":false,"max_tokens":256,"prompt_cache_options":{"enabled":true},"input":[{"type":"message","content":"hi"}]}`))
  197. require.Equal(t, int64(256), gjson.GetBytes(out, "max_output_tokens").Int())
  198. require.False(t, gjson.GetBytes(out, "max_tokens").Exists())
  199. require.False(t, gjson.GetBytes(out, "prompt_cache_options").Exists())
  200. })
  201. t.Run("同时存在时保留 max_output_tokens 丢弃 max_tokens", func(t *testing.T) {
  202. out := runForward(t, []byte(`{"model":"gpt-5.4","stream":false,"max_tokens":256,"max_output_tokens":512,"input":[{"type":"message","content":"hi"}]}`))
  203. require.Equal(t, int64(512), gjson.GetBytes(out, "max_output_tokens").Int())
  204. require.False(t, gjson.GetBytes(out, "max_tokens").Exists())
  205. })
  206. }
  207. func TestOpenAIGatewayService_Forward_MappedImageModelUsesImageGate(t *testing.T) {
  208. gin.SetMode(gin.TestMode)
  209. upstream := &httpUpstreamRecorder{
  210. resp: &http.Response{
  211. StatusCode: http.StatusOK,
  212. Header: http.Header{"Content-Type": []string{"application/json"}},
  213. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  214. },
  215. }
  216. cfg := &config.Config{}
  217. cfg.Security.URLAllowlist.Enabled = false
  218. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  219. account := &Account{
  220. ID: 3,
  221. Name: "openai-apikey",
  222. Platform: PlatformOpenAI,
  223. Type: AccountTypeAPIKey,
  224. Concurrency: 1,
  225. Credentials: map[string]any{
  226. "api_key": "sk-test",
  227. "base_url": "https://example.com",
  228. "model_mapping": map[string]any{"draw-alias": "gpt-image-2"},
  229. },
  230. Extra: map[string]any{"use_responses_api": true},
  231. }
  232. rec := httptest.NewRecorder()
  233. c, _ := gin.CreateTestContext(rec)
  234. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  235. c.Set("api_key", &APIKey{Group: &Group{AllowImageGeneration: false}})
  236. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  237. body := []byte(`{"model":"draw-alias","stream":false,"input":"draw"}`)
  238. result, err := svc.Forward(context.Background(), c, account, body)
  239. require.Error(t, err)
  240. require.Nil(t, result)
  241. require.Nil(t, upstream.lastReq)
  242. require.Equal(t, http.StatusForbidden, rec.Code)
  243. cached, known := getOpenAIImageIntentHint(c)
  244. require.True(t, known)
  245. require.False(t, cached)
  246. textAccount := *account
  247. textAccount.ID = 4
  248. textAccount.Credentials = map[string]any{
  249. "api_key": "sk-test",
  250. "base_url": "https://example.com",
  251. }
  252. result, err = svc.Forward(context.Background(), c, &textAccount, body)
  253. require.NoError(t, err)
  254. require.NotNil(t, result)
  255. require.NotNil(t, upstream.lastReq)
  256. require.Len(t, upstream.bodies, 1)
  257. cached, known = getOpenAIImageIntentHint(c)
  258. require.True(t, known)
  259. require.False(t, cached)
  260. }
  261. func TestOpenAIGatewayService_Forward_TextResponsesSetsBillingModelToMappedModel(t *testing.T) {
  262. gin.SetMode(gin.TestMode)
  263. upstream := &httpUpstreamRecorder{
  264. resp: &http.Response{
  265. StatusCode: http.StatusOK,
  266. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_text_mapped_billing"}},
  267. Body: io.NopCloser(strings.NewReader(
  268. `{"id":"resp_text_mapped","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}`,
  269. )),
  270. },
  271. }
  272. cfg := &config.Config{}
  273. cfg.Security.URLAllowlist.Enabled = false
  274. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  275. account := &Account{
  276. ID: 4,
  277. Name: "openai-apikey",
  278. Platform: PlatformOpenAI,
  279. Type: AccountTypeAPIKey,
  280. Concurrency: 1,
  281. Credentials: map[string]any{
  282. "api_key": "sk-test",
  283. "base_url": "https://example.com",
  284. "model_mapping": map[string]any{"gpt-5.4": "gpt-5.5"},
  285. },
  286. Extra: map[string]any{"use_responses_api": true},
  287. }
  288. rec := httptest.NewRecorder()
  289. c, _ := gin.CreateTestContext(rec)
  290. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  291. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  292. body := []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`)
  293. result, err := svc.Forward(context.Background(), c, account, body)
  294. require.NoError(t, err)
  295. require.NotNil(t, result)
  296. require.Equal(t, "gpt-5.4", result.Model)
  297. require.Equal(t, "gpt-5.5", result.BillingModel)
  298. require.Equal(t, "gpt-5.5", result.UpstreamModel)
  299. require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.lastBody, "model").String())
  300. require.Equal(t, 0, result.ImageCount)
  301. }
  302. func TestOpenAIGatewayService_Forward_TextResponsesWithoutMappingKeepsRequestedBillingModel(t *testing.T) {
  303. gin.SetMode(gin.TestMode)
  304. upstream := &httpUpstreamRecorder{
  305. resp: &http.Response{
  306. StatusCode: http.StatusOK,
  307. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_text_unmapped_billing"}},
  308. Body: io.NopCloser(strings.NewReader(`{"id":"resp_text_unmapped","object":"response","model":"gpt-5.4","status":"completed","usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}`)),
  309. },
  310. }
  311. cfg := &config.Config{}
  312. cfg.Security.URLAllowlist.Enabled = false
  313. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  314. account := &Account{
  315. ID: 4,
  316. Name: "openai-apikey",
  317. Platform: PlatformOpenAI,
  318. Type: AccountTypeAPIKey,
  319. Concurrency: 1,
  320. Credentials: map[string]any{
  321. "api_key": "sk-test",
  322. "base_url": "https://example.com",
  323. },
  324. Extra: map[string]any{"use_responses_api": true},
  325. }
  326. rec := httptest.NewRecorder()
  327. c, _ := gin.CreateTestContext(rec)
  328. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  329. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  330. result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`))
  331. require.NoError(t, err)
  332. require.NotNil(t, result)
  333. require.Equal(t, "gpt-5.4", result.Model)
  334. require.Equal(t, "gpt-5.4", result.BillingModel)
  335. require.Equal(t, "gpt-5.4", result.UpstreamModel)
  336. }
  337. func TestOpenAIGatewayService_Forward_TextResponsesBillingModelMatchesChatCompletions(t *testing.T) {
  338. gin.SetMode(gin.TestMode)
  339. cfg := &config.Config{}
  340. cfg.Security.URLAllowlist.Enabled = false
  341. account := &Account{
  342. ID: 5,
  343. Name: "openai-apikey",
  344. Platform: PlatformOpenAI,
  345. Type: AccountTypeAPIKey,
  346. Concurrency: 1,
  347. Credentials: map[string]any{
  348. "api_key": "sk-test",
  349. "base_url": "https://example.com",
  350. "model_mapping": map[string]any{"gpt-5.4": "gpt-5.5"},
  351. },
  352. Extra: map[string]any{"use_responses_api": true},
  353. }
  354. responsesUpstream := &httpUpstreamRecorder{
  355. resp: &http.Response{
  356. StatusCode: http.StatusOK,
  357. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_responses_mapped_billing"}},
  358. Body: io.NopCloser(strings.NewReader(
  359. `{"id":"resp_native","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}`,
  360. )),
  361. },
  362. }
  363. responsesSvc := &OpenAIGatewayService{cfg: cfg, httpUpstream: responsesUpstream}
  364. responsesRecorder := httptest.NewRecorder()
  365. responsesCtx, _ := gin.CreateTestContext(responsesRecorder)
  366. responsesCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  367. SetOpenAIClientTransport(responsesCtx, OpenAIClientTransportHTTP)
  368. responsesResult, err := responsesSvc.Forward(context.Background(), responsesCtx, account, []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`))
  369. require.NoError(t, err)
  370. require.NotNil(t, responsesResult)
  371. chatUpstream := &httpUpstreamRecorder{
  372. resp: &http.Response{
  373. StatusCode: http.StatusOK,
  374. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_mapped_billing"}},
  375. Body: io.NopCloser(strings.NewReader(
  376. `data: {"type":"response.completed","response":{"id":"resp_chat","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}}` + "\n\n",
  377. )),
  378. },
  379. }
  380. chatSvc := &OpenAIGatewayService{cfg: cfg, httpUpstream: chatUpstream}
  381. chatRecorder := httptest.NewRecorder()
  382. chatCtx, _ := gin.CreateTestContext(chatRecorder)
  383. chatCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil)
  384. chatResult, err := chatSvc.ForwardAsChatCompletions(context.Background(), chatCtx, account, []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"user","content":"hello"}]}`), "", "")
  385. require.NoError(t, err)
  386. require.NotNil(t, chatResult)
  387. require.Equal(t, chatResult.BillingModel, responsesResult.BillingModel)
  388. require.Equal(t, "gpt-5.5", responsesResult.BillingModel)
  389. require.Equal(t, "gpt-5.5", chatResult.BillingModel)
  390. }
  391. func TestOpenAIGatewayService_Forward_TextDataImageDoesNotForceMapMarshal(t *testing.T) {
  392. gin.SetMode(gin.TestMode)
  393. upstream := &httpUpstreamRecorder{
  394. resp: &http.Response{
  395. StatusCode: http.StatusOK,
  396. Header: http.Header{"Content-Type": []string{"application/json"}},
  397. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  398. },
  399. }
  400. cfg := &config.Config{}
  401. cfg.Security.URLAllowlist.Enabled = false
  402. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  403. account := &Account{
  404. ID: 4,
  405. Name: "openai-apikey",
  406. Platform: PlatformOpenAI,
  407. Type: AccountTypeAPIKey,
  408. Concurrency: 1,
  409. Credentials: map[string]any{
  410. "api_key": "sk-test",
  411. "base_url": "https://example.com",
  412. },
  413. Extra: map[string]any{"use_responses_api": true},
  414. }
  415. rec := httptest.NewRecorder()
  416. c, _ := gin.CreateTestContext(rec)
  417. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  418. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  419. body := []byte(`{"model":"gpt-5","stream":false,"input":[{"type":"message","content":[{"type":"input_text","text":"literal data:image/png;base64, only","nonce":1e1000000}]}]}`)
  420. result, err := svc.Forward(context.Background(), c, account, body)
  421. require.NoError(t, err)
  422. require.NotNil(t, result)
  423. require.Equal(t, "1e1000000", gjson.GetBytes(upstream.lastBody, "input.0.content.0.nonce").Raw)
  424. }
  425. func TestOpenAIGatewayService_Forward_ImageToolBillingDoesNotForceFullDecode(t *testing.T) {
  426. gin.SetMode(gin.TestMode)
  427. upstream := &httpUpstreamRecorder{
  428. resp: &http.Response{
  429. StatusCode: http.StatusOK,
  430. Header: http.Header{"Content-Type": []string{"application/json"}},
  431. Body: io.NopCloser(strings.NewReader(
  432. `{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-image"}],"usage":{"input_tokens":1,"output_tokens":2}}`,
  433. )),
  434. },
  435. }
  436. cfg := &config.Config{}
  437. cfg.Security.URLAllowlist.Enabled = false
  438. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  439. account := &Account{
  440. ID: 9,
  441. Name: "openai-apikey",
  442. Platform: PlatformOpenAI,
  443. Type: AccountTypeAPIKey,
  444. Concurrency: 1,
  445. Credentials: map[string]any{
  446. "api_key": "sk-test",
  447. "base_url": "https://example.com",
  448. },
  449. Extra: map[string]any{"use_responses_api": true},
  450. }
  451. rec := httptest.NewRecorder()
  452. c, _ := gin.CreateTestContext(rec)
  453. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  454. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  455. body := []byte(`{"model":"gpt-5","stream":false,"tools":[{"type":"image_generation","model":"gpt-image-2","size":"2048x1152"}],"input":[{"type":"message","content":[{"type":"input_text","text":"draw","nonce":1e1000000}]}]}`)
  456. result, err := svc.Forward(context.Background(), c, account, body)
  457. require.NoError(t, err)
  458. require.NotNil(t, result)
  459. require.Equal(t, "1e1000000", gjson.GetBytes(upstream.lastBody, "input.0.content.0.nonce").Raw)
  460. require.Equal(t, 1, result.ImageCount)
  461. require.Equal(t, "2K", result.ImageSize)
  462. require.Equal(t, "gpt-image-2", result.BillingModel)
  463. }
  464. func TestOpenAIGatewayService_Forward_ImageToolWithImageOnlyModelIsNormalized(t *testing.T) {
  465. gin.SetMode(gin.TestMode)
  466. upstream := &httpUpstreamRecorder{
  467. resp: &http.Response{
  468. StatusCode: http.StatusOK,
  469. Header: http.Header{"Content-Type": []string{"application/json"}},
  470. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  471. },
  472. }
  473. cfg := &config.Config{}
  474. cfg.Security.URLAllowlist.Enabled = false
  475. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  476. account := &Account{
  477. ID: 11,
  478. Name: "openai-apikey",
  479. Platform: PlatformOpenAI,
  480. Type: AccountTypeAPIKey,
  481. Concurrency: 1,
  482. Credentials: map[string]any{
  483. "api_key": "sk-test",
  484. "base_url": "https://example.com",
  485. },
  486. Extra: map[string]any{"use_responses_api": true},
  487. }
  488. rec := httptest.NewRecorder()
  489. c, _ := gin.CreateTestContext(rec)
  490. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  491. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  492. body := []byte(`{"model":"gpt-image-2","stream":false,"tools":[{"type":"image_generation","model":"gpt-image-2"}],"input":"draw"}`)
  493. result, err := svc.Forward(context.Background(), c, account, body)
  494. require.NoError(t, err)
  495. require.NotNil(t, result)
  496. require.Equal(t, openAIImagesResponsesMainModel, gjson.GetBytes(upstream.lastBody, "model").String())
  497. }
  498. func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDoesNotDecodeBeforeError(t *testing.T) {
  499. gin.SetMode(gin.TestMode)
  500. upstream := &httpUpstreamRecorder{
  501. responses: []*http.Response{
  502. {
  503. StatusCode: http.StatusBadRequest,
  504. Header: http.Header{"Content-Type": []string{"application/json"}},
  505. Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"bad encrypted content"}}`)),
  506. },
  507. {
  508. StatusCode: http.StatusOK,
  509. Header: http.Header{"Content-Type": []string{"application/json"}},
  510. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  511. },
  512. },
  513. }
  514. cfg := &config.Config{}
  515. cfg.Security.URLAllowlist.Enabled = false
  516. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  517. account := &Account{
  518. ID: 10,
  519. Name: "openai-apikey",
  520. Platform: PlatformOpenAI,
  521. Type: AccountTypeAPIKey,
  522. Concurrency: 1,
  523. Credentials: map[string]any{
  524. "api_key": "sk-test",
  525. "base_url": "https://example.com",
  526. },
  527. Extra: map[string]any{"use_responses_api": true},
  528. }
  529. rec := httptest.NewRecorder()
  530. c, _ := gin.CreateTestContext(rec)
  531. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  532. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  533. body := []byte(`{"model":"gpt-5","stream":false,"input":[{"type":"reasoning","encrypted_content":"gAAA","summary":[{"type":"summary_text","text":"keep me"}]},{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}`)
  534. result, err := svc.Forward(context.Background(), c, account, body)
  535. require.NoError(t, err)
  536. require.NotNil(t, result)
  537. require.Len(t, upstream.bodies, 2)
  538. require.Equal(t, "gAAA", gjson.GetBytes(upstream.bodies[0], "input.0.encrypted_content").String())
  539. require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.bodies[0], "input.1.content.0.nonce").Raw)
  540. require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.encrypted_content").Exists())
  541. require.Equal(t, "summary_text", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.type").String())
  542. }
  543. func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDropsCompaction(t *testing.T) {
  544. gin.SetMode(gin.TestMode)
  545. upstream := &httpUpstreamRecorder{
  546. responses: []*http.Response{
  547. {
  548. StatusCode: http.StatusBadRequest,
  549. Header: http.Header{"Content-Type": []string{"application/json"}},
  550. Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"bad encrypted content"}}`)),
  551. },
  552. {
  553. StatusCode: http.StatusOK,
  554. Header: http.Header{"Content-Type": []string{"application/json"}},
  555. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  556. },
  557. },
  558. }
  559. cfg := &config.Config{}
  560. cfg.Security.URLAllowlist.Enabled = false
  561. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  562. account := &Account{
  563. ID: 10,
  564. Name: "openai-apikey",
  565. Platform: PlatformOpenAI,
  566. Type: AccountTypeAPIKey,
  567. Concurrency: 1,
  568. Credentials: map[string]any{
  569. "api_key": "sk-test",
  570. "base_url": "https://example.com",
  571. },
  572. Extra: map[string]any{"use_responses_api": true},
  573. }
  574. rec := httptest.NewRecorder()
  575. c, _ := gin.CreateTestContext(rec)
  576. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  577. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  578. body := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"id":"cmp_stale","type":"compaction","encrypted_content":"gAAA"},{"type":"message","content":[{"type":"input_text","text":"hi"}]}]}`)
  579. result, err := svc.Forward(context.Background(), c, account, body)
  580. require.NoError(t, err)
  581. require.NotNil(t, result)
  582. require.Len(t, upstream.bodies, 2)
  583. require.Equal(t, "compaction", gjson.GetBytes(upstream.bodies[0], "input.0.type").String())
  584. require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.0.type").String())
  585. require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1").Exists())
  586. }
  587. func TestOpenAIGatewayService_Forward_CodexSparkRejectsEscapedInputImage(t *testing.T) {
  588. gin.SetMode(gin.TestMode)
  589. upstream := &httpUpstreamRecorder{
  590. resp: &http.Response{
  591. StatusCode: http.StatusOK,
  592. Header: http.Header{"Content-Type": []string{"application/json"}},
  593. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  594. },
  595. }
  596. cfg := &config.Config{}
  597. cfg.Security.URLAllowlist.Enabled = false
  598. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  599. account := &Account{
  600. ID: 5,
  601. Name: "openai-apikey",
  602. Platform: PlatformOpenAI,
  603. Type: AccountTypeAPIKey,
  604. Concurrency: 1,
  605. Credentials: map[string]any{
  606. "api_key": "sk-test",
  607. "base_url": "https://example.com",
  608. },
  609. Extra: map[string]any{"use_responses_api": true},
  610. }
  611. rec := httptest.NewRecorder()
  612. c, _ := gin.CreateTestContext(rec)
  613. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  614. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  615. body := []byte(`{"model":"gpt-5.3-codex-spark","stream":false,"input":[{"type":"input_` + "\\u0069" + `mage","file_id":"file_1"}]}`)
  616. result, err := svc.Forward(context.Background(), c, account, body)
  617. require.Error(t, err)
  618. require.Nil(t, result)
  619. require.Nil(t, upstream.lastReq)
  620. require.Equal(t, http.StatusBadRequest, rec.Code)
  621. }
  622. func TestOpenAIGatewayService_Forward_CodexBridgeInjectionSetsImageBilling(t *testing.T) {
  623. gin.SetMode(gin.TestMode)
  624. upstream := &httpUpstreamRecorder{
  625. resp: &http.Response{
  626. StatusCode: http.StatusOK,
  627. Header: http.Header{"Content-Type": []string{"application/json"}},
  628. Body: io.NopCloser(strings.NewReader(
  629. `{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-image","size":"1024x1024"}],"usage":{"input_tokens":1,"output_tokens":2}}`,
  630. )),
  631. },
  632. }
  633. cfg := &config.Config{}
  634. cfg.Security.URLAllowlist.Enabled = false
  635. cfg.Gateway.ForceCodexCLI = true
  636. cfg.Gateway.CodexImageGenerationBridgeEnabled = true
  637. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  638. account := &Account{
  639. ID: 7,
  640. Name: "openai-apikey",
  641. Platform: PlatformOpenAI,
  642. Type: AccountTypeAPIKey,
  643. Concurrency: 1,
  644. Credentials: map[string]any{
  645. "api_key": "sk-test",
  646. "base_url": "https://example.com",
  647. },
  648. Extra: map[string]any{"use_responses_api": true},
  649. }
  650. rec := httptest.NewRecorder()
  651. c, _ := gin.CreateTestContext(rec)
  652. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  653. c.Set("api_key", &APIKey{Group: &Group{AllowImageGeneration: true}})
  654. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  655. body := []byte(`{"model":"gpt-5","stream":false,"input":"draw if needed"}`)
  656. result, err := svc.Forward(context.Background(), c, account, body)
  657. require.NoError(t, err)
  658. require.NotNil(t, result)
  659. require.Equal(t, 1, result.ImageCount)
  660. require.Equal(t, "2K", result.ImageSize)
  661. require.Equal(t, "gpt-image-2", result.BillingModel)
  662. }
  663. func TestOpenAIGatewayService_Forward_HTTPPreservesPreviousResponseIDForAPIKey(t *testing.T) {
  664. gin.SetMode(gin.TestMode)
  665. cfg := &config.Config{}
  666. cfg.Security.URLAllowlist.Enabled = false
  667. account := &Account{
  668. ID: 8,
  669. Name: "openai-apikey",
  670. Platform: PlatformOpenAI,
  671. Type: AccountTypeAPIKey,
  672. Concurrency: 1,
  673. Credentials: map[string]any{
  674. "api_key": "sk-test",
  675. "base_url": "https://example.com",
  676. },
  677. Extra: map[string]any{"use_responses_api": true},
  678. }
  679. for _, body := range [][]byte{
  680. []byte(`{"model":"gpt-5","stream":false,"previous_response_id":"","input":"hi"}`),
  681. []byte(`{"model":"gpt-5","stream":false,"previous_response_id":null,"input":"hi"}`),
  682. } {
  683. upstream := &httpUpstreamRecorder{
  684. resp: &http.Response{
  685. StatusCode: http.StatusOK,
  686. Header: http.Header{"Content-Type": []string{"application/json"}},
  687. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  688. },
  689. }
  690. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  691. rec := httptest.NewRecorder()
  692. c, _ := gin.CreateTestContext(rec)
  693. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  694. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  695. result, err := svc.Forward(context.Background(), c, account, body)
  696. require.NoError(t, err)
  697. require.NotNil(t, result)
  698. require.True(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
  699. }
  700. }
  701. func TestOpenAIGatewayService_Forward_StripsImageGenerationToolForSparkAPIKey(t *testing.T) {
  702. gin.SetMode(gin.TestMode)
  703. upstream := &httpUpstreamRecorder{
  704. resp: &http.Response{
  705. StatusCode: http.StatusOK,
  706. Header: http.Header{"Content-Type": []string{"application/json"}},
  707. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  708. },
  709. }
  710. cfg := &config.Config{}
  711. cfg.Security.URLAllowlist.Enabled = false
  712. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  713. account := &Account{
  714. ID: 11,
  715. Name: "openai-apikey",
  716. Platform: PlatformOpenAI,
  717. Type: AccountTypeAPIKey,
  718. Concurrency: 1,
  719. Credentials: map[string]any{
  720. "api_key": "sk-test",
  721. "base_url": "https://example.com",
  722. },
  723. Extra: map[string]any{"use_responses_api": true},
  724. }
  725. rec := httptest.NewRecorder()
  726. c, _ := gin.CreateTestContext(rec)
  727. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  728. // Allow image generation so the tool is normalized (not gated out), reproducing
  729. // the leak the strip must override.
  730. c.Set("api_key", &APIKey{Group: &Group{AllowImageGeneration: true}})
  731. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  732. body := []byte(`{"model":"gpt-5.3-codex-spark","stream":false,"input":"hi","tools":[{"type":"function","name":"shell"},{"type":"image_generation","output_format":"png"}]}`)
  733. result, err := svc.Forward(context.Background(), c, account, body)
  734. require.NoError(t, err)
  735. require.NotNil(t, result)
  736. require.NotNil(t, upstream.lastReq)
  737. require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists())
  738. require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="function")`).Exists())
  739. }
  740. func TestOpenAIRequestBodyMayContainEmptyBase64InputImageSeesEscapedJSON(t *testing.T) {
  741. body := []byte(`{"input":[{"type":"message","content":[{"type":"input_image","image_` + "\\u0075" + `rl":"data:image/png;base64` + "\\u002c" + ` "}]}]}`)
  742. require.True(t, openAIRequestBodyMayContainEmptyBase64InputImage(body))
  743. }
  744. func TestOpenAIRequestBodyMayContainEmptyBase64InputImageSeesEscapedImageType(t *testing.T) {
  745. body := []byte(`{"input":[{"type":"message","content":[{"type":"input_` + "\\u0069" + `mage","image_url":"data:image/png;base64, "}]}]}`)
  746. require.True(t, openAIRequestBodyMayContainEmptyBase64InputImage(body))
  747. }
  748. func TestOpenAIRequestBodyMayContainEmptyBase64InputImageSeesEscapedInputPrefix(t *testing.T) {
  749. body := []byte(`{"input":[{"type":"message","content":[{"type":"inp` + "\\u0075" + `t_image","image_url":"data:image/png;base64, "}]}]}`)
  750. require.True(t, openAIRequestBodyMayContainEmptyBase64InputImage(body))
  751. }
  752. func TestOpenAIGatewayService_Forward_ImageOnlyModelKeepsSupportedVerbosity(t *testing.T) {
  753. gin.SetMode(gin.TestMode)
  754. upstream := &httpUpstreamRecorder{
  755. resp: &http.Response{
  756. StatusCode: http.StatusOK,
  757. Header: http.Header{"Content-Type": []string{"application/json"}},
  758. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  759. },
  760. }
  761. cfg := &config.Config{}
  762. cfg.Security.URLAllowlist.Enabled = false
  763. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  764. account := &Account{
  765. ID: 6,
  766. Name: "openai-apikey",
  767. Platform: PlatformOpenAI,
  768. Type: AccountTypeAPIKey,
  769. Concurrency: 1,
  770. Credentials: map[string]any{
  771. "api_key": "sk-test",
  772. "base_url": "https://example.com",
  773. },
  774. Extra: map[string]any{"use_responses_api": true},
  775. }
  776. rec := httptest.NewRecorder()
  777. c, _ := gin.CreateTestContext(rec)
  778. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  779. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  780. body := []byte(`{"model":"gpt-image-2","stream":false,"text":{"verbosity":"low"},"input":"draw"}`)
  781. result, err := svc.Forward(context.Background(), c, account, body)
  782. require.NoError(t, err)
  783. require.NotNil(t, result)
  784. require.Equal(t, "low", gjson.GetBytes(upstream.lastBody, "text.verbosity").String())
  785. require.Equal(t, openAIImagesResponsesMainModel, gjson.GetBytes(upstream.lastBody, "model").String())
  786. }
  787. func TestExtractOpenAIRequestMetaFromBody(t *testing.T) {
  788. tests := []struct {
  789. name string
  790. body []byte
  791. wantModel string
  792. wantStream bool
  793. wantPromptKey string
  794. }{
  795. {
  796. name: "完整字段",
  797. body: []byte(`{"model":"gpt-5","stream":true,"prompt_cache_key":" ses-1 "}`),
  798. wantModel: "gpt-5",
  799. wantStream: true,
  800. wantPromptKey: "ses-1",
  801. },
  802. {
  803. name: "缺失可选字段",
  804. body: []byte(`{"model":"gpt-4"}`),
  805. wantModel: "gpt-4",
  806. wantStream: false,
  807. wantPromptKey: "",
  808. },
  809. {
  810. name: "空请求体",
  811. body: nil,
  812. wantModel: "",
  813. wantStream: false,
  814. wantPromptKey: "",
  815. },
  816. }
  817. for _, tt := range tests {
  818. t.Run(tt.name, func(t *testing.T) {
  819. model, stream, promptKey := extractOpenAIRequestMetaFromBody(tt.body)
  820. require.Equal(t, tt.wantModel, model)
  821. require.Equal(t, tt.wantStream, stream)
  822. require.Equal(t, tt.wantPromptKey, promptKey)
  823. })
  824. }
  825. }
  826. func TestExtractOpenAIReasoningEffortFromBody(t *testing.T) {
  827. tests := []struct {
  828. name string
  829. body []byte
  830. model string
  831. wantNil bool
  832. wantValue string
  833. }{
  834. {
  835. name: "优先读取 reasoning.effort",
  836. body: []byte(`{"reasoning":{"effort":"medium"}}`),
  837. model: "gpt-5-high",
  838. wantNil: false,
  839. wantValue: "medium",
  840. },
  841. {
  842. name: "兼容 reasoning_effort",
  843. body: []byte(`{"reasoning_effort":"x-high"}`),
  844. model: "",
  845. wantNil: false,
  846. wantValue: "xhigh",
  847. },
  848. {
  849. name: "DeepSeek V4 保留 max",
  850. body: []byte(`{"reasoning_effort":"max"}`),
  851. model: "deepseek-v4-pro",
  852. wantNil: false,
  853. wantValue: "max",
  854. },
  855. {
  856. name: "旧模型仍将 max 归一化为 xhigh",
  857. body: []byte(`{"reasoning_effort":"max"}`),
  858. model: "gpt-5.5",
  859. wantNil: false,
  860. wantValue: "xhigh",
  861. },
  862. {
  863. name: "minimal 归一化为空",
  864. body: []byte(`{"reasoning":{"effort":"minimal"}}`),
  865. model: "gpt-5-high",
  866. wantNil: true,
  867. },
  868. {
  869. name: "缺失字段时从模型后缀推导",
  870. body: []byte(`{"input":"hi"}`),
  871. model: "gpt-5-high",
  872. wantNil: false,
  873. wantValue: "high",
  874. },
  875. {
  876. name: "未知后缀不返回",
  877. body: []byte(`{"input":"hi"}`),
  878. model: "gpt-5-unknown",
  879. wantNil: true,
  880. },
  881. }
  882. for _, tt := range tests {
  883. t.Run(tt.name, func(t *testing.T) {
  884. got := extractOpenAIReasoningEffortFromBody(tt.body, tt.model)
  885. if tt.wantNil {
  886. require.Nil(t, got)
  887. return
  888. }
  889. require.NotNil(t, got)
  890. require.Equal(t, tt.wantValue, *got)
  891. })
  892. }
  893. }
  894. func TestGetOpenAIRequestBodyMap_ParseError(t *testing.T) {
  895. _, err := getOpenAIRequestBodyMap(nil, []byte(`{invalid-json`))
  896. require.Error(t, err)
  897. require.Contains(t, err.Error(), "parse request")
  898. }
  899. func TestGetOpenAIRequestBodyMap_DoesNotWriteContextCache(t *testing.T) {
  900. gin.SetMode(gin.TestMode)
  901. rec := httptest.NewRecorder()
  902. c, _ := gin.CreateTestContext(rec)
  903. got, err := getOpenAIRequestBodyMap(c, []byte(`{"model":"gpt-5","stream":true}`))
  904. require.NoError(t, err)
  905. require.Equal(t, "gpt-5", got["model"])
  906. require.Empty(t, c.Keys)
  907. }
  908. func TestSanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(t *testing.T) {
  909. var reqBody map[string]any
  910. require.NoError(t, json.Unmarshal([]byte(`{
  911. "model":"gpt-5.4",
  912. "input":[
  913. {"role":"user","content":[
  914. {"type":"input_text","text":"Describe this"},
  915. {"type":"input_image","image_url":"data:image/png;base64, "},
  916. {"type":"input_image","image_url":"data:image/png;base64,abc123"}
  917. ]},
  918. {"role":"user","content":[
  919. {"type":"input_image","image_url":"data:image/png;base64,"}
  920. ]},
  921. {"type":"input_image","image_url":"data:image/png;base64,"},
  922. {"type":"input_image","image_url":"data:image/png;base64,top-level-valid"}
  923. ]
  924. }`), &reqBody))
  925. require.True(t, sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody))
  926. normalized, err := json.Marshal(reqBody)
  927. require.NoError(t, err)
  928. require.JSONEq(t, `{
  929. "model":"gpt-5.4",
  930. "input":[
  931. {"role":"user","content":[
  932. {"type":"input_text","text":"Describe this"},
  933. {"type":"input_image","image_url":"data:image/png;base64,abc123"}
  934. ]},
  935. {"type":"input_image","image_url":"data:image/png;base64,top-level-valid"}
  936. ]
  937. }`, string(normalized))
  938. }
  939. func TestSanitizeEmptyBase64InputImagesInOpenAIBody(t *testing.T) {
  940. body, changed, err := sanitizeEmptyBase64InputImagesInOpenAIBody([]byte(`{
  941. "model":"gpt-5.4",
  942. "stream":true,
  943. "input":[
  944. {"role":"user","content":[
  945. {"type":"input_text","text":"Describe this"},
  946. {"type":"input_image","image_url":"data:image/png;base64,"}
  947. ]}
  948. ]
  949. }`))
  950. require.NoError(t, err)
  951. require.True(t, changed)
  952. require.JSONEq(t, `{
  953. "model":"gpt-5.4",
  954. "stream":true,
  955. "input":[
  956. {"role":"user","content":[
  957. {"type":"input_text","text":"Describe this"}
  958. ]}
  959. ]
  960. }`, string(body))
  961. }