Files
sub2api/backend/internal/service/openai_compact_fallback_test.go
T
Wei-Shaw 4c1f920d58 fix(ops): close proxy attribution gaps and bound queued upstream events
Follow-up to the event-time proxy snapshot: every upstream attempt now
carries attribution, the direct/unknown contract is enforced everywhere,
and the queued upstream_errors array has hard bounds again.

Attribution coverage
- compact fallback retry helper and the streaming compact failover event
  now stamp proxy_id/proxy_name; the non-streaming SSE compact retry
  previously dropped its first attempt on `continue` and now records it
- raw chat-completions stream truncation events (new on main) attributed
- Anthropic/Bedrock transport errors get attribution inside the shared
  handleUpstreamTransportError helper, so all five callers inherit it
- OpenAI first-output timeout takes attribution from the caller: HTTP
  callers pass the HTTP rule, the WS passthrough relay passes the WS rule
  (no managed proxy => unknown, never direct, because http.DefaultClient
  may honor environment proxies)

Contract hardening
- single opsUpstreamProxyAttribution returns (id, name) from one decision;
  a hydrated Proxy without a durable ID is unknown, not id=null+name
- both normalizers (struct at append time, JSON on detail reads) enforce
  proxy_id==null <=> sentinel name; blank managed name => "proxy"
- detail-read normalization edits only the attribution keys via sjson, so
  keys from older struct versions and stored key order are preserved

Queue bounds
- keep at most 256 attempts and ~512 KiB serialized per entry, newest
  first; the oldest retained event records dropped_earlier_attempts
- attempts outside the 16-event body window trim url/message to 512 bytes
- restore the empty-event drop rule, judged before the body window clears
  detail so older real attempts survive

Tests cover each site above (managed proxy, direct, WS unknown), the
normalizer invariants, key/order preservation, and the count/byte bounds.
2026-09-03 15:11:36 +08:00

522 lines
24 KiB
Go

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)
}