| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521 |
- package service
- import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strconv"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func newOpenAICompactFallbackTestContext(t *testing.T, path string) *gin.Context {
- t.Helper()
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, path, nil)
- return c
- }
- func TestPrepareOpenAICompactFallbackRetryRequiresExplicitCompact(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
- body := []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":"hello"}]}`)
- errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"maximum context length exceeded"}}`)
- retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
- c, nil, "gpt-5.5", body, http.StatusBadRequest, "maximum context length exceeded", errorBody, false,
- )
- require.False(t, retry)
- require.Empty(t, fallbackModel)
- require.Equal(t, body, retryBody)
- }
- func TestPrepareOpenAICompactFallbackRetryPreservesNativeTriggerAndContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
- MarkOpenAINativeCompactionV2(c)
- body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)
- pathBefore := openAIResponsesRequestPathSuffix(c)
- retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
- c, nil, "gpt-5.5", body, http.StatusBadRequest, "context window exceeded", errorBody, false,
- )
- require.True(t, retry)
- require.Equal(t, "gpt-5.4", fallbackModel)
- require.Equal(t, "gpt-5.4", gjson.GetBytes(retryBody, "model").String())
- require.True(t, HasCompactionTriggerInInput(retryBody))
- require.True(t, isOpenAINativeCompactionV2(c))
- require.Equal(t, pathBefore, openAIResponsesRequestPathSuffix(c))
- }
- func TestResolveOpenAICompactFallbackModelPrefersAccountMapping(t *testing.T) {
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}}}
- account := &Account{Credentials: map[string]any{
- "compact_model_mapping": map[string]any{"gpt-5.5": "account-compact"},
- }}
- require.Equal(t, "account-compact", svc.resolveOpenAICompactFallbackModel(account, "gpt-5.5"))
- require.Equal(t, "global-compact", svc.resolveOpenAICompactFallbackModel(account, "unmapped-model"))
- }
- func TestOpenAIGatewayForwardUsesGlobalCompactModelOnInitialLegacyRequest(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[]}`)
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"global-compact","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
- Status: StatusActive, Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 1)
- require.Equal(t, "global-compact", gjson.GetBytes(upstream.bodies[0], "model").String())
- require.Contains(t, upstream.requests[0].URL.Path, "/compact")
- }
- func TestPrepareOpenAICompactFallbackRetryLegacyPathAndSingleAttemptGuard(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
- body := []byte(`{"model":"gpt-5.5","input":[]}`)
- errorBody := []byte(`{"response":{"status":"failed","error":null}}`)
- retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
- c, nil, "gpt-5.5", body, http.StatusBadRequest, "", errorBody, false,
- )
- require.True(t, retry)
- require.Equal(t, "gpt-5.4", fallbackModel)
- require.Equal(t, "/compact", openAIResponsesRequestPathSuffix(c))
- secondBody, secondModel, secondRetry := svc.prepareOpenAICompactFallbackRetry(
- c, nil, "gpt-5.5", retryBody, http.StatusBadRequest, "", errorBody, true,
- )
- require.False(t, secondRetry)
- require.Empty(t, secondModel)
- require.Equal(t, retryBody, secondBody)
- }
- func TestPrepareOpenAICompactFallbackRetryDoesNotHideSpecificBusinessFailure(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
- body := []byte(`{"model":"gpt-5.5","input":[]}`)
- errorBody := []byte(`{"response":{"status":"failed","error":{"type":"permission_error","message":"workspace denied"}}}`)
- retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
- c, nil, "gpt-5.5", body, http.StatusBadRequest, "workspace denied", errorBody, false,
- )
- require.False(t, retry)
- require.Empty(t, fallbackModel)
- require.Equal(t, body, retryBody)
- }
- func TestIsOpenAICompactModelFailureRequiresExplicitModelAvailabilityMessage(t *testing.T) {
- tests := []struct {
- name string
- message string
- want bool
- }{
- {name: "explicit unsupported model", message: "The requested model is not supported", want: true},
- {name: "named missing model", message: "The model `gpt-5.5` does not exist", want: true},
- {name: "unsupported model code-like message", message: "unsupported model: gpt-5.5", want: true},
- {name: "unsupported model feature", message: "This model output format is not supported", want: false},
- {name: "unsupported parameter for model", message: "Parameter tools is not supported for this model", want: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, isOpenAICompactModelFailure(
- http.StatusBadRequest,
- tt.message,
- []byte(`{"error":{"message":`+strconv.Quote(tt.message)+`}}`),
- ))
- })
- }
- }
- func TestPrepareOpenAICompactFallbackRetrySkipsSameModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.5"}}}
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
- body := []byte(`{"model":"gpt-5.5","input":[]}`)
- errorBody := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
- _, _, retry := svc.prepareOpenAICompactFallbackRetry(
- c, nil, "gpt-5.5", body, http.StatusNotFound, "model not found", errorBody, false,
- )
- require.False(t, retry)
- }
- func TestOpenAIGatewayForwardRetriesExplicitNativeCompactHTTPFailureOnce(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
- },
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
- Status: StatusActive, Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
- require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
- require.True(t, HasCompactionTriggerInInput(upstream.bodies[1]))
- require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
- require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
- rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 1)
- require.Equal(t, "retry", events[0].Kind)
- require.Equal(t, "compact_model_fallback", events[0].Reason)
- require.Equal(t, http.StatusBadRequest, events[0].UpstreamStatusCode)
- require.Nil(t, events[0].ProxyID)
- require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
- }
- func compactFallbackManagedProxyAccount() (*Account, *Proxy) {
- proxy := &Proxy{ID: 10060, Name: "wldsg82-ipv6-10060", Protocol: "http", Host: "proxy.example", Port: 8080}
- return &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
- Status: StatusActive, Schedulable: true,
- ProxyID: &proxy.ID, Proxy: proxy,
- }, proxy
- }
- func requireCompactEventsAttributedTo(t *testing.T, events []*OpsUpstreamErrorEvent, proxy *Proxy) {
- t.Helper()
- for i, ev := range events {
- require.NotNil(t, ev.ProxyID, "event %d proxy_id", i)
- require.Equal(t, proxy.ID, *ev.ProxyID, "event %d proxy_id", i)
- require.Equal(t, proxy.Name, ev.ProxyName, "event %d proxy_name", i)
- }
- }
- // Non-streaming SSE compact retry: the first attempt's failure must be recorded
- // as its own attempt (it previously vanished on `continue`), carrying the
- // managed proxy the transport actually used.
- func TestOpenAIGatewayForwardNonStreamCompactRetryRecordsAttemptWithManagedProxy(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_compact_1"}},
- Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
- },
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account, proxy := compactFallbackManagedProxyAccount()
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, proxy.URL(), upstream.lastProxyURL)
- rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 1)
- require.Equal(t, "retry", events[0].Kind)
- require.Equal(t, "compact_model_fallback", events[0].Reason)
- require.Equal(t, "rid_compact_1", events[0].UpstreamRequestID)
- requireCompactEventsAttributedTo(t, events, proxy)
- }
- // Streaming compact fallback whose second attempt fails with a failover-class
- // error: both the retry event and the failover event must carry the proxy.
- func TestOpenAIGatewayForwardCompactFailoverEventCarriesManagedProxy(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- contextFailed := "event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
- // The fallback model's failure is an account-state error, which
- // shouldFailoverOpenAIUpstreamResponse classifies as failover-worthy.
- workspaceFailed := "event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"deactivated_workspace","message":"workspace deactivated"}}}` + "\n\n"
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(contextFailed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(workspaceFailed))},
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account, proxy := compactFallbackManagedProxyAccount()
- result, err := svc.Forward(context.Background(), c, account, body)
- require.Error(t, err)
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Len(t, upstream.bodies, 2)
- rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 2)
- require.Equal(t, "retry", events[0].Kind)
- require.Equal(t, "compact_model_fallback", events[0].Reason)
- require.Equal(t, "failover", events[1].Kind)
- requireCompactEventsAttributedTo(t, events, proxy)
- }
- func TestOpenAIGatewayForwardRetriesExplicitNativeCompactSSEFailureBeforeOutput(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
- },
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
- Status: StatusActive, Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
- require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
- require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
- require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
- }
- func TestOpenAIGatewayForwardRetriesStreamingCompactFailureBeforeOutput(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- failed := "event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
- completed := "event: response.completed\n" +
- `data: {"type":"response.completed","response":{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n"
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed))},
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
- Status: StatusActive, Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
- require.NotContains(t, recorder.Body.String(), "context_length_exceeded")
- require.Contains(t, recorder.Body.String(), "response.completed")
- }
- func TestOpenAIGatewayForwardDoesNotRecurseWhenCompactFallbackAlsoFails(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Request.Body = io.NopCloser(bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- failed := "event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"model_not_found","message":"model not found"}}}` + "\n\n"
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
- Status: StatusActive, Schedulable: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.Error(t, err)
- require.Nil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
- require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
- var compactSignal *openAICompactFallbackSignal
- require.False(t, errors.As(err, &compactSignal))
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- require.Contains(t, recorder.Body.String(), "model not found")
- rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 2)
- require.Equal(t, "retry", events[0].Kind)
- require.Equal(t, "compact_model_fallback", events[0].Reason)
- require.Equal(t, "http_error", events[1].Kind)
- for _, ev := range events {
- require.Nil(t, ev.ProxyID)
- require.Equal(t, opsProxyNameDirect, ev.ProxyName)
- }
- }
- func TestOpenAIPassthroughCompactFallbackSecondStreamFailureUsesStandardErrorPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Request.Header.Set("Content-Type", "application/json")
- MarkOpenAINativeCompactionV2(c)
- failed := "event: response.failed\n" +
- `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
- httpUpstream: upstream,
- }
- account, proxy := compactFallbackManagedProxyAccount()
- result, err := svc.forwardOpenAIPassthrough(
- context.Background(), c, account, body, body, "gpt-5.5", false, nil, true, time.Now(),
- )
- require.Error(t, err)
- require.Nil(t, result)
- require.Len(t, upstream.bodies, 2)
- var compactSignal *openAICompactFallbackSignal
- require.False(t, errors.As(err, &compactSignal))
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- require.Contains(t, recorder.Body.String(), "context window exceeded")
- rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 2)
- require.Equal(t, "retry", events[0].Kind)
- require.Equal(t, "compact_model_fallback", events[0].Reason)
- require.Equal(t, "http_error", events[1].Kind)
- require.True(t, events[1].Passthrough)
- require.Equal(t, proxy.URL(), upstream.lastProxyURL)
- requireCompactEventsAttributedTo(t, events, proxy)
- }
|