mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
Merge pull request #5406 from bestony/fix/openai-oauth-routing-hints
fix(openai): forward OAuth routing hints
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestNormalizeOpenAICompactRequestBodyPreservesServiceTier(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"input":[{"type":"message","role":"user","content":"hello"}],
|
||||
"service_tier":"priority",
|
||||
"prompt_cache_key":"compact-cache-key",
|
||||
"store":false,
|
||||
"stream":true
|
||||
}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAICompactRequestBody(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(normalized, "model").String())
|
||||
require.Equal(t, "priority", gjson.GetBytes(normalized, "service_tier").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "store").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "stream").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIOAuthCompactHTTPBuildersUsePreservedServiceTierInRoutingHint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"input":[{"type":"message","role":"user","content":"hello"}],
|
||||
"service_tier":"priority",
|
||||
"stream":true
|
||||
}`)
|
||||
normalized, changed, err := normalizeOpenAICompactRequestBody(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "priority", gjson.GetBytes(normalized, "service_tier").String())
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "test-account",
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
build func(*gin.Context) (*http.Request, error)
|
||||
}{
|
||||
{
|
||||
name: "ordinary",
|
||||
build: func(c *gin.Context) (*http.Request, error) {
|
||||
return svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, normalized, "test-token",
|
||||
false, "", true,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "passthrough",
|
||||
build: func(c *gin.Context) (*http.Request, error) {
|
||||
return svc.buildUpstreamRequestOpenAIPassthrough(
|
||||
context.Background(), c, account, normalized, "test-token",
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/v1/responses/compact",
|
||||
bytes.NewReader(normalized),
|
||||
)
|
||||
|
||||
req, buildErr := tt.build(c)
|
||||
require.NoError(t, buildErr)
|
||||
require.Equal(
|
||||
t,
|
||||
"model=gpt-5.6-sol;tier=priority",
|
||||
req.Header.Get(openAICodexRoutingHintHeader),
|
||||
)
|
||||
require.Equal(t, "priority", gjson.GetBytes(normalized, "service_tier").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1072,7 +1072,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
req.Header.Del("OpenAI-Beta")
|
||||
req.Header.Del("originator")
|
||||
} else {
|
||||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
|
||||
}
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
@@ -1124,6 +1123,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
setOpenAICodexRoutingHintFromBody(req.Header, account, body)
|
||||
logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -392,6 +392,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
|
||||
// OAuth 透传到 ChatGPT internal API 时补齐必要头。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
// Current Codex OAuth HTTP no longer negotiates the legacy Responses
|
||||
// experiment. Passthrough may receive it from an older client, so remove
|
||||
// only that token while preserving any independent beta negotiation.
|
||||
stripOpenAILegacyResponsesBeta(req.Header)
|
||||
promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
|
||||
req.Host = "chatgpt.com"
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
|
||||
@@ -412,9 +416,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
} else if req.Header.Get("accept") == "" {
|
||||
req.Header.Set("accept", "text/event-stream")
|
||||
}
|
||||
if req.Header.Get("OpenAI-Beta") == "" {
|
||||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
}
|
||||
if req.Header.Get("originator") == "" {
|
||||
req.Header.Set("originator", openai.CodexDefaultOriginator)
|
||||
}
|
||||
@@ -458,10 +459,43 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
setOpenAICodexRoutingHintFromBody(req.Header, account, body)
|
||||
logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func stripOpenAILegacyResponsesBeta(headers http.Header) {
|
||||
if headers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
preserved := make([]string, 0)
|
||||
for key, values := range headers {
|
||||
if !strings.EqualFold(strings.TrimSpace(key), "OpenAI-Beta") {
|
||||
continue
|
||||
}
|
||||
delete(headers, key)
|
||||
for _, value := range values {
|
||||
parts := strings.Split(value, ",")
|
||||
kept := parts[:0]
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || strings.EqualFold(part, "responses=experimental") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
preserved = append(preserved, strings.Join(kept, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range preserved {
|
||||
headers.Add("OpenAI-Beta", value)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
|
||||
if isOpenAIContextWindowError("", responseBody) {
|
||||
return false
|
||||
|
||||
@@ -308,6 +308,7 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
|
||||
"tools",
|
||||
"parallel_tool_calls",
|
||||
"reasoning",
|
||||
"service_tier",
|
||||
"text",
|
||||
"previous_response_id",
|
||||
} {
|
||||
|
||||
@@ -2888,10 +2888,30 @@ func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *test
|
||||
require.Equal(t, chatgptCodexURL+"/compact", req.URL.String())
|
||||
require.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
require.Equal(t, codexCLIVersion, req.Header.Get("Version"))
|
||||
require.Empty(t, req.Header.Get("OpenAI-Beta"), "Codex OAuth HTTP must not synthesize the legacy responses beta header")
|
||||
require.NotEmpty(t, req.Header.Get("Session_Id"))
|
||||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(req.Context()))
|
||||
}
|
||||
|
||||
func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesExplicitAPIKeyBetaHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(`{"model":"gpt-5"}`)))
|
||||
c.Request.Header.Set("OpenAI-Beta", "api-key-specific-beta")
|
||||
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||
},
|
||||
}}
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
req, err := svc.buildUpstreamRequestOpenAIPassthrough(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "api-key-specific-beta", req.Header.Get("OpenAI-Beta"), "OAuth-only backport must not alter API-key passthrough headers")
|
||||
}
|
||||
|
||||
func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2909,6 +2929,7 @@ func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T)
|
||||
require.Equal(t, chatgptCodexURL+"/compact", req.URL.String())
|
||||
require.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
require.Equal(t, codexCLIVersion, req.Header.Get("Version"))
|
||||
require.Empty(t, req.Header.Get("OpenAI-Beta"), "Codex OAuth HTTP must not synthesize the legacy responses beta header")
|
||||
require.NotEmpty(t, req.Header.Get("Session_Id"))
|
||||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(req.Context()))
|
||||
}
|
||||
|
||||
@@ -767,7 +767,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
|
||||
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
|
||||
require.Equal(t, "acct-123", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||||
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
|
||||
require.Equal(t, openAIImagesResponsesMainModel, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
const openAICodexRoutingHintHeader = "x-codex-routing-hint"
|
||||
|
||||
// setOpenAICodexRoutingHint mirrors the Codex backend routing-hint contract for
|
||||
// OpenAI OAuth requests. The request model must already be the final upstream
|
||||
// slug and serviceTier must already reflect any local policy rewrite/filter.
|
||||
func setOpenAICodexRoutingHint(headers http.Header, account *Account, model string, serviceTier string) {
|
||||
if headers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// The routing hint is gateway-owned. Strip every spelling before deciding
|
||||
// whether to synthesize it so API-key/provider credential paths cannot pass
|
||||
// through a caller- or account-override-supplied hint. http.Header.Del only
|
||||
// removes the canonical map key; inbound maps can contain raw lowercase keys.
|
||||
deleteOpenAIHeaderEqualFold(headers, openAICodexRoutingHintHeader)
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" || strings.ContainsAny(model, ";=") {
|
||||
return
|
||||
}
|
||||
|
||||
// Codex treats "default" as an explicit standard-routing sentinel, not as a
|
||||
// service tier sent to the backend. Fast follows the gateway's existing
|
||||
// canonicalization and therefore becomes "priority"; flex stays "flex".
|
||||
canonicalTier := normalizedOpenAIServiceTierValue(serviceTier)
|
||||
// This backport has no Codex model-catalog snapshot with which to validate
|
||||
// arbitrary tier ids. Keep the hint to the two effective tiers Codex itself
|
||||
// selects; default, missing, and other gateway-compatible API values remain
|
||||
// model-only rather than expanding the ChatGPT routing protocol here.
|
||||
switch canonicalTier {
|
||||
case OpenAIFastTierPriority, OpenAIFastTierFlex:
|
||||
default:
|
||||
canonicalTier = ""
|
||||
}
|
||||
|
||||
hint := "model=" + model
|
||||
if canonicalTier != "" {
|
||||
hint += ";tier=" + canonicalTier
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(hint) {
|
||||
return
|
||||
}
|
||||
headers.Set(openAICodexRoutingHintHeader, hint)
|
||||
}
|
||||
|
||||
func deleteOpenAIHeaderEqualFold(headers http.Header, name string) {
|
||||
if headers == nil {
|
||||
return
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
for key := range headers {
|
||||
if strings.EqualFold(strings.TrimSpace(key), name) {
|
||||
delete(headers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setOpenAICodexRoutingHintFromBody(headers http.Header, account *Account, body []byte) {
|
||||
fields := gjson.GetManyBytes(body, "model", "service_tier")
|
||||
setOpenAICodexRoutingHint(headers, account, fields[0].String(), fields[1].String())
|
||||
}
|
||||
|
||||
// logOpenAIRoutingDiagnostics records only gateway-derived routing state. In
|
||||
// particular, it deliberately does not include any header values, tokens, or
|
||||
// credentials because these diagnostics run on authentication-bearing paths.
|
||||
func logOpenAIRoutingDiagnostics(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
transport string,
|
||||
model string,
|
||||
serviceTier string,
|
||||
hintGenerated bool,
|
||||
wsAffinityDecision string,
|
||||
) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
accountID := int64(0)
|
||||
if account != nil {
|
||||
accountID = account.ID
|
||||
}
|
||||
|
||||
logger.FromContext(ctx).Debug("openai routing decision",
|
||||
zap.String("component", "service.openai_routing"),
|
||||
zap.String("transport", strings.TrimSpace(transport)),
|
||||
zap.Int64("account_id", accountID),
|
||||
zap.String("final_model", strings.TrimSpace(model)),
|
||||
zap.String("final_service_tier", normalizedOpenAIServiceTierValue(serviceTier)),
|
||||
zap.Bool("routing_hint_generated", hintGenerated),
|
||||
zap.String("ws_affinity_decision", strings.TrimSpace(wsAffinityDecision)),
|
||||
)
|
||||
}
|
||||
|
||||
func logOpenAIRoutingDiagnosticsFromBody(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
transport string,
|
||||
headers http.Header,
|
||||
body []byte,
|
||||
wsAffinityDecision string,
|
||||
) {
|
||||
fields := gjson.GetManyBytes(body, "model", "service_tier")
|
||||
logOpenAIRoutingDiagnostics(
|
||||
ctx,
|
||||
account,
|
||||
transport,
|
||||
fields[0].String(),
|
||||
fields[1].String(),
|
||||
strings.TrimSpace(headers.Get(openAICodexRoutingHintHeader)) != "",
|
||||
wsAffinityDecision,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
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: "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: "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", 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())
|
||||
}
|
||||
@@ -617,7 +617,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
}
|
||||
|
||||
wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), firstPayload.promptCacheKey)
|
||||
firstRoutingFields := gjson.GetManyBytes(firstPayload.payloadRaw, "model", "service_tier")
|
||||
wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
token,
|
||||
wsDecision,
|
||||
isCodexCLI,
|
||||
turnState,
|
||||
strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)),
|
||||
firstPayload.promptCacheKey,
|
||||
firstRoutingFields[0].String(),
|
||||
firstRoutingFields[1].String(),
|
||||
)
|
||||
if buildHdrErr != nil {
|
||||
return fmt.Errorf("build ws headers: %w", buildHdrErr)
|
||||
}
|
||||
@@ -1636,16 +1649,30 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
nextRoutingFields := gjson.GetManyBytes(nextPayload.payloadRaw, "model", "service_tier")
|
||||
if nextPayload.promptCacheKey != "" {
|
||||
// ingress 会话在整个客户端 WS 生命周期内复用同一上游连接;
|
||||
// prompt_cache_key 对握手头的更新仅在未来需要重新建连时生效。
|
||||
updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), nextPayload.promptCacheKey)
|
||||
updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
token,
|
||||
wsDecision,
|
||||
isCodexCLI,
|
||||
turnState,
|
||||
strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)),
|
||||
nextPayload.promptCacheKey,
|
||||
nextRoutingFields[0].String(),
|
||||
nextRoutingFields[1].String(),
|
||||
)
|
||||
if updHdrErr != nil {
|
||||
logOpenAIWSModeInfo("ingress_ws_update_headers_failed account_id=%d err=%v", account.ID, updHdrErr)
|
||||
} else {
|
||||
baseAcquireReq.Headers = updatedHeaders
|
||||
}
|
||||
}
|
||||
setOpenAICodexRoutingHint(baseAcquireReq.Headers, account, nextRoutingFields[0].String(), nextRoutingFields[1].String())
|
||||
if nextPayload.previousResponseID != "" {
|
||||
expectedPrev := strings.TrimSpace(lastTurnResponseID)
|
||||
chainedFromLast := expectedPrev != "" && nextPayload.previousResponseID == expectedPrev
|
||||
|
||||
@@ -75,6 +75,8 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
|
||||
turnState string,
|
||||
turnMetadata string,
|
||||
promptCacheKey string,
|
||||
routingModel string,
|
||||
routingServiceTier string,
|
||||
) (http.Header, openAIWSSessionHeaderResolution, error) {
|
||||
headers := make(http.Header)
|
||||
if account == nil || !account.IsOpenAIAgentIdentity() {
|
||||
@@ -157,6 +159,16 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
|
||||
// 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
|
||||
account.ApplyHeaderOverrides(headers)
|
||||
setOpenAICodexRoutingHint(headers, account, routingModel, routingServiceTier)
|
||||
logOpenAIRoutingDiagnostics(
|
||||
ctx,
|
||||
account,
|
||||
string(decision.Transport),
|
||||
routingModel,
|
||||
routingServiceTier,
|
||||
strings.TrimSpace(headers.Get(openAICodexRoutingHintHeader)) != "",
|
||||
"soft_routing_hint",
|
||||
)
|
||||
|
||||
return headers, sessionResolution, nil
|
||||
}
|
||||
|
||||
@@ -414,6 +414,8 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -137,7 +137,19 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
storeDisabledConnMode := s.openAIWSStoreDisabledConnMode()
|
||||
forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason)
|
||||
forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == ""
|
||||
wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey)
|
||||
wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
token,
|
||||
decision,
|
||||
isCodexCLI,
|
||||
turnState,
|
||||
turnMetadata,
|
||||
promptCacheKey,
|
||||
openAIWSPayloadString(payload, "model"),
|
||||
openAIWSPayloadString(payload, "service_tier"),
|
||||
)
|
||||
if buildHdrErr != nil {
|
||||
return nil, fmt.Errorf("build ws headers: %w", buildHdrErr)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ type openAIWSAcquireRequest struct {
|
||||
ForcePreferredConn bool
|
||||
}
|
||||
|
||||
type openAIWSHandshakeCompatibilityKey struct {
|
||||
betaFeatures string
|
||||
}
|
||||
|
||||
type openAIWSConnLease struct {
|
||||
pool *openAIWSConnPool
|
||||
accountID int64
|
||||
@@ -240,8 +244,9 @@ type openAIWSConn struct {
|
||||
id string
|
||||
ws openAIWSClientConn
|
||||
|
||||
handshakeHeaders http.Header
|
||||
betaFeatures string
|
||||
handshakeHeaders http.Header
|
||||
handshakeCompatibility openAIWSHandshakeCompatibilityKey
|
||||
routingAffinity string
|
||||
|
||||
leaseCh chan struct{}
|
||||
closedCh chan struct{}
|
||||
@@ -305,6 +310,14 @@ func (c *openAIWSConn) acquire(ctx context.Context) error {
|
||||
case <-c.closedCh:
|
||||
return errOpenAIWSConnClosed
|
||||
case <-c.leaseCh:
|
||||
// A cancellation and a lease delivery can become ready together. Once
|
||||
// the semaphore token has been consumed, check the context again and
|
||||
// return it before reporting cancellation so a canceled waiter cannot
|
||||
// strand a pooled connection.
|
||||
if err := ctx.Err(); err != nil {
|
||||
c.release()
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-c.closedCh:
|
||||
c.release()
|
||||
@@ -525,8 +538,12 @@ func (c *openAIWSConn) handshakeHeader(name string) string {
|
||||
return strings.TrimSpace(c.handshakeHeaders.Get(strings.TrimSpace(name)))
|
||||
}
|
||||
|
||||
func (c *openAIWSConn) matchesBetaFeatures(betaFeatures string) bool {
|
||||
return c != nil && c.betaFeatures == betaFeatures
|
||||
func (c *openAIWSConn) matchesHandshakeCompatibility(compatibility openAIWSHandshakeCompatibilityKey) bool {
|
||||
return c != nil && c.handshakeCompatibility == compatibility
|
||||
}
|
||||
|
||||
func (c *openAIWSConn) matchesRoutingAffinity(routingAffinity string) bool {
|
||||
return c != nil && c.routingAffinity == routingAffinity
|
||||
}
|
||||
|
||||
func (c *openAIWSConn) isPrewarmed() bool {
|
||||
@@ -838,7 +855,8 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque
|
||||
|
||||
retryAcquire:
|
||||
accountID := req.Account.ID
|
||||
betaFeatures := normalizeOpenAIWSBetaFeatures(req.Headers)
|
||||
compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Headers)
|
||||
routingAffinity := normalizeOpenAIWSRoutingAffinity(req.Headers)
|
||||
effectiveMaxConns := p.effectiveMaxConnsByAccount(req.Account)
|
||||
if effectiveMaxConns <= 0 {
|
||||
return nil, errOpenAIWSConnQueueFull
|
||||
@@ -846,7 +864,7 @@ retryAcquire:
|
||||
var evicted []*openAIWSConn
|
||||
ap := p.getOrCreateAccountPool(accountID)
|
||||
ap.mu.Lock()
|
||||
ap.lastAcquire = cloneOpenAIWSAcquireRequestPtr(&req)
|
||||
acquireGeneration := ap.generation
|
||||
now := time.Now()
|
||||
if ap.lastCleanupAt.IsZero() || now.Sub(ap.lastCleanupAt) >= openAIWSAcquireCleanupInterval {
|
||||
evicted = p.cleanupAccountLocked(ap, now, effectiveMaxConns)
|
||||
@@ -866,7 +884,7 @@ retryAcquire:
|
||||
return nil, errOpenAIWSPreferredConnUnavailable
|
||||
}
|
||||
preferredConn, ok := ap.conns[preferredConnID]
|
||||
if !ok || !preferredConn.matchesBetaFeatures(betaFeatures) {
|
||||
if !ok || !preferredConn.matchesHandshakeCompatibility(compatibility) {
|
||||
p.recordConnPickDuration(time.Since(pickStartedAt))
|
||||
ap.mu.Unlock()
|
||||
closeOpenAIWSConns(evicted)
|
||||
@@ -895,6 +913,7 @@ retryAcquire:
|
||||
reused: true,
|
||||
}
|
||||
p.metrics.acquireReuseTotal.Add(1)
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
@@ -942,12 +961,13 @@ retryAcquire:
|
||||
reused: true,
|
||||
}
|
||||
p.metrics.acquireReuseTotal.Add(1)
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
if preferredConnID != "" {
|
||||
if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesBetaFeatures(betaFeatures) && conn.tryAcquire() {
|
||||
if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesHandshakeCompatibility(compatibility) && conn.tryAcquire() {
|
||||
connPick := time.Since(pickStartedAt)
|
||||
p.recordConnPickDuration(connPick)
|
||||
ap.mu.Unlock()
|
||||
@@ -964,12 +984,16 @@ retryAcquire:
|
||||
}
|
||||
lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick, reused: true}
|
||||
p.metrics.acquireReuseTotal.Add(1)
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
}
|
||||
|
||||
best := p.pickLeastBusyConnLocked(ap, "", betaFeatures)
|
||||
// A routing hint is advisory at WebSocket dial time. Prefer a pooled
|
||||
// connection whose handshake used the same hint, but do not make that
|
||||
// preference a continuation compatibility requirement.
|
||||
best := p.pickLeastBusyConnWithRoutingAffinityLocked(ap, compatibility, routingAffinity)
|
||||
if best != nil && best.tryAcquire() {
|
||||
connPick := time.Since(pickStartedAt)
|
||||
p.recordConnPickDuration(connPick)
|
||||
@@ -987,11 +1011,12 @@ retryAcquire:
|
||||
}
|
||||
lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: best, connPick: connPick, reused: true}
|
||||
p.metrics.acquireReuseTotal.Add(1)
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
for _, conn := range ap.conns {
|
||||
if conn == nil || conn == best || !conn.matchesBetaFeatures(betaFeatures) {
|
||||
if conn == nil || conn == best || !conn.matchesHandshakeCompatibility(compatibility) || !conn.matchesRoutingAffinity(routingAffinity) {
|
||||
continue
|
||||
}
|
||||
if conn.tryAcquire() {
|
||||
@@ -1011,6 +1036,7 @@ retryAcquire:
|
||||
}
|
||||
lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick, reused: true}
|
||||
p.metrics.acquireReuseTotal.Add(1)
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
@@ -1018,12 +1044,18 @@ retryAcquire:
|
||||
}
|
||||
|
||||
if !req.ForceNewConn && len(ap.conns)+ap.creating >= effectiveMaxConns {
|
||||
compatible := p.pickLeastBusyConnLocked(ap, "", betaFeatures)
|
||||
if idle := p.pickOldestIdleConnWithDifferentBetaFeaturesLocked(ap, betaFeatures); idle != nil {
|
||||
affine := p.pickLeastBusyConnWithRoutingAffinityLocked(ap, compatibility, routingAffinity)
|
||||
if idle := p.pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked(ap, compatibility, routingAffinity); idle != nil {
|
||||
delete(ap.conns, idle.id)
|
||||
evicted = append(evicted, idle)
|
||||
p.metrics.scaleDownTotal.Add(1)
|
||||
} else if compatible == nil {
|
||||
} else if affine == nil {
|
||||
compatible := p.pickLeastBusyConnLocked(ap, "", compatibility)
|
||||
if compatible != nil {
|
||||
// Capacity is full and every compatible connection is busy. The
|
||||
// hint remains soft here: queue on a compatible connection below.
|
||||
goto acquireAtCapacity
|
||||
}
|
||||
hasConnection := false
|
||||
for _, conn := range ap.conns {
|
||||
if conn != nil {
|
||||
@@ -1068,6 +1100,17 @@ retryAcquire:
|
||||
ap = p.getOrCreateAccountPool(accountID)
|
||||
ap.mu.Lock()
|
||||
ap.creating--
|
||||
if ap.generation != acquireGeneration {
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
if conn != nil {
|
||||
conn.close()
|
||||
}
|
||||
if retry < 1 {
|
||||
return p.acquire(ctx, req, retry+1)
|
||||
}
|
||||
return nil, errOpenAIWSConnClosed
|
||||
}
|
||||
if dialErr != nil {
|
||||
ap.prewarmFails++
|
||||
ap.prewarmFailAt = time.Now()
|
||||
@@ -1075,20 +1118,26 @@ retryAcquire:
|
||||
ap.mu.Unlock()
|
||||
return nil, dialErr
|
||||
}
|
||||
// Claim the freshly dialed connection before publishing it. Otherwise a
|
||||
// topology waiter awakened below can take the free semaphore first and
|
||||
// make the caller that paid for the dial queue behind it.
|
||||
if !conn.tryAcquire() {
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
conn.close()
|
||||
return nil, errOpenAIWSConnClosed
|
||||
}
|
||||
ap.conns[conn.id] = conn
|
||||
ap.prewarmFails = 0
|
||||
ap.prewarmFailAt = time.Time{}
|
||||
// Wake acquires that observed creating>0 with no compatible connection.
|
||||
// Without this signal they can remain asleep until the new lease is
|
||||
// released, even though the pool topology already changed.
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
p.metrics.acquireCreateTotal.Add(1)
|
||||
|
||||
if !conn.tryAcquire() {
|
||||
if err := conn.acquire(ctx); err != nil {
|
||||
conn.close()
|
||||
p.evictConn(accountID, conn.id)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick}
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
@@ -1100,7 +1149,8 @@ retryAcquire:
|
||||
return nil, errOpenAIWSConnQueueFull
|
||||
}
|
||||
|
||||
target := p.pickLeastBusyConnLocked(ap, req.PreferredConnID, betaFeatures)
|
||||
acquireAtCapacity:
|
||||
target := p.pickLeastBusyConnLocked(ap, req.PreferredConnID, compatibility)
|
||||
connPick := time.Since(pickStartedAt)
|
||||
p.recordConnPickDuration(connPick)
|
||||
if target == nil {
|
||||
@@ -1142,6 +1192,7 @@ retryAcquire:
|
||||
p.metrics.acquireQueueWaitMs.Add(queueWait.Milliseconds())
|
||||
lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: target, queueWait: queueWait, connPick: connPick, reused: true}
|
||||
p.metrics.acquireReuseTotal.Add(1)
|
||||
p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req)
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
return lease, nil
|
||||
}
|
||||
@@ -1157,6 +1208,23 @@ func (p *openAIWSConnPool) recordConnPickDuration(duration time.Duration) {
|
||||
p.metrics.connPickMs.Add(duration.Milliseconds())
|
||||
}
|
||||
|
||||
func (p *openAIWSConnPool) recordLastSuccessfulAcquire(accountID int64, generation uint64, req openAIWSAcquireRequest) {
|
||||
if p == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
ap, ok := p.getAccountPool(accountID)
|
||||
if !ok || ap == nil {
|
||||
return
|
||||
}
|
||||
ap.mu.Lock()
|
||||
if ap.generation != generation {
|
||||
ap.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ap.lastAcquire = cloneOpenAIWSAcquireRequestPtr(&req)
|
||||
ap.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *openAIWSConn {
|
||||
if ap == nil || len(ap.conns) == 0 {
|
||||
return nil
|
||||
@@ -1173,13 +1241,19 @@ func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *op
|
||||
return oldest
|
||||
}
|
||||
|
||||
func (p *openAIWSConnPool) pickOldestIdleConnWithDifferentBetaFeaturesLocked(ap *openAIWSAccountPool, betaFeatures string) *openAIWSConn {
|
||||
func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked(
|
||||
ap *openAIWSAccountPool,
|
||||
compatibility openAIWSHandshakeCompatibilityKey,
|
||||
routingAffinity string,
|
||||
) *openAIWSConn {
|
||||
if ap == nil || len(ap.conns) == 0 {
|
||||
return nil
|
||||
}
|
||||
var oldest *openAIWSConn
|
||||
for _, conn := range ap.conns {
|
||||
if conn == nil || conn.matchesBetaFeatures(betaFeatures) || conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) {
|
||||
if conn == nil ||
|
||||
(conn.matchesHandshakeCompatibility(compatibility) && conn.matchesRoutingAffinity(routingAffinity)) ||
|
||||
conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) {
|
||||
continue
|
||||
}
|
||||
if oldest == nil || conn.lastUsedAt().Before(oldest.lastUsedAt()) {
|
||||
@@ -1330,13 +1404,17 @@ func (p *openAIWSConnPool) cleanupAccountLocked(ap *openAIWSAccountPool, now tim
|
||||
return evicted
|
||||
}
|
||||
|
||||
func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, preferredConnID, betaFeatures string) *openAIWSConn {
|
||||
func (p *openAIWSConnPool) pickLeastBusyConnLocked(
|
||||
ap *openAIWSAccountPool,
|
||||
preferredConnID string,
|
||||
compatibility openAIWSHandshakeCompatibilityKey,
|
||||
) *openAIWSConn {
|
||||
if ap == nil || len(ap.conns) == 0 {
|
||||
return nil
|
||||
}
|
||||
preferredConnID = stringsTrim(preferredConnID)
|
||||
if preferredConnID != "" {
|
||||
if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesBetaFeatures(betaFeatures) {
|
||||
if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesHandshakeCompatibility(compatibility) {
|
||||
return conn
|
||||
}
|
||||
}
|
||||
@@ -1344,7 +1422,37 @@ func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, pref
|
||||
var bestWaiters int32
|
||||
var bestLastUsed time.Time
|
||||
for _, conn := range ap.conns {
|
||||
if conn == nil || !conn.matchesBetaFeatures(betaFeatures) {
|
||||
if conn == nil || !conn.matchesHandshakeCompatibility(compatibility) {
|
||||
continue
|
||||
}
|
||||
waiters := conn.waiters.Load()
|
||||
lastUsed := conn.lastUsedAt()
|
||||
if best == nil ||
|
||||
waiters < bestWaiters ||
|
||||
(waiters == bestWaiters && lastUsed.Before(bestLastUsed)) {
|
||||
best = conn
|
||||
bestWaiters = waiters
|
||||
bestLastUsed = lastUsed
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (p *openAIWSConnPool) pickLeastBusyConnWithRoutingAffinityLocked(
|
||||
ap *openAIWSAccountPool,
|
||||
compatibility openAIWSHandshakeCompatibilityKey,
|
||||
routingAffinity string,
|
||||
) *openAIWSConn {
|
||||
if ap == nil || len(ap.conns) == 0 {
|
||||
return nil
|
||||
}
|
||||
var best *openAIWSConn
|
||||
var bestWaiters int32
|
||||
var bestLastUsed time.Time
|
||||
for _, conn := range ap.conns {
|
||||
if conn == nil ||
|
||||
!conn.matchesHandshakeCompatibility(compatibility) ||
|
||||
!conn.matchesRoutingAffinity(routingAffinity) {
|
||||
continue
|
||||
}
|
||||
waiters := conn.waiters.Load()
|
||||
@@ -1488,12 +1596,20 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ
|
||||
if len(generations) > 0 {
|
||||
generation = generations[0]
|
||||
}
|
||||
staleTarget := false
|
||||
defer func() {
|
||||
if ap, ok := p.getAccountPool(accountID); ok && ap != nil {
|
||||
ap.mu.Lock()
|
||||
ap.prewarmActive = false
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
}
|
||||
if staleTarget {
|
||||
// A newer acquire arrived while the old dial was in flight. Re-run
|
||||
// target selection only after clearing prewarmActive so the latest
|
||||
// beta/hint target can fill the idle budget.
|
||||
p.ensureTargetIdleAsync(accountID)
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
@@ -1524,6 +1640,13 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ
|
||||
conn.close()
|
||||
continue
|
||||
}
|
||||
if !sameOpenAIWSPrewarmTarget(req, *ap.lastAcquire) {
|
||||
staleTarget = true
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
conn.close()
|
||||
continue
|
||||
}
|
||||
if len(ap.conns) >= p.effectiveMaxConnsByAccount(req.Account) {
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
@@ -1563,6 +1686,7 @@ func (p *openAIWSConnPool) ClearAccount(accountID int64) {
|
||||
ap.prewarmUntil = time.Time{}
|
||||
ap.prewarmFails = 0
|
||||
ap.prewarmFailAt = time.Time{}
|
||||
ap.signalChangedLocked()
|
||||
ap.mu.Unlock()
|
||||
closeOpenAIWSConns(conns)
|
||||
}
|
||||
@@ -1676,7 +1800,8 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ
|
||||
}
|
||||
id := p.nextConnID(req.Account.ID)
|
||||
pooledConn := newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders)
|
||||
pooledConn.betaFeatures = normalizeOpenAIWSBetaFeatures(req.Headers)
|
||||
pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Headers)
|
||||
pooledConn.routingAffinity = normalizeOpenAIWSRoutingAffinity(req.Headers)
|
||||
return pooledConn, nil
|
||||
}
|
||||
|
||||
@@ -1855,6 +1980,12 @@ func cloneOpenAIWSAcquireRequestPtr(req *openAIWSAcquireRequest) *openAIWSAcquir
|
||||
return &copied
|
||||
}
|
||||
|
||||
func sameOpenAIWSPrewarmTarget(a, b openAIWSAcquireRequest) bool {
|
||||
return stringsTrim(a.WSURL) == stringsTrim(b.WSURL) &&
|
||||
stringsTrim(a.ProxyURL) == stringsTrim(b.ProxyURL) &&
|
||||
normalizeOpenAIWSHandshakeCompatibility(a.Headers) == normalizeOpenAIWSHandshakeCompatibility(b.Headers)
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSBetaFeatures(headers http.Header) string {
|
||||
features := make(map[string]struct{})
|
||||
for name, values := range headers {
|
||||
@@ -1880,6 +2011,39 @@ func normalizeOpenAIWSBetaFeatures(headers http.Header) string {
|
||||
return strings.Join(normalized, ",")
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSHandshakeCompatibility(headers http.Header) openAIWSHandshakeCompatibilityKey {
|
||||
return openAIWSHandshakeCompatibilityKey{
|
||||
betaFeatures: normalizeOpenAIWSBetaFeatures(headers),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSRoutingAffinity(headers http.Header) string {
|
||||
canonicalName := http.CanonicalHeaderKey(openAICodexRoutingHintHeader)
|
||||
if values, ok := headers[canonicalName]; ok {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variantNames := make([]string, 0)
|
||||
for name := range headers {
|
||||
if name != canonicalName && strings.EqualFold(strings.TrimSpace(name), openAICodexRoutingHintHeader) {
|
||||
variantNames = append(variantNames, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(variantNames)
|
||||
for _, name := range variantNames {
|
||||
for _, value := range headers[name] {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneHeader(src http.Header) http.Header {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -61,6 +61,18 @@ func TestOpenAIWSConnPool_AcquireCleanupInterval(t *testing.T) {
|
||||
require.Less(t, openAIWSAcquireCleanupInterval, openAIWSBackgroundSweepTicker)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIWSRoutingAffinityPrefersCanonicalAndSortsVariants(t *testing.T) {
|
||||
headers := http.Header{
|
||||
"X-CODEX-ROUTING-HINT": []string{" variant-uppercase "},
|
||||
"X-Codex-Routing-Hint": []string{" ", " canonical "},
|
||||
}
|
||||
|
||||
require.Equal(t, "canonical", normalizeOpenAIWSRoutingAffinity(headers))
|
||||
|
||||
delete(headers, "X-Codex-Routing-Hint")
|
||||
require.Equal(t, "variant-uppercase", normalizeOpenAIWSRoutingAffinity(headers))
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnLease_WriteJSONAndGuards(t *testing.T) {
|
||||
conn := newOpenAIWSConn("lease_write", 1, &openAIWSFakeConn{}, nil)
|
||||
lease := &openAIWSConnLease{conn: conn}
|
||||
@@ -310,6 +322,219 @@ func TestOpenAIWSConnPool_AcquireQueueWaitMetrics(t *testing.T) {
|
||||
require.GreaterOrEqual(t, metrics.ConnPickTotal, int64(1))
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnPool_DialSuccessWakesTopologyWaiterAndCanceledWaiterDoesNotLoseLease(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.QueueLimitPerConn = 4
|
||||
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
dialer := newOpenAIWSFirstDialBlockingCaptureDialer()
|
||||
pool.setClientDialerForTest(dialer)
|
||||
account := &Account{ID: 991, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
req := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"}
|
||||
|
||||
type result struct {
|
||||
lease *openAIWSConnLease
|
||||
err error
|
||||
}
|
||||
firstCh := make(chan result, 1)
|
||||
go func() {
|
||||
lease, err := pool.Acquire(context.Background(), req)
|
||||
firstCh <- result{lease: lease, err: err}
|
||||
}()
|
||||
<-dialer.firstStarted
|
||||
|
||||
waitCtx, cancelWait := context.WithCancel(context.Background())
|
||||
secondCh := make(chan result, 1)
|
||||
waitReq := req
|
||||
waitReq.Headers = http.Header{openAICodexRoutingHintHeader: {"model=gpt-5.6-codex;tier=priority"}}
|
||||
go func() {
|
||||
lease, err := pool.Acquire(waitCtx, waitReq)
|
||||
secondCh <- result{lease: lease, err: err}
|
||||
}()
|
||||
|
||||
close(dialer.releaseFirst)
|
||||
first := <-firstCh
|
||||
require.NoError(t, first.err)
|
||||
require.NotNil(t, first.lease)
|
||||
|
||||
// The second acquire initially waits on the account topology channel while
|
||||
// the first dial is in flight. Dial success must wake it immediately so it
|
||||
// can queue on the newly-created (still leased) connection.
|
||||
require.Eventually(t, func() bool {
|
||||
ap, ok := pool.getAccountPool(account.ID)
|
||||
if !ok || ap == nil {
|
||||
return false
|
||||
}
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
for _, conn := range ap.conns {
|
||||
if conn != nil && conn.waiters.Load() == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
cancelWait()
|
||||
second := <-secondCh
|
||||
require.ErrorIs(t, second.err, context.Canceled)
|
||||
require.Nil(t, second.lease)
|
||||
ap, ok := pool.getAccountPool(account.ID)
|
||||
require.True(t, ok)
|
||||
ap.mu.Lock()
|
||||
require.NotNil(t, ap.lastAcquire)
|
||||
require.Empty(t, normalizeOpenAIWSRoutingAffinity(ap.lastAcquire.Headers), "a canceled acquire must not replace the successful prewarm target")
|
||||
ap.mu.Unlock()
|
||||
first.lease.Release()
|
||||
|
||||
third, err := pool.Acquire(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, third.Reused(), "a canceled waiter must not consume the released semaphore token")
|
||||
require.Equal(t, first.lease.ConnID(), third.ConnID())
|
||||
third.Release()
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnPool_PrewarmHintChangeDoesNotInvalidateHealthyDial(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 2
|
||||
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
dialer := newOpenAIWSFirstDialBlockingCaptureDialer()
|
||||
pool.setClientDialerForTest(dialer)
|
||||
account := &Account{ID: 992, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
oldHeaders := make(http.Header)
|
||||
oldHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex")
|
||||
newHeaders := make(http.Header)
|
||||
newHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex;tier=priority")
|
||||
ap := pool.getOrCreateAccountPool(account.ID)
|
||||
ap.mu.Lock()
|
||||
ap.lastAcquire = &openAIWSAcquireRequest{
|
||||
Account: account,
|
||||
WSURL: "wss://example.com/v1/responses",
|
||||
Headers: oldHeaders,
|
||||
}
|
||||
ap.mu.Unlock()
|
||||
|
||||
pool.ensureTargetIdleAsync(account.ID)
|
||||
<-dialer.firstStarted
|
||||
|
||||
// Simulate a newer priority target arriving while the old model-only
|
||||
// prewarm dial is in flight. Routing hints are advisory, so this alone must
|
||||
// not discard an otherwise compatible connection.
|
||||
ap.mu.Lock()
|
||||
ap.lastAcquire = &openAIWSAcquireRequest{
|
||||
Account: account,
|
||||
WSURL: "wss://example.com/v1/responses",
|
||||
Headers: newHeaders,
|
||||
}
|
||||
ap.mu.Unlock()
|
||||
close(dialer.releaseFirst)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
if ap.prewarmActive || len(ap.conns) != 1 {
|
||||
return false
|
||||
}
|
||||
for _, conn := range ap.conns {
|
||||
return conn != nil && conn.routingAffinity == "model=gpt-5.6-codex"
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
require.Equal(t, 1, dialer.DialCount(), "routing-hint-only changes must not turn advisory metadata into hard reconnects")
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnPool_ClearAccountWakesIncompatibleTopologyWaiter(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
dialer := &openAIWSCountingDialer{}
|
||||
pool.setClientDialerForTest(dialer)
|
||||
account := &Account{ID: 993, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
baseReq := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"}
|
||||
betaAReq := baseReq
|
||||
betaAReq.Headers = http.Header{"X-Codex-Beta-Features": {"feature_a"}}
|
||||
betaBReq := baseReq
|
||||
betaBReq.Headers = http.Header{"X-Codex-Beta-Features": {"feature_b"}}
|
||||
|
||||
busy, err := pool.Acquire(context.Background(), betaAReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, dialer.DialCount())
|
||||
|
||||
type result struct {
|
||||
lease *openAIWSConnLease
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
waitCtx, cancelWait := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelWait()
|
||||
go func() {
|
||||
lease, acquireErr := pool.Acquire(waitCtx, betaBReq)
|
||||
resultCh <- result{lease: lease, err: acquireErr}
|
||||
}()
|
||||
|
||||
require.Never(t, func() bool { return dialer.DialCount() > 1 }, 50*time.Millisecond, 5*time.Millisecond)
|
||||
pool.ClearAccount(account.ID)
|
||||
|
||||
resultB := <-resultCh
|
||||
require.NoError(t, resultB.err)
|
||||
require.NotNil(t, resultB.lease)
|
||||
require.False(t, resultB.lease.Reused())
|
||||
require.Equal(t, 2, dialer.DialCount(), "ClearAccount must wake the waiter to redial immediately")
|
||||
resultB.lease.Release()
|
||||
busy.Release()
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnPool_ClearAccountDoesNotReviveInFlightDialGeneration(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
dialer := newOpenAIWSFirstDialBlockingCaptureDialer()
|
||||
pool.setClientDialerForTest(dialer)
|
||||
account := &Account{ID: 994, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
req := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"}
|
||||
|
||||
type result struct {
|
||||
lease *openAIWSConnLease
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
lease, err := pool.Acquire(context.Background(), req)
|
||||
resultCh <- result{lease: lease, err: err}
|
||||
}()
|
||||
<-dialer.firstStarted
|
||||
|
||||
pool.ClearAccount(account.ID)
|
||||
close(dialer.releaseFirst)
|
||||
got := <-resultCh
|
||||
require.NoError(t, got.err)
|
||||
require.NotNil(t, got.lease)
|
||||
require.Equal(t, 2, dialer.DialCount(), "the pre-clear dial must be discarded and retried in the new generation")
|
||||
require.True(t, strings.HasSuffix(got.lease.ConnID(), "_2"))
|
||||
|
||||
ap, ok := pool.getAccountPool(account.ID)
|
||||
require.True(t, ok)
|
||||
ap.mu.Lock()
|
||||
require.Equal(t, uint64(1), ap.generation)
|
||||
require.Len(t, ap.conns, 1)
|
||||
require.NotNil(t, ap.lastAcquire, "only the post-clear successful acquire may restore the prewarm target")
|
||||
ap.mu.Unlock()
|
||||
got.lease.Release()
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnPool_ForceNewConnSkipsReuse(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2
|
||||
@@ -1468,6 +1693,21 @@ func TestOpenAIWSConn_AdditionalGuardBranches(t *testing.T) {
|
||||
closeOpenAIWSConns([]*openAIWSConn{nil, connOK})
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnPool_CanceledWaiterReturnsDeliveredLease(t *testing.T) {
|
||||
conn := newOpenAIWSConn("cancelled_delivery", 1, &openAIWSFakeConn{}, nil)
|
||||
|
||||
// Both branches of acquire's select are ready. Before the post-delivery
|
||||
// cancellation check this intermittently returned nil after consuming the
|
||||
// only lease token, which made the next pool acquire block forever.
|
||||
for range 64 {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.ErrorIs(t, conn.acquire(ctx), context.Canceled)
|
||||
require.True(t, conn.tryAcquire(), "a canceled waiter must return a delivered lease token")
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIWSConnLease_MarkBrokenEvictsConn(t *testing.T) {
|
||||
pool := newOpenAIWSConnPool(&config.Config{})
|
||||
accountID := int64(5001)
|
||||
@@ -1602,6 +1842,21 @@ type openAIWSCountingDialer struct {
|
||||
dialCount int
|
||||
}
|
||||
|
||||
type openAIWSFirstDialBlockingCaptureDialer struct {
|
||||
mu sync.Mutex
|
||||
dialCount int
|
||||
headers []http.Header
|
||||
firstStarted chan struct{}
|
||||
releaseFirst chan struct{}
|
||||
}
|
||||
|
||||
func newOpenAIWSFirstDialBlockingCaptureDialer() *openAIWSFirstDialBlockingCaptureDialer {
|
||||
return &openAIWSFirstDialBlockingCaptureDialer{
|
||||
firstStarted: make(chan struct{}),
|
||||
releaseFirst: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
type openAIWSAlwaysFailDialer struct {
|
||||
mu sync.Mutex
|
||||
dialCount int
|
||||
@@ -1671,6 +1926,36 @@ func (d *openAIWSCountingDialer) Dial(
|
||||
return &openAIWSFakeConn{}, 0, nil, nil
|
||||
}
|
||||
|
||||
func (d *openAIWSFirstDialBlockingCaptureDialer) Dial(
|
||||
ctx context.Context,
|
||||
wsURL string,
|
||||
headers http.Header,
|
||||
proxyURL string,
|
||||
) (openAIWSClientConn, int, http.Header, error) {
|
||||
_ = wsURL
|
||||
_ = proxyURL
|
||||
d.mu.Lock()
|
||||
d.dialCount++
|
||||
dialNumber := d.dialCount
|
||||
d.headers = append(d.headers, cloneHeader(headers))
|
||||
d.mu.Unlock()
|
||||
if dialNumber == 1 {
|
||||
close(d.firstStarted)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, 0, nil, ctx.Err()
|
||||
case <-d.releaseFirst:
|
||||
}
|
||||
}
|
||||
return &openAIWSFakeConn{}, 0, nil, nil
|
||||
}
|
||||
|
||||
func (d *openAIWSFirstDialBlockingCaptureDialer) DialCount() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.dialCount
|
||||
}
|
||||
|
||||
func (d *openAIWSCountingDialer) DialCount() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
@@ -800,7 +800,19 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader))
|
||||
turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader))
|
||||
}
|
||||
headers, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, turnMetadata, promptCacheKey)
|
||||
headers, _, buildHdrErr := s.buildOpenAIWSHeaders(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
token,
|
||||
wsDecision,
|
||||
isCodexCLI,
|
||||
turnState,
|
||||
turnMetadata,
|
||||
promptCacheKey,
|
||||
gjson.GetBytes(firstClientMessage, "model").String(),
|
||||
gjson.GetBytes(firstClientMessage, "service_tier").String(),
|
||||
)
|
||||
if buildHdrErr != nil {
|
||||
return fmt.Errorf("build ws headers: %w", buildHdrErr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user