mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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.
522 lines
24 KiB
Go
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)
|
|
}
|