openai_compact_fallback_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strconv"
  10. "strings"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. func newOpenAICompactFallbackTestContext(t *testing.T, path string) *gin.Context {
  19. t.Helper()
  20. rec := httptest.NewRecorder()
  21. c, _ := gin.CreateTestContext(rec)
  22. c.Request = httptest.NewRequest(http.MethodPost, path, nil)
  23. return c
  24. }
  25. func TestPrepareOpenAICompactFallbackRetryRequiresExplicitCompact(t *testing.T) {
  26. gin.SetMode(gin.TestMode)
  27. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
  28. c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
  29. body := []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":"hello"}]}`)
  30. errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"maximum context length exceeded"}}`)
  31. retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
  32. c, nil, "gpt-5.5", body, http.StatusBadRequest, "maximum context length exceeded", errorBody, false,
  33. )
  34. require.False(t, retry)
  35. require.Empty(t, fallbackModel)
  36. require.Equal(t, body, retryBody)
  37. }
  38. func TestPrepareOpenAICompactFallbackRetryPreservesNativeTriggerAndContext(t *testing.T) {
  39. gin.SetMode(gin.TestMode)
  40. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
  41. c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
  42. MarkOpenAINativeCompactionV2(c)
  43. body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  44. errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)
  45. pathBefore := openAIResponsesRequestPathSuffix(c)
  46. retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
  47. c, nil, "gpt-5.5", body, http.StatusBadRequest, "context window exceeded", errorBody, false,
  48. )
  49. require.True(t, retry)
  50. require.Equal(t, "gpt-5.4", fallbackModel)
  51. require.Equal(t, "gpt-5.4", gjson.GetBytes(retryBody, "model").String())
  52. require.True(t, HasCompactionTriggerInInput(retryBody))
  53. require.True(t, isOpenAINativeCompactionV2(c))
  54. require.Equal(t, pathBefore, openAIResponsesRequestPathSuffix(c))
  55. }
  56. func TestResolveOpenAICompactFallbackModelPrefersAccountMapping(t *testing.T) {
  57. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}}}
  58. account := &Account{Credentials: map[string]any{
  59. "compact_model_mapping": map[string]any{"gpt-5.5": "account-compact"},
  60. }}
  61. require.Equal(t, "account-compact", svc.resolveOpenAICompactFallbackModel(account, "gpt-5.5"))
  62. require.Equal(t, "global-compact", svc.resolveOpenAICompactFallbackModel(account, "unmapped-model"))
  63. }
  64. func TestOpenAIGatewayForwardUsesGlobalCompactModelOnInitialLegacyRequest(t *testing.T) {
  65. gin.SetMode(gin.TestMode)
  66. body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[]}`)
  67. c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
  68. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  69. c.Request.Header.Set("Content-Type", "application/json")
  70. upstream := &httpUpstreamRecorder{resp: &http.Response{
  71. StatusCode: http.StatusOK,
  72. Header: http.Header{"Content-Type": []string{"application/json"}},
  73. Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"global-compact","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
  74. }}
  75. svc := &OpenAIGatewayService{
  76. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}},
  77. httpUpstream: upstream,
  78. }
  79. account := &Account{
  80. ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
  81. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  82. Status: StatusActive, Schedulable: true,
  83. }
  84. result, err := svc.Forward(context.Background(), c, account, body)
  85. require.NoError(t, err)
  86. require.NotNil(t, result)
  87. require.Len(t, upstream.bodies, 1)
  88. require.Equal(t, "global-compact", gjson.GetBytes(upstream.bodies[0], "model").String())
  89. require.Contains(t, upstream.requests[0].URL.Path, "/compact")
  90. }
  91. func TestPrepareOpenAICompactFallbackRetryLegacyPathAndSingleAttemptGuard(t *testing.T) {
  92. gin.SetMode(gin.TestMode)
  93. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
  94. c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
  95. body := []byte(`{"model":"gpt-5.5","input":[]}`)
  96. errorBody := []byte(`{"response":{"status":"failed","error":null}}`)
  97. retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
  98. c, nil, "gpt-5.5", body, http.StatusBadRequest, "", errorBody, false,
  99. )
  100. require.True(t, retry)
  101. require.Equal(t, "gpt-5.4", fallbackModel)
  102. require.Equal(t, "/compact", openAIResponsesRequestPathSuffix(c))
  103. secondBody, secondModel, secondRetry := svc.prepareOpenAICompactFallbackRetry(
  104. c, nil, "gpt-5.5", retryBody, http.StatusBadRequest, "", errorBody, true,
  105. )
  106. require.False(t, secondRetry)
  107. require.Empty(t, secondModel)
  108. require.Equal(t, retryBody, secondBody)
  109. }
  110. func TestPrepareOpenAICompactFallbackRetryDoesNotHideSpecificBusinessFailure(t *testing.T) {
  111. gin.SetMode(gin.TestMode)
  112. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
  113. c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
  114. body := []byte(`{"model":"gpt-5.5","input":[]}`)
  115. errorBody := []byte(`{"response":{"status":"failed","error":{"type":"permission_error","message":"workspace denied"}}}`)
  116. retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
  117. c, nil, "gpt-5.5", body, http.StatusBadRequest, "workspace denied", errorBody, false,
  118. )
  119. require.False(t, retry)
  120. require.Empty(t, fallbackModel)
  121. require.Equal(t, body, retryBody)
  122. }
  123. func TestIsOpenAICompactModelFailureRequiresExplicitModelAvailabilityMessage(t *testing.T) {
  124. tests := []struct {
  125. name string
  126. message string
  127. want bool
  128. }{
  129. {name: "explicit unsupported model", message: "The requested model is not supported", want: true},
  130. {name: "named missing model", message: "The model `gpt-5.5` does not exist", want: true},
  131. {name: "unsupported model code-like message", message: "unsupported model: gpt-5.5", want: true},
  132. {name: "unsupported model feature", message: "This model output format is not supported", want: false},
  133. {name: "unsupported parameter for model", message: "Parameter tools is not supported for this model", want: false},
  134. }
  135. for _, tt := range tests {
  136. t.Run(tt.name, func(t *testing.T) {
  137. require.Equal(t, tt.want, isOpenAICompactModelFailure(
  138. http.StatusBadRequest,
  139. tt.message,
  140. []byte(`{"error":{"message":`+strconv.Quote(tt.message)+`}}`),
  141. ))
  142. })
  143. }
  144. }
  145. func TestPrepareOpenAICompactFallbackRetrySkipsSameModel(t *testing.T) {
  146. gin.SetMode(gin.TestMode)
  147. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.5"}}}
  148. c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
  149. body := []byte(`{"model":"gpt-5.5","input":[]}`)
  150. errorBody := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
  151. _, _, retry := svc.prepareOpenAICompactFallbackRetry(
  152. c, nil, "gpt-5.5", body, http.StatusNotFound, "model not found", errorBody, false,
  153. )
  154. require.False(t, retry)
  155. }
  156. func TestOpenAIGatewayForwardRetriesExplicitNativeCompactHTTPFailureOnce(t *testing.T) {
  157. gin.SetMode(gin.TestMode)
  158. body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  159. c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
  160. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  161. c.Request.Header.Set("Content-Type", "application/json")
  162. MarkOpenAINativeCompactionV2(c)
  163. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  164. {
  165. StatusCode: http.StatusBadRequest,
  166. Header: http.Header{"Content-Type": []string{"application/json"}},
  167. Body: io.NopCloser(strings.NewReader(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)),
  168. },
  169. {
  170. StatusCode: http.StatusOK,
  171. Header: http.Header{"Content-Type": []string{"application/json"}},
  172. Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
  173. },
  174. }}
  175. svc := &OpenAIGatewayService{
  176. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  177. httpUpstream: upstream,
  178. }
  179. account := &Account{
  180. ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
  181. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  182. Status: StatusActive, Schedulable: true,
  183. }
  184. result, err := svc.Forward(context.Background(), c, account, body)
  185. require.NoError(t, err)
  186. require.NotNil(t, result)
  187. require.Len(t, upstream.bodies, 2)
  188. require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
  189. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
  190. require.True(t, HasCompactionTriggerInInput(upstream.bodies[1]))
  191. require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
  192. require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
  193. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  194. require.True(t, ok)
  195. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  196. require.True(t, ok)
  197. require.Len(t, events, 1)
  198. require.Equal(t, "retry", events[0].Kind)
  199. require.Equal(t, "compact_model_fallback", events[0].Reason)
  200. require.Equal(t, http.StatusBadRequest, events[0].UpstreamStatusCode)
  201. require.Nil(t, events[0].ProxyID)
  202. require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
  203. }
  204. func compactFallbackManagedProxyAccount() (*Account, *Proxy) {
  205. proxy := &Proxy{ID: 10060, Name: "wldsg82-ipv6-10060", Protocol: "http", Host: "proxy.example", Port: 8080}
  206. return &Account{
  207. ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
  208. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  209. Status: StatusActive, Schedulable: true,
  210. ProxyID: &proxy.ID, Proxy: proxy,
  211. }, proxy
  212. }
  213. func requireCompactEventsAttributedTo(t *testing.T, events []*OpsUpstreamErrorEvent, proxy *Proxy) {
  214. t.Helper()
  215. for i, ev := range events {
  216. require.NotNil(t, ev.ProxyID, "event %d proxy_id", i)
  217. require.Equal(t, proxy.ID, *ev.ProxyID, "event %d proxy_id", i)
  218. require.Equal(t, proxy.Name, ev.ProxyName, "event %d proxy_name", i)
  219. }
  220. }
  221. // Non-streaming SSE compact retry: the first attempt's failure must be recorded
  222. // as its own attempt (it previously vanished on `continue`), carrying the
  223. // managed proxy the transport actually used.
  224. func TestOpenAIGatewayForwardNonStreamCompactRetryRecordsAttemptWithManagedProxy(t *testing.T) {
  225. gin.SetMode(gin.TestMode)
  226. body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  227. c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
  228. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  229. c.Request.Header.Set("Content-Type", "application/json")
  230. MarkOpenAINativeCompactionV2(c)
  231. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  232. {
  233. StatusCode: http.StatusOK,
  234. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_compact_1"}},
  235. Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
  236. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
  237. },
  238. {
  239. StatusCode: http.StatusOK,
  240. Header: http.Header{"Content-Type": []string{"application/json"}},
  241. Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
  242. },
  243. }}
  244. svc := &OpenAIGatewayService{
  245. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  246. httpUpstream: upstream,
  247. }
  248. account, proxy := compactFallbackManagedProxyAccount()
  249. result, err := svc.Forward(context.Background(), c, account, body)
  250. require.NoError(t, err)
  251. require.NotNil(t, result)
  252. require.Len(t, upstream.bodies, 2)
  253. require.Equal(t, proxy.URL(), upstream.lastProxyURL)
  254. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  255. require.True(t, ok)
  256. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  257. require.True(t, ok)
  258. require.Len(t, events, 1)
  259. require.Equal(t, "retry", events[0].Kind)
  260. require.Equal(t, "compact_model_fallback", events[0].Reason)
  261. require.Equal(t, "rid_compact_1", events[0].UpstreamRequestID)
  262. requireCompactEventsAttributedTo(t, events, proxy)
  263. }
  264. // Streaming compact fallback whose second attempt fails with a failover-class
  265. // error: both the retry event and the failover event must carry the proxy.
  266. func TestOpenAIGatewayForwardCompactFailoverEventCarriesManagedProxy(t *testing.T) {
  267. gin.SetMode(gin.TestMode)
  268. body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  269. recorder := httptest.NewRecorder()
  270. c, _ := gin.CreateTestContext(recorder)
  271. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  272. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  273. c.Request.Header.Set("Content-Type", "application/json")
  274. MarkOpenAINativeCompactionV2(c)
  275. contextFailed := "event: response.failed\n" +
  276. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
  277. // The fallback model's failure is an account-state error, which
  278. // shouldFailoverOpenAIUpstreamResponse classifies as failover-worthy.
  279. workspaceFailed := "event: response.failed\n" +
  280. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"deactivated_workspace","message":"workspace deactivated"}}}` + "\n\n"
  281. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  282. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(contextFailed))},
  283. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(workspaceFailed))},
  284. }}
  285. svc := &OpenAIGatewayService{
  286. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  287. httpUpstream: upstream,
  288. }
  289. account, proxy := compactFallbackManagedProxyAccount()
  290. result, err := svc.Forward(context.Background(), c, account, body)
  291. require.Error(t, err)
  292. require.Nil(t, result)
  293. var failoverErr *UpstreamFailoverError
  294. require.ErrorAs(t, err, &failoverErr)
  295. require.Len(t, upstream.bodies, 2)
  296. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  297. require.True(t, ok)
  298. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  299. require.True(t, ok)
  300. require.Len(t, events, 2)
  301. require.Equal(t, "retry", events[0].Kind)
  302. require.Equal(t, "compact_model_fallback", events[0].Reason)
  303. require.Equal(t, "failover", events[1].Kind)
  304. requireCompactEventsAttributedTo(t, events, proxy)
  305. }
  306. func TestOpenAIGatewayForwardRetriesExplicitNativeCompactSSEFailureBeforeOutput(t *testing.T) {
  307. gin.SetMode(gin.TestMode)
  308. body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  309. c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
  310. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  311. c.Request.Header.Set("Content-Type", "application/json")
  312. MarkOpenAINativeCompactionV2(c)
  313. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  314. {
  315. StatusCode: http.StatusOK,
  316. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  317. Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
  318. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
  319. },
  320. {
  321. StatusCode: http.StatusOK,
  322. Header: http.Header{"Content-Type": []string{"application/json"}},
  323. Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
  324. },
  325. }}
  326. svc := &OpenAIGatewayService{
  327. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  328. httpUpstream: upstream,
  329. }
  330. account := &Account{
  331. ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
  332. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  333. Status: StatusActive, Schedulable: true,
  334. }
  335. result, err := svc.Forward(context.Background(), c, account, body)
  336. require.NoError(t, err)
  337. require.NotNil(t, result)
  338. require.Len(t, upstream.bodies, 2)
  339. require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
  340. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
  341. require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
  342. require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
  343. }
  344. func TestOpenAIGatewayForwardRetriesStreamingCompactFailureBeforeOutput(t *testing.T) {
  345. gin.SetMode(gin.TestMode)
  346. body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  347. recorder := httptest.NewRecorder()
  348. c, _ := gin.CreateTestContext(recorder)
  349. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  350. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  351. c.Request.Header.Set("Content-Type", "application/json")
  352. MarkOpenAINativeCompactionV2(c)
  353. failed := "event: response.failed\n" +
  354. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
  355. completed := "event: response.completed\n" +
  356. `data: {"type":"response.completed","response":{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n"
  357. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  358. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  359. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed))},
  360. }}
  361. svc := &OpenAIGatewayService{
  362. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  363. httpUpstream: upstream,
  364. }
  365. account := &Account{
  366. ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
  367. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  368. Status: StatusActive, Schedulable: true,
  369. }
  370. result, err := svc.Forward(context.Background(), c, account, body)
  371. require.NoError(t, err)
  372. require.NotNil(t, result)
  373. require.Len(t, upstream.bodies, 2)
  374. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
  375. require.NotContains(t, recorder.Body.String(), "context_length_exceeded")
  376. require.Contains(t, recorder.Body.String(), "response.completed")
  377. }
  378. func TestOpenAIGatewayForwardDoesNotRecurseWhenCompactFallbackAlsoFails(t *testing.T) {
  379. gin.SetMode(gin.TestMode)
  380. body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
  381. recorder := httptest.NewRecorder()
  382. c, _ := gin.CreateTestContext(recorder)
  383. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  384. c.Request.Body = io.NopCloser(bytes.NewReader(body))
  385. c.Request.Header.Set("Content-Type", "application/json")
  386. MarkOpenAINativeCompactionV2(c)
  387. failed := "event: response.failed\n" +
  388. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"model_not_found","message":"model not found"}}}` + "\n\n"
  389. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  390. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  391. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  392. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  393. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  394. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  395. }}
  396. svc := &OpenAIGatewayService{
  397. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  398. httpUpstream: upstream,
  399. }
  400. account := &Account{
  401. ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
  402. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
  403. Status: StatusActive, Schedulable: true,
  404. }
  405. result, err := svc.Forward(context.Background(), c, account, body)
  406. require.Error(t, err)
  407. require.Nil(t, result)
  408. require.Len(t, upstream.bodies, 2)
  409. require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
  410. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
  411. var compactSignal *openAICompactFallbackSignal
  412. require.False(t, errors.As(err, &compactSignal))
  413. require.Equal(t, http.StatusBadRequest, recorder.Code)
  414. require.Contains(t, recorder.Body.String(), "model not found")
  415. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  416. require.True(t, ok)
  417. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  418. require.True(t, ok)
  419. require.Len(t, events, 2)
  420. require.Equal(t, "retry", events[0].Kind)
  421. require.Equal(t, "compact_model_fallback", events[0].Reason)
  422. require.Equal(t, "http_error", events[1].Kind)
  423. for _, ev := range events {
  424. require.Nil(t, ev.ProxyID)
  425. require.Equal(t, opsProxyNameDirect, ev.ProxyName)
  426. }
  427. }
  428. func TestOpenAIPassthroughCompactFallbackSecondStreamFailureUsesStandardErrorPath(t *testing.T) {
  429. gin.SetMode(gin.TestMode)
  430. body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`)
  431. recorder := httptest.NewRecorder()
  432. c, _ := gin.CreateTestContext(recorder)
  433. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  434. c.Request.Header.Set("Content-Type", "application/json")
  435. MarkOpenAINativeCompactionV2(c)
  436. failed := "event: response.failed\n" +
  437. `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
  438. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  439. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  440. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
  441. }}
  442. svc := &OpenAIGatewayService{
  443. cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
  444. httpUpstream: upstream,
  445. }
  446. account, proxy := compactFallbackManagedProxyAccount()
  447. result, err := svc.forwardOpenAIPassthrough(
  448. context.Background(), c, account, body, body, "gpt-5.5", false, nil, true, time.Now(),
  449. )
  450. require.Error(t, err)
  451. require.Nil(t, result)
  452. require.Len(t, upstream.bodies, 2)
  453. var compactSignal *openAICompactFallbackSignal
  454. require.False(t, errors.As(err, &compactSignal))
  455. require.Equal(t, http.StatusBadRequest, recorder.Code)
  456. require.Contains(t, recorder.Body.String(), "context window exceeded")
  457. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  458. require.True(t, ok)
  459. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  460. require.True(t, ok)
  461. require.Len(t, events, 2)
  462. require.Equal(t, "retry", events[0].Kind)
  463. require.Equal(t, "compact_model_fallback", events[0].Reason)
  464. require.Equal(t, "http_error", events[1].Kind)
  465. require.True(t, events[1].Passthrough)
  466. require.Equal(t, proxy.URL(), upstream.lastProxyURL)
  467. requireCompactEventsAttributedTo(t, events, proxy)
  468. }