openai_ws_protocol_forward_test.go 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  16. "github.com/gin-gonic/gin"
  17. "github.com/gorilla/websocket"
  18. "github.com/stretchr/testify/require"
  19. "github.com/tidwall/gjson"
  20. )
  21. type httpUpstreamSequenceRecorder struct {
  22. mu sync.Mutex
  23. bodies [][]byte
  24. reqs []*http.Request
  25. responses []*http.Response
  26. errs []error
  27. callCount int
  28. }
  29. func (u *httpUpstreamSequenceRecorder) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  30. u.mu.Lock()
  31. defer u.mu.Unlock()
  32. idx := u.callCount
  33. u.callCount++
  34. u.reqs = append(u.reqs, req)
  35. if req != nil && req.Body != nil {
  36. b, _ := io.ReadAll(req.Body)
  37. u.bodies = append(u.bodies, b)
  38. _ = req.Body.Close()
  39. req.Body = io.NopCloser(bytes.NewReader(b))
  40. } else {
  41. u.bodies = append(u.bodies, nil)
  42. }
  43. if idx < len(u.errs) && u.errs[idx] != nil {
  44. return nil, u.errs[idx]
  45. }
  46. if idx < len(u.responses) {
  47. return u.responses[idx], nil
  48. }
  49. if len(u.responses) == 0 {
  50. return nil, nil
  51. }
  52. return u.responses[len(u.responses)-1], nil
  53. }
  54. func (u *httpUpstreamSequenceRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  55. return u.Do(req, proxyURL, accountID, accountConcurrency)
  56. }
  57. func TestOpenAIGatewayService_Forward_PreservePreviousResponseIDWhenWSEnabled(t *testing.T) {
  58. gin.SetMode(gin.TestMode)
  59. wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  60. http.NotFound(w, r)
  61. }))
  62. defer wsFallbackServer.Close()
  63. rec := httptest.NewRecorder()
  64. c, _ := gin.CreateTestContext(rec)
  65. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  66. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  67. upstream := &httpUpstreamRecorder{
  68. resp: &http.Response{
  69. StatusCode: http.StatusOK,
  70. Header: http.Header{"Content-Type": []string{"application/json"}},
  71. Body: io.NopCloser(strings.NewReader(
  72. `{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  73. )),
  74. },
  75. }
  76. cfg := &config.Config{}
  77. cfg.Security.URLAllowlist.Enabled = false
  78. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  79. cfg.Gateway.OpenAIWS.Enabled = true
  80. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  81. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  82. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  83. svc := &OpenAIGatewayService{
  84. cfg: cfg,
  85. httpUpstream: upstream,
  86. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  87. }
  88. account := &Account{
  89. ID: 1,
  90. Name: "openai-apikey",
  91. Platform: PlatformOpenAI,
  92. Type: AccountTypeAPIKey,
  93. Concurrency: 1,
  94. Credentials: map[string]any{
  95. "api_key": "sk-test",
  96. "base_url": wsFallbackServer.URL,
  97. },
  98. Extra: map[string]any{
  99. "responses_websockets_v2_enabled": true,
  100. },
  101. }
  102. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_123","input":[{"type":"input_text","text":"hello"}]}`)
  103. result, err := svc.Forward(context.Background(), c, account, body)
  104. require.Error(t, err)
  105. require.Nil(t, result)
  106. require.Nil(t, upstream.lastReq, "WS 模式下失败时不应回退 HTTP")
  107. }
  108. func TestOpenAIGatewayService_Forward_HTTPIngressStaysHTTPWhenWSEnabled(t *testing.T) {
  109. gin.SetMode(gin.TestMode)
  110. wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  111. http.NotFound(w, r)
  112. }))
  113. defer wsFallbackServer.Close()
  114. rec := httptest.NewRecorder()
  115. c, _ := gin.CreateTestContext(rec)
  116. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  117. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  118. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  119. upstream := &httpUpstreamRecorder{
  120. resp: &http.Response{
  121. StatusCode: http.StatusOK,
  122. Header: http.Header{"Content-Type": []string{"application/json"}},
  123. Body: io.NopCloser(strings.NewReader(
  124. `{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  125. )),
  126. },
  127. }
  128. cfg := &config.Config{}
  129. cfg.Security.URLAllowlist.Enabled = false
  130. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  131. cfg.Gateway.OpenAIWS.Enabled = true
  132. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  133. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  134. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  135. svc := &OpenAIGatewayService{
  136. cfg: cfg,
  137. httpUpstream: upstream,
  138. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  139. }
  140. account := &Account{
  141. ID: 101,
  142. Name: "openai-apikey",
  143. Platform: PlatformOpenAI,
  144. Type: AccountTypeAPIKey,
  145. Concurrency: 1,
  146. Credentials: map[string]any{
  147. "api_key": "sk-test",
  148. "base_url": wsFallbackServer.URL,
  149. },
  150. Extra: map[string]any{
  151. "responses_websockets_v2_enabled": true,
  152. },
  153. }
  154. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_http_keep","input":[{"type":"input_text","text":"hello"}]}`)
  155. result, err := svc.Forward(context.Background(), c, account, body)
  156. require.NoError(t, err)
  157. require.NotNil(t, result)
  158. require.False(t, result.OpenAIWSMode, "HTTP 入站应保持 HTTP 转发")
  159. require.NotNil(t, upstream.lastReq, "HTTP 入站应命中 HTTP 上游")
  160. require.Equal(t, "resp_http_keep", gjson.GetBytes(upstream.lastBody, "previous_response_id").String(), "API-key HTTP must preserve official Responses continuation")
  161. decision, _ := c.Get("openai_ws_transport_decision")
  162. reason, _ := c.Get("openai_ws_transport_reason")
  163. require.Equal(t, string(OpenAIUpstreamTransportHTTPSSE), decision)
  164. require.Equal(t, "client_protocol_http", reason)
  165. }
  166. func TestOpenAIGatewayService_Forward_HTTPIngressRetriesInvalidEncryptedContentOnce(t *testing.T) {
  167. gin.SetMode(gin.TestMode)
  168. wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  169. http.NotFound(w, r)
  170. }))
  171. defer wsFallbackServer.Close()
  172. rec := httptest.NewRecorder()
  173. c, _ := gin.CreateTestContext(rec)
  174. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  175. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  176. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  177. upstream := &httpUpstreamSequenceRecorder{
  178. responses: []*http.Response{
  179. {
  180. StatusCode: http.StatusBadRequest,
  181. Header: http.Header{"Content-Type": []string{"application/json"}},
  182. Body: io.NopCloser(strings.NewReader(
  183. `{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"The encrypted content could not be verified."}}`,
  184. )),
  185. },
  186. {
  187. StatusCode: http.StatusOK,
  188. Header: http.Header{"Content-Type": []string{"application/json"}},
  189. Body: io.NopCloser(strings.NewReader(
  190. `{"id":"resp_http_retry_ok","usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  191. )),
  192. },
  193. },
  194. }
  195. cfg := &config.Config{}
  196. cfg.Security.URLAllowlist.Enabled = false
  197. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  198. cfg.Gateway.OpenAIWS.Enabled = true
  199. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  200. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  201. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  202. svc := &OpenAIGatewayService{
  203. cfg: cfg,
  204. httpUpstream: upstream,
  205. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  206. }
  207. account := &Account{
  208. ID: 102,
  209. Name: "openai-apikey",
  210. Platform: PlatformOpenAI,
  211. Type: AccountTypeAPIKey,
  212. Concurrency: 1,
  213. Credentials: map[string]any{
  214. "api_key": "sk-test",
  215. "base_url": wsFallbackServer.URL,
  216. },
  217. Extra: map[string]any{
  218. "responses_websockets_v2_enabled": true,
  219. },
  220. }
  221. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_http_retry","input":[{"type":"reasoning","encrypted_content":"gAAA","summary":[{"type":"summary_text","text":"keep me"}]},{"type":"input_text","text":"hello"}]}`)
  222. result, err := svc.Forward(context.Background(), c, account, body)
  223. require.NoError(t, err)
  224. require.NotNil(t, result)
  225. require.False(t, result.OpenAIWSMode, "HTTP 入站应保持 HTTP 转发")
  226. require.Equal(t, 2, upstream.callCount, "命中 invalid_encrypted_content 后应只在 HTTP 路径重试一次")
  227. require.Len(t, upstream.bodies, 2)
  228. firstBody := upstream.bodies[0]
  229. secondBody := upstream.bodies[1]
  230. require.Equal(t, "resp_http_retry", gjson.GetBytes(firstBody, "previous_response_id").String(), "API-key HTTP must preserve continuation on the first attempt")
  231. require.True(t, gjson.GetBytes(firstBody, "input.0.encrypted_content").Exists(), "首次请求不应做发送前预清理")
  232. require.Equal(t, "keep me", gjson.GetBytes(firstBody, "input.0.summary.0.text").String())
  233. require.Equal(t, "resp_http_retry", gjson.GetBytes(secondBody, "previous_response_id").String(), "encrypted-content retry must preserve API-key continuation")
  234. require.False(t, gjson.GetBytes(secondBody, "input.0.encrypted_content").Exists(), "精确重试应移除 reasoning.encrypted_content")
  235. require.Equal(t, "keep me", gjson.GetBytes(secondBody, "input.0.summary.0.text").String(), "精确重试应保留有效 reasoning summary")
  236. require.Equal(t, "input_text", gjson.GetBytes(secondBody, "input.1.type").String(), "非 reasoning input 应保持原样")
  237. decision, _ := c.Get("openai_ws_transport_decision")
  238. reason, _ := c.Get("openai_ws_transport_reason")
  239. require.Equal(t, string(OpenAIUpstreamTransportHTTPSSE), decision)
  240. require.Equal(t, "client_protocol_http", reason)
  241. }
  242. func TestOpenAIGatewayService_Forward_HTTPIngressRetriesWrappedInvalidEncryptedContentOnce(t *testing.T) {
  243. gin.SetMode(gin.TestMode)
  244. wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  245. http.NotFound(w, r)
  246. }))
  247. defer wsFallbackServer.Close()
  248. rec := httptest.NewRecorder()
  249. c, _ := gin.CreateTestContext(rec)
  250. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  251. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  252. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  253. upstream := &httpUpstreamSequenceRecorder{
  254. responses: []*http.Response{
  255. {
  256. StatusCode: http.StatusBadRequest,
  257. Header: http.Header{"Content-Type": []string{"application/json"}},
  258. Body: io.NopCloser(strings.NewReader(
  259. `{"error":{"code":null,"message":"{\"error\":{\"message\":\"The encrypted content could not be verified.\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_encrypted_content\"}}(traceid: fb7ad1dbc7699c18f8a02f258f1af5ab)","param":null,"type":"invalid_request_error"}}`,
  260. )),
  261. },
  262. {
  263. StatusCode: http.StatusOK,
  264. Header: http.Header{
  265. "Content-Type": []string{"application/json"},
  266. "x-request-id": []string{"req_http_retry_wrapped_ok"},
  267. },
  268. Body: io.NopCloser(strings.NewReader(
  269. `{"id":"resp_http_retry_wrapped_ok","usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  270. )),
  271. },
  272. },
  273. }
  274. cfg := &config.Config{}
  275. cfg.Security.URLAllowlist.Enabled = false
  276. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  277. cfg.Gateway.OpenAIWS.Enabled = true
  278. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  279. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  280. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  281. svc := &OpenAIGatewayService{
  282. cfg: cfg,
  283. httpUpstream: upstream,
  284. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  285. }
  286. account := &Account{
  287. ID: 103,
  288. Name: "openai-apikey-wrapped",
  289. Platform: PlatformOpenAI,
  290. Type: AccountTypeAPIKey,
  291. Concurrency: 1,
  292. Credentials: map[string]any{
  293. "api_key": "sk-test",
  294. "base_url": wsFallbackServer.URL,
  295. },
  296. Extra: map[string]any{
  297. "responses_websockets_v2_enabled": true,
  298. },
  299. }
  300. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_http_retry_wrapped","input":[{"type":"reasoning","encrypted_content":"gAAA","summary":[{"type":"summary_text","text":"keep me too"}]},{"type":"input_text","text":"hello"}]}`)
  301. result, err := svc.Forward(context.Background(), c, account, body)
  302. require.NoError(t, err)
  303. require.NotNil(t, result)
  304. require.False(t, result.OpenAIWSMode, "HTTP 入站应保持 HTTP 转发")
  305. require.Equal(t, 2, upstream.callCount, "wrapped invalid_encrypted_content 也应只在 HTTP 路径重试一次")
  306. require.Len(t, upstream.bodies, 2)
  307. firstBody := upstream.bodies[0]
  308. secondBody := upstream.bodies[1]
  309. require.True(t, gjson.GetBytes(firstBody, "input.0.encrypted_content").Exists(), "首次请求不应做发送前预清理")
  310. require.False(t, gjson.GetBytes(secondBody, "input.0.encrypted_content").Exists(), "wrapped exact retry 应移除 reasoning.encrypted_content")
  311. require.Equal(t, "keep me too", gjson.GetBytes(secondBody, "input.0.summary.0.text").String(), "wrapped exact retry 应保留有效 reasoning summary")
  312. decision, _ := c.Get("openai_ws_transport_decision")
  313. reason, _ := c.Get("openai_ws_transport_reason")
  314. require.Equal(t, string(OpenAIUpstreamTransportHTTPSSE), decision)
  315. require.Equal(t, "client_protocol_http", reason)
  316. }
  317. func TestOpenAIGatewayService_Forward_APIKeyHTTPPreservesPreviousResponseIDWhenWSDisabled(t *testing.T) {
  318. gin.SetMode(gin.TestMode)
  319. wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  320. http.NotFound(w, r)
  321. }))
  322. defer wsFallbackServer.Close()
  323. rec := httptest.NewRecorder()
  324. c, _ := gin.CreateTestContext(rec)
  325. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  326. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  327. upstream := &httpUpstreamRecorder{
  328. resp: &http.Response{
  329. StatusCode: http.StatusOK,
  330. Header: http.Header{"Content-Type": []string{"application/json"}},
  331. Body: io.NopCloser(strings.NewReader(
  332. `{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  333. )),
  334. },
  335. }
  336. cfg := &config.Config{}
  337. cfg.Security.URLAllowlist.Enabled = false
  338. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  339. cfg.Gateway.OpenAIWS.Enabled = false
  340. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  341. svc := &OpenAIGatewayService{
  342. cfg: cfg,
  343. httpUpstream: upstream,
  344. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  345. }
  346. account := &Account{
  347. ID: 1,
  348. Name: "openai-apikey",
  349. Platform: PlatformOpenAI,
  350. Type: AccountTypeAPIKey,
  351. Concurrency: 1,
  352. Credentials: map[string]any{
  353. "api_key": "sk-test",
  354. "base_url": wsFallbackServer.URL,
  355. },
  356. Extra: map[string]any{
  357. "responses_websockets_v2_enabled": true,
  358. },
  359. }
  360. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_123","input":[{"type":"input_text","text":"hello"}]}`)
  361. result, err := svc.Forward(context.Background(), c, account, body)
  362. require.NoError(t, err)
  363. require.NotNil(t, result)
  364. require.Equal(t, "resp_123", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
  365. }
  366. func TestOpenAIGatewayService_Forward_WSv2Dial426FallbackHTTP(t *testing.T) {
  367. gin.SetMode(gin.TestMode)
  368. ws426Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  369. w.WriteHeader(http.StatusUpgradeRequired)
  370. _, _ = w.Write([]byte(`upgrade required`))
  371. }))
  372. defer ws426Server.Close()
  373. rec := httptest.NewRecorder()
  374. c, _ := gin.CreateTestContext(rec)
  375. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  376. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  377. upstream := &httpUpstreamRecorder{
  378. resp: &http.Response{
  379. StatusCode: http.StatusOK,
  380. Header: http.Header{"Content-Type": []string{"application/json"}},
  381. Body: io.NopCloser(strings.NewReader(
  382. `{"usage":{"input_tokens":8,"output_tokens":9,"input_tokens_details":{"cached_tokens":1}}}`,
  383. )),
  384. },
  385. }
  386. cfg := &config.Config{}
  387. cfg.Security.URLAllowlist.Enabled = false
  388. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  389. cfg.Gateway.OpenAIWS.Enabled = true
  390. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  391. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  392. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  393. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  394. svc := &OpenAIGatewayService{
  395. cfg: cfg,
  396. httpUpstream: upstream,
  397. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  398. }
  399. account := &Account{
  400. ID: 12,
  401. Name: "openai-apikey",
  402. Platform: PlatformOpenAI,
  403. Type: AccountTypeAPIKey,
  404. Concurrency: 1,
  405. Credentials: map[string]any{
  406. "api_key": "sk-test",
  407. "base_url": ws426Server.URL,
  408. },
  409. Extra: map[string]any{
  410. "responses_websockets_v2_enabled": true,
  411. },
  412. }
  413. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_426","input":[{"type":"input_text","text":"hello"}]}`)
  414. result, err := svc.Forward(context.Background(), c, account, body)
  415. require.Error(t, err)
  416. require.Nil(t, result)
  417. require.Contains(t, err.Error(), "upgrade_required")
  418. require.Nil(t, upstream.lastReq, "WS 模式下不应再回退 HTTP")
  419. require.Equal(t, http.StatusUpgradeRequired, rec.Code)
  420. require.Contains(t, rec.Body.String(), "426")
  421. }
  422. func TestOpenAIGatewayService_Forward_WSv2FallbackCoolingSkipWS(t *testing.T) {
  423. gin.SetMode(gin.TestMode)
  424. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  425. http.NotFound(w, r)
  426. }))
  427. defer wsServer.Close()
  428. rec := httptest.NewRecorder()
  429. c, _ := gin.CreateTestContext(rec)
  430. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  431. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  432. upstream := &httpUpstreamRecorder{
  433. resp: &http.Response{
  434. StatusCode: http.StatusOK,
  435. Header: http.Header{"Content-Type": []string{"application/json"}},
  436. Body: io.NopCloser(strings.NewReader(
  437. `{"usage":{"input_tokens":2,"output_tokens":3,"input_tokens_details":{"cached_tokens":0}}}`,
  438. )),
  439. },
  440. }
  441. cfg := &config.Config{}
  442. cfg.Security.URLAllowlist.Enabled = false
  443. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  444. cfg.Gateway.OpenAIWS.Enabled = true
  445. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  446. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  447. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  448. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 30
  449. svc := &OpenAIGatewayService{
  450. cfg: cfg,
  451. httpUpstream: upstream,
  452. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  453. }
  454. account := &Account{
  455. ID: 21,
  456. Name: "openai-apikey",
  457. Platform: PlatformOpenAI,
  458. Type: AccountTypeAPIKey,
  459. Concurrency: 1,
  460. Credentials: map[string]any{
  461. "api_key": "sk-test",
  462. "base_url": wsServer.URL,
  463. },
  464. Extra: map[string]any{
  465. "responses_websockets_v2_enabled": true,
  466. },
  467. }
  468. svc.markOpenAIWSFallbackCooling(account.ID, "upgrade_required")
  469. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_cooling","input":[{"type":"input_text","text":"hello"}]}`)
  470. result, err := svc.Forward(context.Background(), c, account, body)
  471. require.Error(t, err)
  472. require.Nil(t, result)
  473. require.Nil(t, upstream.lastReq, "WS 模式下不应再回退 HTTP")
  474. _, ok := c.Get("openai_ws_fallback_cooling")
  475. require.False(t, ok, "已移除 fallback cooling 快捷回退路径")
  476. }
  477. func TestOpenAIGatewayService_Forward_ReturnErrorWhenOnlyWSv1Enabled(t *testing.T) {
  478. gin.SetMode(gin.TestMode)
  479. rec := httptest.NewRecorder()
  480. c, _ := gin.CreateTestContext(rec)
  481. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  482. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  483. upstream := &httpUpstreamRecorder{
  484. resp: &http.Response{
  485. StatusCode: http.StatusOK,
  486. Header: http.Header{"Content-Type": []string{"application/json"}},
  487. Body: io.NopCloser(strings.NewReader(
  488. `{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`,
  489. )),
  490. },
  491. }
  492. cfg := &config.Config{}
  493. cfg.Security.URLAllowlist.Enabled = false
  494. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  495. cfg.Gateway.OpenAIWS.Enabled = true
  496. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  497. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  498. cfg.Gateway.OpenAIWS.ResponsesWebsockets = true
  499. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = false
  500. svc := &OpenAIGatewayService{
  501. cfg: cfg,
  502. httpUpstream: upstream,
  503. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  504. }
  505. account := &Account{
  506. ID: 31,
  507. Name: "openai-apikey",
  508. Platform: PlatformOpenAI,
  509. Type: AccountTypeAPIKey,
  510. Concurrency: 1,
  511. Credentials: map[string]any{
  512. "api_key": "sk-test",
  513. "base_url": "https://api.openai.com/v1/responses",
  514. },
  515. Extra: map[string]any{
  516. "responses_websockets_v2_enabled": true,
  517. },
  518. }
  519. body := []byte(`{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_v1","input":[{"type":"input_text","text":"hello"}]}`)
  520. result, err := svc.Forward(context.Background(), c, account, body)
  521. require.Error(t, err)
  522. require.Nil(t, result)
  523. require.Contains(t, err.Error(), "ws v1")
  524. require.Equal(t, http.StatusBadRequest, rec.Code)
  525. require.Contains(t, rec.Body.String(), "WSv1")
  526. require.Nil(t, upstream.lastReq, "WSv1 不支持时不应触发 HTTP 上游请求")
  527. }
  528. func TestNewOpenAIGatewayService_InitializesOpenAIWSResolver(t *testing.T) {
  529. cfg := &config.Config{}
  530. svc := NewOpenAIGatewayService(
  531. nil,
  532. nil,
  533. nil,
  534. nil,
  535. nil,
  536. nil,
  537. nil,
  538. cfg,
  539. nil,
  540. nil,
  541. nil,
  542. nil,
  543. nil,
  544. nil,
  545. nil,
  546. nil,
  547. nil,
  548. nil,
  549. nil,
  550. nil,
  551. nil,
  552. nil, // userPlatformQuotaRepo
  553. )
  554. decision := svc.getOpenAIWSProtocolResolver().Resolve(nil)
  555. require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport)
  556. require.Equal(t, "account_missing", decision.Reason)
  557. }
  558. func TestOpenAIGatewayService_Forward_WSv2FallbackWhenResponseAlreadyWrittenReturnsWSError(t *testing.T) {
  559. gin.SetMode(gin.TestMode)
  560. ws426Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  561. w.WriteHeader(http.StatusUpgradeRequired)
  562. _, _ = w.Write([]byte(`upgrade required`))
  563. }))
  564. defer ws426Server.Close()
  565. rec := httptest.NewRecorder()
  566. c, _ := gin.CreateTestContext(rec)
  567. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  568. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  569. c.String(http.StatusAccepted, "already-written")
  570. upstream := &httpUpstreamRecorder{
  571. resp: &http.Response{
  572. StatusCode: http.StatusOK,
  573. Header: http.Header{"Content-Type": []string{"application/json"}},
  574. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":1}}`)),
  575. },
  576. }
  577. cfg := &config.Config{}
  578. cfg.Security.URLAllowlist.Enabled = false
  579. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  580. cfg.Gateway.OpenAIWS.Enabled = true
  581. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  582. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  583. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  584. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  585. svc := &OpenAIGatewayService{
  586. cfg: cfg,
  587. httpUpstream: upstream,
  588. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  589. }
  590. account := &Account{
  591. ID: 41,
  592. Name: "openai-apikey",
  593. Platform: PlatformOpenAI,
  594. Type: AccountTypeAPIKey,
  595. Concurrency: 1,
  596. Credentials: map[string]any{
  597. "api_key": "sk-test",
  598. "base_url": ws426Server.URL,
  599. },
  600. Extra: map[string]any{
  601. "responses_websockets_v2_enabled": true,
  602. },
  603. }
  604. body := []byte(`{"model":"gpt-5.1","stream":false,"input":[{"type":"input_text","text":"hello"}]}`)
  605. result, err := svc.Forward(context.Background(), c, account, body)
  606. require.Error(t, err)
  607. require.Nil(t, result)
  608. require.Contains(t, err.Error(), "ws fallback")
  609. require.Nil(t, upstream.lastReq, "已写下游响应时,不应再回退 HTTP")
  610. }
  611. func TestOpenAIGatewayService_Forward_WSv2StreamEarlyCloseFallbackHTTP(t *testing.T) {
  612. gin.SetMode(gin.TestMode)
  613. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  614. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  615. conn, err := upgrader.Upgrade(w, r, nil)
  616. if err != nil {
  617. t.Errorf("upgrade websocket failed: %v", err)
  618. return
  619. }
  620. defer func() {
  621. _ = conn.Close()
  622. }()
  623. var req map[string]any
  624. if err := conn.ReadJSON(&req); err != nil {
  625. t.Errorf("read ws request failed: %v", err)
  626. return
  627. }
  628. // 仅发送 response.created(非 token 事件)后立即关闭,
  629. // 模拟线上“上游早期内部错误断连”的场景。
  630. if err := conn.WriteJSON(map[string]any{
  631. "type": "response.created",
  632. "response": map[string]any{
  633. "id": "resp_ws_created_only",
  634. "model": "gpt-5.3-codex",
  635. },
  636. }); err != nil {
  637. t.Errorf("write response.created failed: %v", err)
  638. return
  639. }
  640. closePayload := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "")
  641. _ = conn.WriteControl(websocket.CloseMessage, closePayload, time.Now().Add(time.Second))
  642. }))
  643. defer wsServer.Close()
  644. rec := httptest.NewRecorder()
  645. c, _ := gin.CreateTestContext(rec)
  646. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  647. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  648. upstream := &httpUpstreamRecorder{
  649. resp: &http.Response{
  650. StatusCode: http.StatusOK,
  651. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  652. Body: io.NopCloser(strings.NewReader(
  653. "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n" +
  654. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_http_fallback\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n" +
  655. "data: [DONE]\n\n",
  656. )),
  657. },
  658. }
  659. cfg := &config.Config{}
  660. cfg.Security.URLAllowlist.Enabled = false
  661. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  662. cfg.Gateway.OpenAIWS.Enabled = true
  663. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  664. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  665. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  666. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  667. svc := &OpenAIGatewayService{
  668. cfg: cfg,
  669. httpUpstream: upstream,
  670. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  671. toolCorrector: NewCodexToolCorrector(),
  672. }
  673. account := &Account{
  674. ID: 88,
  675. Name: "openai-apikey",
  676. Platform: PlatformOpenAI,
  677. Type: AccountTypeAPIKey,
  678. Concurrency: 1,
  679. Credentials: map[string]any{
  680. "api_key": "sk-test",
  681. "base_url": wsServer.URL,
  682. },
  683. Extra: map[string]any{
  684. "responses_websockets_v2_enabled": true,
  685. },
  686. }
  687. body := []byte(`{"model":"gpt-5.3-codex","stream":true,"input":[{"type":"input_text","text":"hello"}]}`)
  688. result, err := svc.Forward(context.Background(), c, account, body)
  689. require.Error(t, err)
  690. require.Nil(t, result)
  691. require.Nil(t, upstream.lastReq, "WS 早期断连后不应再回退 HTTP")
  692. require.Empty(t, rec.Body.String(), "未产出 token 前上游断连时不应写入下游半截流")
  693. }
  694. func TestOpenAIGatewayService_Forward_WSv2RetryFiveTimesThenFallbackHTTP(t *testing.T) {
  695. gin.SetMode(gin.TestMode)
  696. var wsAttempts atomic.Int32
  697. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  698. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  699. wsAttempts.Add(1)
  700. conn, err := upgrader.Upgrade(w, r, nil)
  701. if err != nil {
  702. t.Errorf("upgrade websocket failed: %v", err)
  703. return
  704. }
  705. defer func() {
  706. _ = conn.Close()
  707. }()
  708. var req map[string]any
  709. if err := conn.ReadJSON(&req); err != nil {
  710. t.Errorf("read ws request failed: %v", err)
  711. return
  712. }
  713. closePayload := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "")
  714. _ = conn.WriteControl(websocket.CloseMessage, closePayload, time.Now().Add(time.Second))
  715. }))
  716. defer wsServer.Close()
  717. rec := httptest.NewRecorder()
  718. c, _ := gin.CreateTestContext(rec)
  719. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  720. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  721. upstream := &httpUpstreamRecorder{
  722. resp: &http.Response{
  723. StatusCode: http.StatusOK,
  724. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  725. Body: io.NopCloser(strings.NewReader(
  726. "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n" +
  727. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_retry_http_fallback\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n" +
  728. "data: [DONE]\n\n",
  729. )),
  730. },
  731. }
  732. cfg := &config.Config{}
  733. cfg.Security.URLAllowlist.Enabled = false
  734. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  735. cfg.Gateway.OpenAIWS.Enabled = true
  736. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  737. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  738. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  739. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  740. svc := &OpenAIGatewayService{
  741. cfg: cfg,
  742. httpUpstream: upstream,
  743. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  744. toolCorrector: NewCodexToolCorrector(),
  745. }
  746. account := &Account{
  747. ID: 89,
  748. Name: "openai-apikey",
  749. Platform: PlatformOpenAI,
  750. Type: AccountTypeAPIKey,
  751. Concurrency: 1,
  752. Credentials: map[string]any{
  753. "api_key": "sk-test",
  754. "base_url": wsServer.URL,
  755. },
  756. Extra: map[string]any{
  757. "responses_websockets_v2_enabled": true,
  758. },
  759. }
  760. body := []byte(`{"model":"gpt-5.3-codex","stream":true,"input":[{"type":"input_text","text":"hello"}]}`)
  761. result, err := svc.Forward(context.Background(), c, account, body)
  762. require.Error(t, err)
  763. require.Nil(t, result)
  764. require.Nil(t, upstream.lastReq, "WS 重连耗尽后不应再回退 HTTP")
  765. require.Equal(t, int32(openAIWSReconnectRetryLimit+1), wsAttempts.Load())
  766. }
  767. func TestOpenAIGatewayService_Forward_WSv2PolicyViolationFastFallbackHTTP(t *testing.T) {
  768. gin.SetMode(gin.TestMode)
  769. var wsAttempts atomic.Int32
  770. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  771. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  772. wsAttempts.Add(1)
  773. conn, err := upgrader.Upgrade(w, r, nil)
  774. if err != nil {
  775. t.Errorf("upgrade websocket failed: %v", err)
  776. return
  777. }
  778. defer func() {
  779. _ = conn.Close()
  780. }()
  781. var req map[string]any
  782. if err := conn.ReadJSON(&req); err != nil {
  783. t.Errorf("read ws request failed: %v", err)
  784. return
  785. }
  786. closePayload := websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "")
  787. _ = conn.WriteControl(websocket.CloseMessage, closePayload, time.Now().Add(time.Second))
  788. }))
  789. defer wsServer.Close()
  790. rec := httptest.NewRecorder()
  791. c, _ := gin.CreateTestContext(rec)
  792. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  793. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  794. upstream := &httpUpstreamRecorder{
  795. resp: &http.Response{
  796. StatusCode: http.StatusOK,
  797. Header: http.Header{"Content-Type": []string{"application/json"}},
  798. Body: io.NopCloser(strings.NewReader(`{"id":"resp_policy_fallback","usage":{"input_tokens":1,"output_tokens":1}}`)),
  799. },
  800. }
  801. cfg := &config.Config{}
  802. cfg.Security.URLAllowlist.Enabled = false
  803. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  804. cfg.Gateway.OpenAIWS.Enabled = true
  805. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  806. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  807. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  808. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  809. cfg.Gateway.OpenAIWS.RetryBackoffInitialMS = 1
  810. cfg.Gateway.OpenAIWS.RetryBackoffMaxMS = 2
  811. cfg.Gateway.OpenAIWS.RetryJitterRatio = 0
  812. svc := &OpenAIGatewayService{
  813. cfg: cfg,
  814. httpUpstream: upstream,
  815. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  816. toolCorrector: NewCodexToolCorrector(),
  817. }
  818. account := &Account{
  819. ID: 8901,
  820. Name: "openai-apikey",
  821. Platform: PlatformOpenAI,
  822. Type: AccountTypeAPIKey,
  823. Concurrency: 1,
  824. Credentials: map[string]any{
  825. "api_key": "sk-test",
  826. "base_url": wsServer.URL,
  827. },
  828. Extra: map[string]any{
  829. "responses_websockets_v2_enabled": true,
  830. },
  831. }
  832. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"input":[{"type":"input_text","text":"hello"}]}`)
  833. result, err := svc.Forward(context.Background(), c, account, body)
  834. require.Error(t, err)
  835. require.Nil(t, result)
  836. require.Nil(t, upstream.lastReq, "策略违规关闭后不应回退 HTTP")
  837. require.Equal(t, int32(1), wsAttempts.Load(), "策略违规不应进行 WS 重试")
  838. }
  839. func TestOpenAIGatewayService_Forward_WSv2ConnectionLimitReachedRetryThenFallbackHTTP(t *testing.T) {
  840. gin.SetMode(gin.TestMode)
  841. var wsAttempts atomic.Int32
  842. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  843. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  844. wsAttempts.Add(1)
  845. conn, err := upgrader.Upgrade(w, r, nil)
  846. if err != nil {
  847. t.Errorf("upgrade websocket failed: %v", err)
  848. return
  849. }
  850. defer func() {
  851. _ = conn.Close()
  852. }()
  853. var req map[string]any
  854. if err := conn.ReadJSON(&req); err != nil {
  855. t.Errorf("read ws request failed: %v", err)
  856. return
  857. }
  858. _ = conn.WriteJSON(map[string]any{
  859. "type": "error",
  860. "error": map[string]any{
  861. "code": "websocket_connection_limit_reached",
  862. "type": "server_error",
  863. "message": "websocket connection limit reached",
  864. },
  865. })
  866. }))
  867. defer wsServer.Close()
  868. rec := httptest.NewRecorder()
  869. c, _ := gin.CreateTestContext(rec)
  870. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  871. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  872. upstream := &httpUpstreamRecorder{
  873. resp: &http.Response{
  874. StatusCode: http.StatusOK,
  875. Header: http.Header{"Content-Type": []string{"application/json"}},
  876. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_retry_limit","usage":{"input_tokens":1,"output_tokens":1}}`)),
  877. },
  878. }
  879. cfg := &config.Config{}
  880. cfg.Security.URLAllowlist.Enabled = false
  881. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  882. cfg.Gateway.OpenAIWS.Enabled = true
  883. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  884. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  885. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  886. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  887. svc := &OpenAIGatewayService{
  888. cfg: cfg,
  889. httpUpstream: upstream,
  890. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  891. toolCorrector: NewCodexToolCorrector(),
  892. }
  893. account := &Account{
  894. ID: 90,
  895. Name: "openai-apikey",
  896. Platform: PlatformOpenAI,
  897. Type: AccountTypeAPIKey,
  898. Concurrency: 1,
  899. Credentials: map[string]any{
  900. "api_key": "sk-test",
  901. "base_url": wsServer.URL,
  902. },
  903. Extra: map[string]any{
  904. "responses_websockets_v2_enabled": true,
  905. },
  906. }
  907. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"input":[{"type":"input_text","text":"hello"}]}`)
  908. result, err := svc.Forward(context.Background(), c, account, body)
  909. require.Error(t, err)
  910. require.Nil(t, result)
  911. require.Nil(t, upstream.lastReq, "触发 websocket_connection_limit_reached 后不应回退 HTTP")
  912. require.Equal(t, int32(openAIWSReconnectRetryLimit+1), wsAttempts.Load())
  913. }
  914. func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundRecoversByDroppingPreviousResponseID(t *testing.T) {
  915. gin.SetMode(gin.TestMode)
  916. var wsAttempts atomic.Int32
  917. var wsRequestPayloads [][]byte
  918. var wsRequestMu sync.Mutex
  919. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  920. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  921. attempt := wsAttempts.Add(1)
  922. conn, err := upgrader.Upgrade(w, r, nil)
  923. if err != nil {
  924. t.Errorf("upgrade websocket failed: %v", err)
  925. return
  926. }
  927. defer func() {
  928. _ = conn.Close()
  929. }()
  930. var req map[string]any
  931. if err := conn.ReadJSON(&req); err != nil {
  932. t.Errorf("read ws request failed: %v", err)
  933. return
  934. }
  935. reqRaw, _ := json.Marshal(req)
  936. wsRequestMu.Lock()
  937. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  938. wsRequestMu.Unlock()
  939. if attempt == 1 {
  940. _ = conn.WriteJSON(map[string]any{
  941. "type": "error",
  942. "error": map[string]any{
  943. "code": "previous_response_not_found",
  944. "type": "invalid_request_error",
  945. "message": "previous response not found",
  946. },
  947. })
  948. return
  949. }
  950. _ = conn.WriteJSON(map[string]any{
  951. "type": "response.completed",
  952. "response": map[string]any{
  953. "id": "resp_ws_prev_recover_ok",
  954. "model": "gpt-5.3-codex",
  955. "usage": map[string]any{
  956. "input_tokens": 1,
  957. "output_tokens": 1,
  958. "input_tokens_details": map[string]any{
  959. "cached_tokens": 0,
  960. },
  961. },
  962. },
  963. })
  964. }))
  965. defer wsServer.Close()
  966. rec := httptest.NewRecorder()
  967. c, _ := gin.CreateTestContext(rec)
  968. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  969. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  970. upstream := &httpUpstreamRecorder{
  971. resp: &http.Response{
  972. StatusCode: http.StatusOK,
  973. Header: http.Header{"Content-Type": []string{"application/json"}},
  974. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_prev","usage":{"input_tokens":1,"output_tokens":1}}`)),
  975. },
  976. }
  977. cfg := &config.Config{}
  978. cfg.Security.URLAllowlist.Enabled = false
  979. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  980. cfg.Gateway.OpenAIWS.Enabled = true
  981. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  982. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  983. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  984. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  985. svc := &OpenAIGatewayService{
  986. cfg: cfg,
  987. httpUpstream: upstream,
  988. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  989. toolCorrector: NewCodexToolCorrector(),
  990. }
  991. account := &Account{
  992. ID: 91,
  993. Name: "openai-apikey",
  994. Platform: PlatformOpenAI,
  995. Type: AccountTypeAPIKey,
  996. Concurrency: 1,
  997. Credentials: map[string]any{
  998. "api_key": "sk-test",
  999. "base_url": wsServer.URL,
  1000. },
  1001. Extra: map[string]any{
  1002. "responses_websockets_v2_enabled": true,
  1003. },
  1004. }
  1005. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_missing","input":[{"type":"input_text","text":"hello"}]}`)
  1006. result, err := svc.Forward(context.Background(), c, account, body)
  1007. require.NoError(t, err)
  1008. require.NotNil(t, result)
  1009. require.Equal(t, "resp_ws_prev_recover_ok", result.RequestID)
  1010. require.Nil(t, upstream.lastReq, "previous_response_not_found 不应回退 HTTP")
  1011. require.Equal(t, int32(2), wsAttempts.Load(), "previous_response_not_found 应触发一次去掉 previous_response_id 的恢复重试")
  1012. require.Equal(t, http.StatusOK, rec.Code)
  1013. require.Equal(t, "resp_ws_prev_recover_ok", gjson.Get(rec.Body.String(), "id").String())
  1014. wsRequestMu.Lock()
  1015. requests := append([][]byte(nil), wsRequestPayloads...)
  1016. wsRequestMu.Unlock()
  1017. require.Len(t, requests, 2)
  1018. require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id")
  1019. require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id")
  1020. }
  1021. func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryForFunctionCallOutput(t *testing.T) {
  1022. gin.SetMode(gin.TestMode)
  1023. var wsAttempts atomic.Int32
  1024. var wsRequestPayloads [][]byte
  1025. var wsRequestMu sync.Mutex
  1026. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1027. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1028. wsAttempts.Add(1)
  1029. conn, err := upgrader.Upgrade(w, r, nil)
  1030. if err != nil {
  1031. t.Errorf("upgrade websocket failed: %v", err)
  1032. return
  1033. }
  1034. defer func() {
  1035. _ = conn.Close()
  1036. }()
  1037. var req map[string]any
  1038. if err := conn.ReadJSON(&req); err != nil {
  1039. t.Errorf("read ws request failed: %v", err)
  1040. return
  1041. }
  1042. reqRaw, _ := json.Marshal(req)
  1043. wsRequestMu.Lock()
  1044. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1045. wsRequestMu.Unlock()
  1046. _ = conn.WriteJSON(map[string]any{
  1047. "type": "error",
  1048. "error": map[string]any{
  1049. "code": "previous_response_not_found",
  1050. "type": "invalid_request_error",
  1051. "message": "previous response not found",
  1052. },
  1053. })
  1054. }))
  1055. defer wsServer.Close()
  1056. rec := httptest.NewRecorder()
  1057. c, _ := gin.CreateTestContext(rec)
  1058. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1059. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1060. upstream := &httpUpstreamRecorder{
  1061. resp: &http.Response{
  1062. StatusCode: http.StatusOK,
  1063. Header: http.Header{"Content-Type": []string{"application/json"}},
  1064. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_prev","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1065. },
  1066. }
  1067. cfg := &config.Config{}
  1068. cfg.Security.URLAllowlist.Enabled = false
  1069. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1070. cfg.Gateway.OpenAIWS.Enabled = true
  1071. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1072. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1073. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1074. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1075. svc := &OpenAIGatewayService{
  1076. cfg: cfg,
  1077. httpUpstream: upstream,
  1078. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1079. toolCorrector: NewCodexToolCorrector(),
  1080. }
  1081. account := &Account{
  1082. ID: 92,
  1083. Name: "openai-apikey",
  1084. Platform: PlatformOpenAI,
  1085. Type: AccountTypeAPIKey,
  1086. Concurrency: 1,
  1087. Credentials: map[string]any{
  1088. "api_key": "sk-test",
  1089. "base_url": wsServer.URL,
  1090. },
  1091. Extra: map[string]any{
  1092. "responses_websockets_v2_enabled": true,
  1093. },
  1094. }
  1095. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_missing","input":[{"type":"function_call_output","call_id":"call_1","output":"ok"}]}`)
  1096. result, err := svc.Forward(context.Background(), c, account, body)
  1097. require.Error(t, err)
  1098. require.Nil(t, result)
  1099. require.Nil(t, upstream.lastReq, "previous_response_not_found 不应回退 HTTP")
  1100. require.Equal(t, int32(1), wsAttempts.Load(), "function_call_output 场景应跳过 previous_response_not_found 自动恢复")
  1101. require.Equal(t, http.StatusBadRequest, rec.Code)
  1102. require.Contains(t, strings.ToLower(rec.Body.String()), "previous response not found")
  1103. wsRequestMu.Lock()
  1104. requests := append([][]byte(nil), wsRequestPayloads...)
  1105. wsRequestMu.Unlock()
  1106. require.Len(t, requests, 1)
  1107. require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists())
  1108. }
  1109. func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryWithoutPreviousResponseID(t *testing.T) {
  1110. gin.SetMode(gin.TestMode)
  1111. var wsAttempts atomic.Int32
  1112. var wsRequestPayloads [][]byte
  1113. var wsRequestMu sync.Mutex
  1114. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1115. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1116. wsAttempts.Add(1)
  1117. conn, err := upgrader.Upgrade(w, r, nil)
  1118. if err != nil {
  1119. t.Errorf("upgrade websocket failed: %v", err)
  1120. return
  1121. }
  1122. defer func() {
  1123. _ = conn.Close()
  1124. }()
  1125. var req map[string]any
  1126. if err := conn.ReadJSON(&req); err != nil {
  1127. t.Errorf("read ws request failed: %v", err)
  1128. return
  1129. }
  1130. reqRaw, _ := json.Marshal(req)
  1131. wsRequestMu.Lock()
  1132. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1133. wsRequestMu.Unlock()
  1134. _ = conn.WriteJSON(map[string]any{
  1135. "type": "error",
  1136. "error": map[string]any{
  1137. "code": "previous_response_not_found",
  1138. "type": "invalid_request_error",
  1139. "message": "previous response not found",
  1140. },
  1141. })
  1142. }))
  1143. defer wsServer.Close()
  1144. rec := httptest.NewRecorder()
  1145. c, _ := gin.CreateTestContext(rec)
  1146. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1147. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1148. upstream := &httpUpstreamRecorder{
  1149. resp: &http.Response{
  1150. StatusCode: http.StatusOK,
  1151. Header: http.Header{"Content-Type": []string{"application/json"}},
  1152. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_prev","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1153. },
  1154. }
  1155. cfg := &config.Config{}
  1156. cfg.Security.URLAllowlist.Enabled = false
  1157. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1158. cfg.Gateway.OpenAIWS.Enabled = true
  1159. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1160. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1161. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1162. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1163. svc := &OpenAIGatewayService{
  1164. cfg: cfg,
  1165. httpUpstream: upstream,
  1166. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1167. toolCorrector: NewCodexToolCorrector(),
  1168. }
  1169. account := &Account{
  1170. ID: 93,
  1171. Name: "openai-apikey",
  1172. Platform: PlatformOpenAI,
  1173. Type: AccountTypeAPIKey,
  1174. Concurrency: 1,
  1175. Credentials: map[string]any{
  1176. "api_key": "sk-test",
  1177. "base_url": wsServer.URL,
  1178. },
  1179. Extra: map[string]any{
  1180. "responses_websockets_v2_enabled": true,
  1181. },
  1182. }
  1183. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"input":[{"type":"input_text","text":"hello"}]}`)
  1184. result, err := svc.Forward(context.Background(), c, account, body)
  1185. require.Error(t, err)
  1186. require.Nil(t, result)
  1187. require.Nil(t, upstream.lastReq, "WS 模式下 previous_response_not_found 不应回退 HTTP")
  1188. require.Equal(t, int32(1), wsAttempts.Load(), "缺少 previous_response_id 时应跳过自动恢复重试")
  1189. require.Equal(t, http.StatusBadRequest, rec.Code)
  1190. wsRequestMu.Lock()
  1191. requests := append([][]byte(nil), wsRequestPayloads...)
  1192. wsRequestMu.Unlock()
  1193. require.Len(t, requests, 1)
  1194. require.False(t, gjson.GetBytes(requests[0], "previous_response_id").Exists())
  1195. }
  1196. func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundOnlyRecoversOnce(t *testing.T) {
  1197. gin.SetMode(gin.TestMode)
  1198. var wsAttempts atomic.Int32
  1199. var wsRequestPayloads [][]byte
  1200. var wsRequestMu sync.Mutex
  1201. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1202. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1203. wsAttempts.Add(1)
  1204. conn, err := upgrader.Upgrade(w, r, nil)
  1205. if err != nil {
  1206. t.Errorf("upgrade websocket failed: %v", err)
  1207. return
  1208. }
  1209. defer func() {
  1210. _ = conn.Close()
  1211. }()
  1212. var req map[string]any
  1213. if err := conn.ReadJSON(&req); err != nil {
  1214. t.Errorf("read ws request failed: %v", err)
  1215. return
  1216. }
  1217. reqRaw, _ := json.Marshal(req)
  1218. wsRequestMu.Lock()
  1219. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1220. wsRequestMu.Unlock()
  1221. _ = conn.WriteJSON(map[string]any{
  1222. "type": "error",
  1223. "error": map[string]any{
  1224. "code": "previous_response_not_found",
  1225. "type": "invalid_request_error",
  1226. "message": "previous response not found",
  1227. },
  1228. })
  1229. }))
  1230. defer wsServer.Close()
  1231. rec := httptest.NewRecorder()
  1232. c, _ := gin.CreateTestContext(rec)
  1233. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1234. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1235. upstream := &httpUpstreamRecorder{
  1236. resp: &http.Response{
  1237. StatusCode: http.StatusOK,
  1238. Header: http.Header{"Content-Type": []string{"application/json"}},
  1239. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_prev","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1240. },
  1241. }
  1242. cfg := &config.Config{}
  1243. cfg.Security.URLAllowlist.Enabled = false
  1244. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1245. cfg.Gateway.OpenAIWS.Enabled = true
  1246. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1247. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1248. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1249. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1250. svc := &OpenAIGatewayService{
  1251. cfg: cfg,
  1252. httpUpstream: upstream,
  1253. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1254. toolCorrector: NewCodexToolCorrector(),
  1255. }
  1256. account := &Account{
  1257. ID: 94,
  1258. Name: "openai-apikey",
  1259. Platform: PlatformOpenAI,
  1260. Type: AccountTypeAPIKey,
  1261. Concurrency: 1,
  1262. Credentials: map[string]any{
  1263. "api_key": "sk-test",
  1264. "base_url": wsServer.URL,
  1265. },
  1266. Extra: map[string]any{
  1267. "responses_websockets_v2_enabled": true,
  1268. },
  1269. }
  1270. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_missing","input":[{"type":"input_text","text":"hello"}]}`)
  1271. result, err := svc.Forward(context.Background(), c, account, body)
  1272. require.Error(t, err)
  1273. require.Nil(t, result)
  1274. require.Nil(t, upstream.lastReq, "WS 模式下 previous_response_not_found 不应回退 HTTP")
  1275. require.Equal(t, int32(2), wsAttempts.Load(), "应只允许一次自动恢复重试")
  1276. require.Equal(t, http.StatusBadRequest, rec.Code)
  1277. wsRequestMu.Lock()
  1278. requests := append([][]byte(nil), wsRequestPayloads...)
  1279. wsRequestMu.Unlock()
  1280. require.Len(t, requests, 2)
  1281. require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应包含 previous_response_id")
  1282. require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id")
  1283. }
  1284. func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t *testing.T) {
  1285. gin.SetMode(gin.TestMode)
  1286. var wsAttempts atomic.Int32
  1287. var wsRequestPayloads [][]byte
  1288. var wsRequestMu sync.Mutex
  1289. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1290. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1291. attempt := wsAttempts.Add(1)
  1292. conn, err := upgrader.Upgrade(w, r, nil)
  1293. if err != nil {
  1294. t.Errorf("upgrade websocket failed: %v", err)
  1295. return
  1296. }
  1297. defer func() {
  1298. _ = conn.Close()
  1299. }()
  1300. var req map[string]any
  1301. if err := conn.ReadJSON(&req); err != nil {
  1302. t.Errorf("read ws request failed: %v", err)
  1303. return
  1304. }
  1305. reqRaw, _ := json.Marshal(req)
  1306. wsRequestMu.Lock()
  1307. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1308. wsRequestMu.Unlock()
  1309. if attempt == 1 {
  1310. _ = conn.WriteJSON(map[string]any{
  1311. "type": "error",
  1312. "error": map[string]any{
  1313. "code": "invalid_encrypted_content",
  1314. "type": "invalid_request_error",
  1315. "message": "The encrypted content could not be verified.",
  1316. },
  1317. })
  1318. return
  1319. }
  1320. _ = conn.WriteJSON(map[string]any{
  1321. "type": "response.completed",
  1322. "response": map[string]any{
  1323. "id": "resp_ws_invalid_encrypted_content_recover_ok",
  1324. "model": "gpt-5.3-codex",
  1325. "usage": map[string]any{
  1326. "input_tokens": 1,
  1327. "output_tokens": 1,
  1328. "input_tokens_details": map[string]any{
  1329. "cached_tokens": 0,
  1330. },
  1331. },
  1332. },
  1333. })
  1334. }))
  1335. defer wsServer.Close()
  1336. rec := httptest.NewRecorder()
  1337. c, _ := gin.CreateTestContext(rec)
  1338. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1339. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1340. upstream := &httpUpstreamRecorder{
  1341. resp: &http.Response{
  1342. StatusCode: http.StatusOK,
  1343. Header: http.Header{"Content-Type": []string{"application/json"}},
  1344. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_reasoning","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1345. },
  1346. }
  1347. cfg := &config.Config{}
  1348. cfg.Security.URLAllowlist.Enabled = false
  1349. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1350. cfg.Gateway.OpenAIWS.Enabled = true
  1351. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1352. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1353. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1354. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1355. svc := &OpenAIGatewayService{
  1356. cfg: cfg,
  1357. httpUpstream: upstream,
  1358. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1359. toolCorrector: NewCodexToolCorrector(),
  1360. }
  1361. account := &Account{
  1362. ID: 95,
  1363. Name: "openai-apikey",
  1364. Platform: PlatformOpenAI,
  1365. Type: AccountTypeAPIKey,
  1366. Concurrency: 1,
  1367. Credentials: map[string]any{
  1368. "api_key": "sk-test",
  1369. "base_url": wsServer.URL,
  1370. },
  1371. Extra: map[string]any{
  1372. "responses_websockets_v2_enabled": true,
  1373. },
  1374. }
  1375. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"compaction","encrypted_content":"cAAA"},{"type":"input_text","text":"hello"}]}`)
  1376. result, err := svc.Forward(context.Background(), c, account, body)
  1377. require.NoError(t, err)
  1378. require.NotNil(t, result)
  1379. require.Equal(t, "resp_ws_invalid_encrypted_content_recover_ok", result.RequestID)
  1380. require.Nil(t, upstream.lastReq, "invalid_encrypted_content 不应回退 HTTP")
  1381. require.Equal(t, int32(2), wsAttempts.Load(), "invalid_encrypted_content 应触发一次清洗后重试")
  1382. require.Equal(t, http.StatusOK, rec.Code)
  1383. require.Equal(t, "resp_ws_invalid_encrypted_content_recover_ok", gjson.Get(rec.Body.String(), "id").String())
  1384. wsRequestMu.Lock()
  1385. requests := append([][]byte(nil), wsRequestPayloads...)
  1386. wsRequestMu.Unlock()
  1387. require.Len(t, requests, 2)
  1388. require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id")
  1389. require.True(t, gjson.GetBytes(requests[0], `input.0.encrypted_content`).Exists(), "首轮请求应保留 encrypted reasoning")
  1390. require.True(t, gjson.GetBytes(requests[0], `input.1.encrypted_content`).Exists(), "首轮请求应保留 encrypted compaction")
  1391. require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id")
  1392. require.False(t, gjson.GetBytes(requests[1], `input.0.encrypted_content`).Exists(), "恢复重试应移除 encrypted reasoning item")
  1393. require.Equal(t, "input_text", gjson.GetBytes(requests[1], `input.0.type`).String())
  1394. }
  1395. func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentSkipsRecoveryWithoutReasoningItem(t *testing.T) {
  1396. gin.SetMode(gin.TestMode)
  1397. var wsAttempts atomic.Int32
  1398. var wsRequestPayloads [][]byte
  1399. var wsRequestMu sync.Mutex
  1400. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1401. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1402. wsAttempts.Add(1)
  1403. conn, err := upgrader.Upgrade(w, r, nil)
  1404. if err != nil {
  1405. t.Errorf("upgrade websocket failed: %v", err)
  1406. return
  1407. }
  1408. defer func() {
  1409. _ = conn.Close()
  1410. }()
  1411. var req map[string]any
  1412. if err := conn.ReadJSON(&req); err != nil {
  1413. t.Errorf("read ws request failed: %v", err)
  1414. return
  1415. }
  1416. reqRaw, _ := json.Marshal(req)
  1417. wsRequestMu.Lock()
  1418. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1419. wsRequestMu.Unlock()
  1420. _ = conn.WriteJSON(map[string]any{
  1421. "type": "error",
  1422. "error": map[string]any{
  1423. "code": "invalid_encrypted_content",
  1424. "type": "invalid_request_error",
  1425. "message": "The encrypted content could not be verified.",
  1426. },
  1427. })
  1428. }))
  1429. defer wsServer.Close()
  1430. rec := httptest.NewRecorder()
  1431. c, _ := gin.CreateTestContext(rec)
  1432. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1433. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1434. upstream := &httpUpstreamRecorder{
  1435. resp: &http.Response{
  1436. StatusCode: http.StatusOK,
  1437. Header: http.Header{"Content-Type": []string{"application/json"}},
  1438. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_reasoning","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1439. },
  1440. }
  1441. cfg := &config.Config{}
  1442. cfg.Security.URLAllowlist.Enabled = false
  1443. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1444. cfg.Gateway.OpenAIWS.Enabled = true
  1445. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1446. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1447. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1448. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1449. svc := &OpenAIGatewayService{
  1450. cfg: cfg,
  1451. httpUpstream: upstream,
  1452. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1453. toolCorrector: NewCodexToolCorrector(),
  1454. }
  1455. account := &Account{
  1456. ID: 96,
  1457. Name: "openai-apikey",
  1458. Platform: PlatformOpenAI,
  1459. Type: AccountTypeAPIKey,
  1460. Concurrency: 1,
  1461. Credentials: map[string]any{
  1462. "api_key": "sk-test",
  1463. "base_url": wsServer.URL,
  1464. },
  1465. Extra: map[string]any{
  1466. "responses_websockets_v2_enabled": true,
  1467. },
  1468. }
  1469. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"input_text","text":"hello"}]}`)
  1470. result, err := svc.Forward(context.Background(), c, account, body)
  1471. require.Error(t, err)
  1472. require.Nil(t, result)
  1473. require.Nil(t, upstream.lastReq, "invalid_encrypted_content 不应回退 HTTP")
  1474. require.Equal(t, int32(1), wsAttempts.Load(), "缺少 reasoning encrypted item 时应跳过自动恢复重试")
  1475. require.Equal(t, http.StatusBadRequest, rec.Code)
  1476. require.Contains(t, strings.ToLower(rec.Body.String()), "encrypted content")
  1477. wsRequestMu.Lock()
  1478. requests := append([][]byte(nil), wsRequestPayloads...)
  1479. wsRequestMu.Unlock()
  1480. require.Len(t, requests, 1)
  1481. require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists())
  1482. require.False(t, gjson.GetBytes(requests[0], `input.0.encrypted_content`).Exists())
  1483. }
  1484. func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversSingleObjectInputAndKeepsSummary(t *testing.T) {
  1485. gin.SetMode(gin.TestMode)
  1486. var wsAttempts atomic.Int32
  1487. var wsRequestPayloads [][]byte
  1488. var wsRequestMu sync.Mutex
  1489. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1490. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1491. attempt := wsAttempts.Add(1)
  1492. conn, err := upgrader.Upgrade(w, r, nil)
  1493. if err != nil {
  1494. t.Errorf("upgrade websocket failed: %v", err)
  1495. return
  1496. }
  1497. defer func() {
  1498. _ = conn.Close()
  1499. }()
  1500. var req map[string]any
  1501. if err := conn.ReadJSON(&req); err != nil {
  1502. t.Errorf("read ws request failed: %v", err)
  1503. return
  1504. }
  1505. reqRaw, _ := json.Marshal(req)
  1506. wsRequestMu.Lock()
  1507. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1508. wsRequestMu.Unlock()
  1509. if attempt == 1 {
  1510. _ = conn.WriteJSON(map[string]any{
  1511. "type": "error",
  1512. "error": map[string]any{
  1513. "code": "invalid_encrypted_content",
  1514. "type": "invalid_request_error",
  1515. "message": "The encrypted content could not be verified.",
  1516. },
  1517. })
  1518. return
  1519. }
  1520. _ = conn.WriteJSON(map[string]any{
  1521. "type": "response.completed",
  1522. "response": map[string]any{
  1523. "id": "resp_ws_invalid_encrypted_content_object_ok",
  1524. "model": "gpt-5.3-codex",
  1525. "usage": map[string]any{
  1526. "input_tokens": 1,
  1527. "output_tokens": 1,
  1528. "input_tokens_details": map[string]any{
  1529. "cached_tokens": 0,
  1530. },
  1531. },
  1532. },
  1533. })
  1534. }))
  1535. defer wsServer.Close()
  1536. rec := httptest.NewRecorder()
  1537. c, _ := gin.CreateTestContext(rec)
  1538. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1539. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1540. upstream := &httpUpstreamRecorder{
  1541. resp: &http.Response{
  1542. StatusCode: http.StatusOK,
  1543. Header: http.Header{"Content-Type": []string{"application/json"}},
  1544. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_reasoning","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1545. },
  1546. }
  1547. cfg := &config.Config{}
  1548. cfg.Security.URLAllowlist.Enabled = false
  1549. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1550. cfg.Gateway.OpenAIWS.Enabled = true
  1551. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1552. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1553. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1554. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1555. svc := &OpenAIGatewayService{
  1556. cfg: cfg,
  1557. httpUpstream: upstream,
  1558. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1559. toolCorrector: NewCodexToolCorrector(),
  1560. }
  1561. account := &Account{
  1562. ID: 97,
  1563. Name: "openai-apikey",
  1564. Platform: PlatformOpenAI,
  1565. Type: AccountTypeAPIKey,
  1566. Concurrency: 1,
  1567. Credentials: map[string]any{
  1568. "api_key": "sk-test",
  1569. "base_url": wsServer.URL,
  1570. },
  1571. Extra: map[string]any{
  1572. "responses_websockets_v2_enabled": true,
  1573. },
  1574. }
  1575. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":{"type":"reasoning","encrypted_content":"gAAA","summary":[{"type":"summary_text","text":"keep me"}]}}`)
  1576. result, err := svc.Forward(context.Background(), c, account, body)
  1577. require.NoError(t, err)
  1578. require.NotNil(t, result)
  1579. require.Equal(t, "resp_ws_invalid_encrypted_content_object_ok", result.RequestID)
  1580. require.Nil(t, upstream.lastReq, "invalid_encrypted_content 单对象 input 不应回退 HTTP")
  1581. require.Equal(t, int32(2), wsAttempts.Load(), "单对象 reasoning input 也应触发一次清洗后重试")
  1582. wsRequestMu.Lock()
  1583. requests := append([][]byte(nil), wsRequestPayloads...)
  1584. wsRequestMu.Unlock()
  1585. require.Len(t, requests, 2)
  1586. require.True(t, gjson.GetBytes(requests[0], `input.encrypted_content`).Exists(), "首轮单对象应保留 encrypted_content")
  1587. require.True(t, gjson.GetBytes(requests[1], `input.summary.0.text`).Exists(), "恢复重试应保留 reasoning summary")
  1588. require.False(t, gjson.GetBytes(requests[1], `input.encrypted_content`).Exists(), "恢复重试只应移除 encrypted_content")
  1589. require.Equal(t, "reasoning", gjson.GetBytes(requests[1], `input.type`).String())
  1590. require.False(t, gjson.GetBytes(requests[1], `previous_response_id`).Exists(), "恢复重试应移除 previous_response_id")
  1591. }
  1592. func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentKeepsPreviousResponseIDForFunctionCallOutput(t *testing.T) {
  1593. gin.SetMode(gin.TestMode)
  1594. var wsAttempts atomic.Int32
  1595. var wsRequestPayloads [][]byte
  1596. var wsRequestMu sync.Mutex
  1597. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
  1598. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1599. attempt := wsAttempts.Add(1)
  1600. conn, err := upgrader.Upgrade(w, r, nil)
  1601. if err != nil {
  1602. t.Errorf("upgrade websocket failed: %v", err)
  1603. return
  1604. }
  1605. defer func() {
  1606. _ = conn.Close()
  1607. }()
  1608. var req map[string]any
  1609. if err := conn.ReadJSON(&req); err != nil {
  1610. t.Errorf("read ws request failed: %v", err)
  1611. return
  1612. }
  1613. reqRaw, _ := json.Marshal(req)
  1614. wsRequestMu.Lock()
  1615. wsRequestPayloads = append(wsRequestPayloads, reqRaw)
  1616. wsRequestMu.Unlock()
  1617. if attempt == 1 {
  1618. _ = conn.WriteJSON(map[string]any{
  1619. "type": "error",
  1620. "error": map[string]any{
  1621. "code": "invalid_encrypted_content",
  1622. "type": "invalid_request_error",
  1623. "message": "The encrypted content could not be verified.",
  1624. },
  1625. })
  1626. return
  1627. }
  1628. _ = conn.WriteJSON(map[string]any{
  1629. "type": "response.completed",
  1630. "response": map[string]any{
  1631. "id": "resp_ws_invalid_encrypted_content_function_call_output_ok",
  1632. "model": "gpt-5.3-codex",
  1633. "usage": map[string]any{
  1634. "input_tokens": 1,
  1635. "output_tokens": 1,
  1636. "input_tokens_details": map[string]any{
  1637. "cached_tokens": 0,
  1638. },
  1639. },
  1640. },
  1641. })
  1642. }))
  1643. defer wsServer.Close()
  1644. rec := httptest.NewRecorder()
  1645. c, _ := gin.CreateTestContext(rec)
  1646. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  1647. c.Request.Header.Set("User-Agent", "custom-client/1.0")
  1648. upstream := &httpUpstreamRecorder{
  1649. resp: &http.Response{
  1650. StatusCode: http.StatusOK,
  1651. Header: http.Header{"Content-Type": []string{"application/json"}},
  1652. Body: io.NopCloser(strings.NewReader(`{"id":"resp_http_drop_reasoning","usage":{"input_tokens":1,"output_tokens":1}}`)),
  1653. },
  1654. }
  1655. cfg := &config.Config{}
  1656. cfg.Security.URLAllowlist.Enabled = false
  1657. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  1658. cfg.Gateway.OpenAIWS.Enabled = true
  1659. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  1660. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  1661. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  1662. cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  1663. svc := &OpenAIGatewayService{
  1664. cfg: cfg,
  1665. httpUpstream: upstream,
  1666. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  1667. toolCorrector: NewCodexToolCorrector(),
  1668. }
  1669. account := &Account{
  1670. ID: 98,
  1671. Name: "openai-apikey",
  1672. Platform: PlatformOpenAI,
  1673. Type: AccountTypeAPIKey,
  1674. Concurrency: 1,
  1675. Credentials: map[string]any{
  1676. "api_key": "sk-test",
  1677. "base_url": wsServer.URL,
  1678. },
  1679. Extra: map[string]any{
  1680. "responses_websockets_v2_enabled": true,
  1681. },
  1682. }
  1683. body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_function_call","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"function_call_output","call_id":"call_123","output":"ok"}]}`)
  1684. result, err := svc.Forward(context.Background(), c, account, body)
  1685. require.NoError(t, err)
  1686. require.NotNil(t, result)
  1687. require.Equal(t, "resp_ws_invalid_encrypted_content_function_call_output_ok", result.RequestID)
  1688. require.Nil(t, upstream.lastReq, "function_call_output + invalid_encrypted_content 不应回退 HTTP")
  1689. require.Equal(t, int32(2), wsAttempts.Load(), "应只做一次保锚点的清洗后重试")
  1690. wsRequestMu.Lock()
  1691. requests := append([][]byte(nil), wsRequestPayloads...)
  1692. wsRequestMu.Unlock()
  1693. require.Len(t, requests, 2)
  1694. require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id")
  1695. require.True(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "function_call_output 恢复重试不应移除 previous_response_id")
  1696. require.False(t, gjson.GetBytes(requests[1], `input.0.encrypted_content`).Exists(), "恢复重试应移除 reasoning encrypted_content")
  1697. require.Equal(t, "function_call_output", gjson.GetBytes(requests[1], `input.0.type`).String(), "清洗后应保留 function_call_output 作为首个输入项")
  1698. require.Equal(t, "call_123", gjson.GetBytes(requests[1], `input.0.call_id`).String())
  1699. require.Equal(t, "ok", gjson.GetBytes(requests[1], `input.0.output`).String())
  1700. require.Equal(t, "resp_prev_function_call", gjson.GetBytes(requests[1], "previous_response_id").String())
  1701. }