| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- package service
- import (
- "bytes"
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- func TestSetOpenAICodexRoutingHintCanonicalizesOfficialServiceTiers(t *testing.T) {
- oauthAccount := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- tests := []struct {
- name string
- model string
- serviceTier string
- want string
- }{
- {name: "fast alias", model: "gpt-5.6", serviceTier: " fast ", want: "model=gpt-5.6;tier=priority"},
- {name: "priority", model: "gpt-5.6", serviceTier: "priority", want: "model=gpt-5.6;tier=priority"},
- {name: "flex", model: "gpt-5.6", serviceTier: "flex", want: "model=gpt-5.6;tier=flex"},
- {name: "ultrafast", model: "gpt-5.6", serviceTier: "ultrafast", want: "model=gpt-5.6;tier=ultrafast"},
- {name: "explicit default sentinel", model: "gpt-5.6", serviceTier: "default", want: "model=gpt-5.6"},
- {name: "omitted tier", model: "gpt-5.6", want: "model=gpt-5.6"},
- {name: "auto is not expanded without catalog support", model: "gpt-5.6", serviceTier: "auto", want: "model=gpt-5.6"},
- {name: "scale is not expanded without catalog support", model: "gpt-5.6", serviceTier: "scale", want: "model=gpt-5.6"},
- {name: "unknown tier does not expand protocol", model: "gpt-5.6", serviceTier: "turbo", want: "model=gpt-5.6"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- headers := make(http.Header)
- setOpenAICodexRoutingHint(headers, oauthAccount, tt.model, tt.serviceTier)
- require.Equal(t, tt.want, headers.Get(openAICodexRoutingHintHeader))
- })
- }
- t.Run("invalid header value is omitted", func(t *testing.T) {
- headers := make(http.Header)
- setOpenAICodexRoutingHint(headers, oauthAccount, "gpt-5.6\ninvalid", "priority")
- require.Empty(t, headers.Get(openAICodexRoutingHintHeader))
- })
- for _, model := range []string{"gpt-5.6;evil", "gpt=5.6"} {
- t.Run("delimiter in model is omitted: "+model, func(t *testing.T) {
- headers := make(http.Header)
- setOpenAICodexRoutingHint(headers, oauthAccount, model, "priority")
- require.Empty(t, headers.Get(openAICodexRoutingHintHeader))
- })
- }
- t.Run("api key strips gateway-owned hint in every key casing", func(t *testing.T) {
- headers := make(http.Header)
- headers[openAICodexRoutingHintHeader] = []string{"lowercase-spoof"}
- headers["X-Codex-Routing-Hint"] = []string{"canonical-spoof"}
- setOpenAICodexRoutingHint(headers, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "gpt-5.6", "priority")
- for key := range headers {
- require.False(t, strings.EqualFold(key, openAICodexRoutingHintHeader))
- }
- })
- t.Run("oauth replaces spoofed lowercase hint", func(t *testing.T) {
- headers := make(http.Header)
- headers[openAICodexRoutingHintHeader] = []string{"model=spoof;tier=flex"}
- setOpenAICodexRoutingHint(headers, oauthAccount, "gpt-5.6", "priority")
- require.Equal(t, "model=gpt-5.6;tier=priority", headers.Get(openAICodexRoutingHintHeader))
- require.Len(t, headers, 1)
- })
- }
- func TestOpenAIOAuthHTTPBuildersSendRoutingHintFromFinalBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
- oauthAccount := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "chatgpt_account_id": "test-account",
- },
- }
- svc := &OpenAIGatewayService{}
- tests := []struct {
- name string
- body []byte
- want string
- }{
- {name: "fast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`), want: "model=gpt-5.6-codex;tier=priority"},
- {name: "flex", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"flex"}`), want: "model=gpt-5.6-codex;tier=flex"},
- {name: "ultrafast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"ultrafast"}`), want: "model=gpt-5.6-codex;tier=ultrafast"},
- {name: "default", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"default"}`), want: "model=gpt-5.6-codex"},
- {name: "omitted", body: []byte(`{"model":"gpt-5.6-codex"}`), want: "model=gpt-5.6-codex"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- for _, passthrough := range []bool{false, true} {
- mode := "ordinary"
- if passthrough {
- mode = "passthrough"
- }
- t.Run(mode, func(t *testing.T) {
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(tt.body))
- var req *http.Request
- var err error
- if passthrough {
- req, err = svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, oauthAccount, tt.body, "test-token")
- } else {
- req, err = svc.buildUpstreamRequest(context.Background(), c, oauthAccount, tt.body, "test-token", false, "", true)
- }
- require.NoError(t, err)
- require.Equal(t, tt.want, req.Header.Get(openAICodexRoutingHintHeader))
- })
- }
- })
- }
- }
- func TestOpenAIHTTPPassthroughStripsOnlyOAuthLegacyResponsesBeta(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{
- Security: config.SecurityConfig{
- URLAllowlist: config.URLAllowlistConfig{Enabled: false},
- },
- }}
- body := []byte(`{"model":"gpt-5.6-codex","service_tier":"priority"}`)
- build := func(t *testing.T, account *Account, betaValues []string, rawLowercaseKey bool) http.Header {
- t.Helper()
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- if rawLowercaseKey {
- c.Request.Header["openai-beta"] = append([]string(nil), betaValues...)
- } else {
- for _, value := range betaValues {
- c.Request.Header.Add("OpenAI-Beta", value)
- }
- }
- req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token")
- require.NoError(t, err)
- return req.Header
- }
- oauth := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "chatgpt_account_id": "test-account",
- },
- }
- apiKey := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "test-api-key",
- },
- }
- t.Run("oauth legacy only is removed including raw lowercase key", func(t *testing.T) {
- headers := build(t, oauth, []string{"responses=experimental"}, true)
- require.Empty(t, headers.Values("OpenAI-Beta"))
- })
- t.Run("oauth mixed beta preserves independent tokens", func(t *testing.T) {
- headers := build(t, oauth, []string{
- "responses=experimental, future_feature=v1",
- "another_feature=v2, RESPONSES=EXPERIMENTAL",
- }, false)
- require.Equal(t, []string{"future_feature=v1", "another_feature=v2"}, headers.Values("OpenAI-Beta"))
- })
- t.Run("api key explicit beta remains caller controlled", func(t *testing.T) {
- headers := build(t, apiKey, []string{"responses=experimental, future_feature=v1"}, false)
- require.Equal(t, []string{"responses=experimental, future_feature=v1"}, headers.Values("OpenAI-Beta"))
- })
- }
- func TestBuildOpenAIWSHeadersSendsOAuthRoutingHintOnly(t *testing.T) {
- gin.SetMode(gin.TestMode)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
- svc := &OpenAIGatewayService{}
- decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}
- build := func(t *testing.T, account *Account, tier string) http.Header {
- headers, _, err := svc.buildOpenAIWSHeaders(
- context.Background(),
- c,
- account,
- "test-token",
- decision,
- true,
- "",
- "",
- "",
- "gpt-5.6-codex",
- tier,
- )
- require.NoError(t, err)
- return headers
- }
- oauthAccount := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "chatgpt_account_id": "test-account",
- },
- }
- require.Equal(t, "model=gpt-5.6-codex;tier=priority", build(t, oauthAccount, "fast").Get(openAICodexRoutingHintHeader))
- require.Equal(t, "model=gpt-5.6-codex;tier=ultrafast", build(t, oauthAccount, "ultrafast").Get(openAICodexRoutingHintHeader))
- require.Equal(t, "model=gpt-5.6-codex", build(t, oauthAccount, "default").Get(openAICodexRoutingHintHeader))
- require.Empty(t, build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "priority").Get(openAICodexRoutingHintHeader))
- }
- func TestOpenAIRoutingDiagnosticsUseFinalDerivedValuesOnly(t *testing.T) {
- gin.SetMode(gin.TestMode)
- logSink, restore := captureStructuredLog(t)
- defer restore()
- account := &Account{
- ID: 917,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "chatgpt_account_id": "chatgpt-account",
- },
- }
- body := []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`)
- svc := &OpenAIGatewayService{}
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Authorization", "Bearer caller-secret")
- c.Request.Header.Set(openAICodexRoutingHintHeader, "model=caller-secret")
- _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "oauth-secret", false, "", true)
- require.NoError(t, err)
- decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}
- _, _, err = svc.buildOpenAIWSHeaders(
- context.Background(), c, account, "oauth-secret", decision, true,
- "", "", "", "gpt-5.6-codex", "fast",
- )
- require.NoError(t, err)
- require.True(t, logSink.ContainsMessageAtLevel("openai routing decision", "debug"))
- require.True(t, logSink.ContainsFieldValue("account_id", "917"))
- require.True(t, logSink.ContainsFieldValue("final_model", "gpt-5.6-codex"))
- require.True(t, logSink.ContainsFieldValue("final_service_tier", "priority"))
- require.True(t, logSink.ContainsFieldValue("routing_hint_generated", "true"))
- require.True(t, logSink.ContainsFieldValue("transport", "http"))
- require.True(t, logSink.ContainsFieldValue("transport", string(OpenAIUpstreamTransportResponsesWebsocketV2)))
- require.True(t, logSink.ContainsFieldValue("ws_affinity_decision", "not_applicable"))
- require.True(t, logSink.ContainsFieldValue("ws_affinity_decision", "soft_routing_hint"))
- require.False(t, logSink.ContainsFieldValue("authorization", "caller-secret"))
- require.False(t, logSink.ContainsFieldValue("credentials", "oauth-secret"))
- require.False(t, logSink.ContainsFieldValue("routing_hint", "caller-secret"))
- }
- func TestOpenAIWSConnPoolPreferredContinuationIgnoresRoutingHintChanges(t *testing.T) {
- cfg := &config.Config{}
- cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2
- cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
- cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2
- pool := newOpenAIWSConnPool(cfg)
- dialer := &openAIWSCountingDialer{}
- pool.setClientDialerForTest(dialer)
- account := &Account{ID: 913, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- acquire := func(t *testing.T, hint, preferred string, forcePreferred bool) *openAIWSConnLease {
- t.Helper()
- headers := make(http.Header)
- if hint != "" {
- headers.Set(openAICodexRoutingHintHeader, hint)
- }
- lease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{
- Account: account,
- WSURL: "wss://example.com/v1/responses",
- Headers: headers,
- PreferredConnID: preferred,
- ForcePreferredConn: forcePreferred,
- })
- require.NoError(t, err)
- require.NotNil(t, lease)
- return lease
- }
- standard := acquire(t, "model=gpt-5.6-codex", "", false)
- connID := standard.ConnID()
- standard.Release()
- priority := acquire(t, "model=gpt-5.6-codex;tier=priority", connID, true)
- require.True(t, priority.Reused())
- require.Equal(t, connID, priority.ConnID())
- priority.Release()
- standardAgain := acquire(t, "model=gpt-5.6-codex", connID, true)
- require.True(t, standardAgain.Reused())
- require.Equal(t, connID, standardAgain.ConnID())
- standardAgain.Release()
- require.Equal(t, 1, dialer.DialCount(), "routing hint is dial-time advisory, not continuation compatibility")
- }
- func TestOpenAIWSConnPoolUsesRoutingHintAsSoftDialAffinity(t *testing.T) {
- cfg := &config.Config{}
- cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 4
- cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
- cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 4
- pool := newOpenAIWSConnPool(cfg)
- dialer := &openAIWSCountingDialer{}
- pool.setClientDialerForTest(dialer)
- account := &Account{ID: 913, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- acquire := func(t *testing.T, hint string) *openAIWSConnLease {
- headers := make(http.Header)
- headers.Set(openAICodexRoutingHintHeader, hint)
- lease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{
- Account: account,
- WSURL: "wss://example.com/v1/responses",
- Headers: headers,
- })
- require.NoError(t, err)
- require.NotNil(t, lease)
- return lease
- }
- priority := acquire(t, "model=gpt-5.6-codex;tier=priority")
- priorityConnID := priority.ConnID()
- priority.Release()
- priorityAgain := acquire(t, "model=gpt-5.6-codex;tier=priority")
- require.True(t, priorityAgain.Reused())
- require.Equal(t, priorityConnID, priorityAgain.ConnID())
- priorityAgain.Release()
- flex := acquire(t, "model=gpt-5.6-codex;tier=flex")
- require.False(t, flex.Reused())
- require.NotEqual(t, priorityConnID, flex.ConnID())
- flex.Release()
- otherModel := acquire(t, "model=gpt-5.5-codex;tier=priority")
- require.False(t, otherModel.Reused())
- require.NotEqual(t, priorityConnID, otherModel.ConnID())
- otherModel.Release()
- defaultTier := acquire(t, "model=gpt-5.6-codex")
- require.False(t, defaultTier.Reused())
- require.NotEqual(t, priorityConnID, defaultTier.ConnID())
- defaultConnID := defaultTier.ConnID()
- defaultTier.Release()
- defaultAgain := acquire(t, "model=gpt-5.6-codex")
- require.True(t, defaultAgain.Reused())
- require.Equal(t, defaultConnID, defaultAgain.ConnID())
- defaultAgain.Release()
- require.Equal(t, 4, dialer.DialCount())
- }
|