diff --git a/admin/handler.go b/admin/handler.go
index 5a53f69c0..74138f80d 100644
--- a/admin/handler.go
+++ b/admin/handler.go
@@ -9262,6 +9262,7 @@ type settingsResponse struct {
CodexWSBusyOverflowEnabled bool `json:"codex_ws_busy_overflow_enabled"`
CodexWSBusyPatienceSec int `json:"codex_ws_busy_patience_sec"`
CodexWSStatelessSlots int `json:"codex_ws_stateless_slots"`
+ CodexWSDownstreamKeepaliveSlots int `json:"codex_ws_downstream_keepalive_slots"`
GithubTokenConfigured bool `json:"github_token_configured"`
GithubProxyURL string `json:"github_proxy_url"`
CodexOverloadPauseEnabled bool `json:"codex_overload_pause_enabled"`
@@ -9457,6 +9458,7 @@ type updateSettingsReq struct {
CodexWSBusyOverflowEnabled *bool `json:"codex_ws_busy_overflow_enabled"`
CodexWSBusyPatienceSec *int `json:"codex_ws_busy_patience_sec"`
CodexWSStatelessSlots *int `json:"codex_ws_stateless_slots"`
+ CodexWSDownstreamKeepaliveSlots *int `json:"codex_ws_downstream_keepalive_slots"`
GithubToken *string `json:"github_token"`
GithubProxyURL *string `json:"github_proxy_url"`
CodexOverloadPauseEnabled *bool `json:"codex_overload_pause_enabled"`
@@ -10294,6 +10296,7 @@ func (h *Handler) GetSettings(c *gin.Context) {
CodexWSBusyOverflowEnabled: h.store.CodexWSBusyOverflowEnabled(),
CodexWSBusyPatienceSec: h.store.CodexWSBusyPatienceSec(),
CodexWSStatelessSlots: h.store.CodexWSStatelessSlots(),
+ CodexWSDownstreamKeepaliveSlots: h.store.CodexWSDownstreamKeepaliveSlots(),
GithubTokenConfigured: h.store.GithubToken() != "",
GithubProxyURL: h.store.GithubProxyURL(),
CodexOverloadPauseEnabled: runtimeCfg.CodexOverloadPauseEnabled,
@@ -11101,6 +11104,13 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
log.Printf("设置已更新: codex_ws_stateless_slots = %d", v)
}
+ if req.CodexWSDownstreamKeepaliveSlots != nil {
+ v := database.NormalizeCodexWSDownstreamKeepaliveSlots(*req.CodexWSDownstreamKeepaliveSlots)
+ h.store.SetCodexWSDownstreamKeepaliveSlots(v)
+ runtimeCfg.CodexWSDownstreamKeepaliveSlots = v
+ log.Printf("设置已更新: codex_ws_downstream_keepalive_slots = %d", v)
+ }
+
// GitHub 访问设置(issue #522)。token 不回显也不落日志,空串表示清除。
if req.GithubToken != nil {
v := strings.TrimSpace(*req.GithubToken)
@@ -11840,6 +11850,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
CodexWSBusyOverflowEnabled: h.store.CodexWSBusyOverflowEnabled(),
CodexWSBusyPatienceSec: h.store.CodexWSBusyPatienceSec(),
CodexWSStatelessSlots: h.store.CodexWSStatelessSlots(),
+ CodexWSDownstreamKeepaliveSlots: h.store.CodexWSDownstreamKeepaliveSlots(),
GithubToken: h.store.GithubToken(),
GithubProxyURL: h.store.GithubProxyURL(),
CodexOverloadPauseEnabled: runtimeCfg.CodexOverloadPauseEnabled,
@@ -12177,6 +12188,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
CodexWSBusyOverflowEnabled: h.store.CodexWSBusyOverflowEnabled(),
CodexWSBusyPatienceSec: h.store.CodexWSBusyPatienceSec(),
CodexWSStatelessSlots: h.store.CodexWSStatelessSlots(),
+ CodexWSDownstreamKeepaliveSlots: h.store.CodexWSDownstreamKeepaliveSlots(),
GithubTokenConfigured: h.store.GithubToken() != "",
GithubProxyURL: h.store.GithubProxyURL(),
CodexOverloadPauseEnabled: runtimeCfg.CodexOverloadPauseEnabled,
diff --git a/auth/codex_ws_settings.go b/auth/codex_ws_settings.go
new file mode 100644
index 000000000..a66a5af0a
--- /dev/null
+++ b/auth/codex_ws_settings.go
@@ -0,0 +1,18 @@
+package auth
+
+import "github.com/codex2api/database"
+
+// SetCodexWSDownstreamKeepaliveSlots 设置每账号独立的空闲聊天连接保留上限。
+func (s *Store) SetCodexWSDownstreamKeepaliveSlots(slots int) {
+ if s != nil {
+ s.codexWSDownstreamKeepaliveSlots.Store(int64(database.NormalizeCodexWSDownstreamKeepaliveSlots(slots)))
+ }
+}
+
+// CodexWSDownstreamKeepaliveSlots 返回每账号独立的空闲聊天连接保留上限。
+func (s *Store) CodexWSDownstreamKeepaliveSlots() int {
+ if s == nil {
+ return database.DefaultCodexWSDownstreamKeepaliveSlots
+ }
+ return database.NormalizeCodexWSDownstreamKeepaliveSlots(int(s.codexWSDownstreamKeepaliveSlots.Load()))
+}
diff --git a/auth/store.go b/auth/store.go
index 6507b9df0..58c950c89 100644
--- a/auth/store.go
+++ b/auth/store.go
@@ -3611,20 +3611,21 @@ type Store struct {
codexForceWebsocket atomic.Bool // 强制 Codex 上游走 WebSocket(复用连接池)
// codexRequestCompression HTTP /responses 请求体 zstd 压缩,默认开启(对齐真实客户端)。
// 与上面几项 WS 设置正交:WS 走 permessage-deflate,本项只作用于 HTTP 路径。
- codexRequestCompression atomic.Bool
- codexWSKeepaliveEnabled atomic.Bool // 启用上游 WS 空闲连接保活(仅 Ping)
- codexWSKeepaliveIntervalSec atomic.Int64 // WS 保活 Ping 间隔(秒),默认 60
- codexWSHideUpstreamErrors atomic.Bool // 隐藏上游 WS 原始错误,默认开启
- codexWSSilentRetryEnabled atomic.Bool // 首包前上游 WS 错误静默换号重试,默认开启
- codexWSSilentMaxRetries atomic.Int64 // WS 静默换号最大重试次数,默认 2
- codexWSSizeRouterEnabled atomic.Bool // 1009 自学习体积路由,默认开启
- codexWSBusyMaxWaitSec atomic.Int64 // busy session 等待上限(秒),默认 30(issue #413)
- codexWSBusyOverflowEnabled atomic.Bool // busy session 溢出到同账号兄弟连接,默认关闭
- codexWSBusyPatienceSec atomic.Int64 // 触发溢出前的短等待(秒),默认 2
- codexWSStatelessSlots atomic.Int64 // 无状态请求每 (账号, cacheKey) 的连接槽位数,默认 8(issue #522)
- overflowAutoCompactEnabled atomic.Bool // 上下文超窗自动摘要重试(实验性,默认关闭,issue #415)
- compactViaResponsesEnabled atomic.Bool // /v1/responses/compact 改写为 /responses body-signal 压缩,默认关闭
- firstTokenExcludesWsAcquire atomic.Bool // 落库 first_token_ms 扣除 WS 取连耗时,默认关闭
+ codexRequestCompression atomic.Bool
+ codexWSKeepaliveEnabled atomic.Bool // 启用上游 WS 空闲连接保活(仅 Ping)
+ codexWSKeepaliveIntervalSec atomic.Int64 // WS 保活 Ping 间隔(秒),默认 60
+ codexWSHideUpstreamErrors atomic.Bool // 隐藏上游 WS 原始错误,默认开启
+ codexWSSilentRetryEnabled atomic.Bool // 首包前上游 WS 错误静默换号重试,默认开启
+ codexWSSilentMaxRetries atomic.Int64 // WS 静默换号最大重试次数,默认 2
+ codexWSSizeRouterEnabled atomic.Bool // 1009 自学习体积路由,默认开启
+ codexWSBusyMaxWaitSec atomic.Int64 // busy session 等待上限(秒),默认 30(issue #413)
+ codexWSBusyOverflowEnabled atomic.Bool // busy session 溢出到同账号兄弟连接,默认关闭
+ codexWSBusyPatienceSec atomic.Int64 // 触发溢出前的短等待(秒),默认 2
+ codexWSStatelessSlots atomic.Int64 // 每账号空白上游 WS 槽位预算,默认 8
+ codexWSDownstreamKeepaliveSlots atomic.Int64
+ overflowAutoCompactEnabled atomic.Bool // 上下文超窗自动摘要重试(实验性,默认关闭,issue #415)
+ compactViaResponsesEnabled atomic.Bool // /v1/responses/compact 改写为 /responses body-signal 压缩,默认关闭
+ firstTokenExcludesWsAcquire atomic.Bool // 落库 first_token_ms 扣除 WS 取连耗时,默认关闭
// 前置元数据 SSE 事件立即透传下游(旧版兼容,默认关闭,issue #425)
codexPreflightSSEPassthroughEnabled atomic.Bool
@@ -4212,6 +4213,7 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet
CodexWSBusyAcquireMaxWaitSec: 30,
CodexWSBusyPatienceSec: 2,
CodexWSStatelessSlots: 8,
+ CodexWSDownstreamKeepaliveSlots: database.DefaultCodexWSDownstreamKeepaliveSlots,
CodexContinueMaxRounds: 8,
AutoPause5hGuardBandPercent: defaultAutoPause5hGuardBandPercent,
AutoPause5hGuardConcurrency: defaultAutoPause5hGuardConcurrency,
@@ -4332,6 +4334,7 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet
s.codexWSBusyOverflowEnabled.Store(settings.CodexWSBusyOverflowEnabled)
s.codexWSBusyPatienceSec.Store(int64(database.NormalizeCodexWSBusyPatienceSec(settings.CodexWSBusyPatienceSec)))
s.codexWSStatelessSlots.Store(int64(database.NormalizeCodexWSStatelessSlots(settings.CodexWSStatelessSlots)))
+ s.SetCodexWSDownstreamKeepaliveSlots(settings.CodexWSDownstreamKeepaliveSlots)
s.overflowAutoCompactEnabled.Store(settings.OverflowAutoCompactEnabled)
s.compactViaResponsesEnabled.Store(settings.CompactViaResponsesEnabled)
s.codexPreflightSSEPassthroughEnabled.Store(settings.CodexPreflightSSEPassthroughEnabled)
@@ -4780,7 +4783,7 @@ func (s *Store) GithubProxyURL() string {
return ""
}
-// SetCodexWSStatelessSlots 设置无状态请求每 (账号, cacheKey) 的连接槽位数。
+// SetCodexWSStatelessSlots 设置每个账号的空白上游 WS 连接槽位预算。
func (s *Store) SetCodexWSStatelessSlots(slots int) {
if s == nil {
return
@@ -4788,7 +4791,7 @@ func (s *Store) SetCodexWSStatelessSlots(slots int) {
s.codexWSStatelessSlots.Store(int64(database.NormalizeCodexWSStatelessSlots(slots)))
}
-// CodexWSStatelessSlots 返回无状态请求每 (账号, cacheKey) 的连接槽位数。
+// CodexWSStatelessSlots 返回每个账号的空白上游 WS 连接槽位预算。
func (s *Store) CodexWSStatelessSlots() int {
if s == nil {
return 8
diff --git a/cache/auth_invalidation.go b/cache/auth_invalidation.go
index 2b38a31ac..a04129695 100644
--- a/cache/auth_invalidation.go
+++ b/cache/auth_invalidation.go
@@ -19,6 +19,9 @@ type BoundedRuntimeReader interface {
GetRuntimeBounded(context.Context, string, string, int64) ([]byte, bool, error)
}
+// ErrRuntimeSnapshotTooLarge 表示后端值在反序列化前超过读取上限。
+var ErrRuntimeSnapshotTooLarge = errors.New("runtime snapshot exceeds byte limit")
+
type AuthSnapshotWriter interface {
SetAuthSnapshot(context.Context, string, string, json.RawMessage, time.Duration) error
}
@@ -73,7 +76,7 @@ func (tc *redisTokenCache) GetRuntimeBounded(ctx context.Context, namespace, key
return nil, false, err
}
if int64(len(raw)) > maxBytes {
- return nil, false, errors.New("runtime snapshot exceeds byte limit")
+ return nil, false, ErrRuntimeSnapshotTooLarge
}
return raw, len(raw) > 0, nil
}
diff --git a/database/codex_ws_settings.go b/database/codex_ws_settings.go
new file mode 100644
index 000000000..2eb07b395
--- /dev/null
+++ b/database/codex_ws_settings.go
@@ -0,0 +1,17 @@
+package database
+
+const (
+ DefaultCodexWSDownstreamKeepaliveSlots = 8
+ MaxCodexWSDownstreamKeepaliveSlots = 32
+)
+
+// NormalizeCodexWSDownstreamKeepaliveSlots 归一化空闲聊天连接保留上限,0 表示不保留。
+func NormalizeCodexWSDownstreamKeepaliveSlots(slots int) int {
+ if slots < 0 {
+ return DefaultCodexWSDownstreamKeepaliveSlots
+ }
+ if slots > MaxCodexWSDownstreamKeepaliveSlots {
+ return MaxCodexWSDownstreamKeepaliveSlots
+ }
+ return slots
+}
diff --git a/database/postgres.go b/database/postgres.go
index 20456988a..55cc90589 100644
--- a/database/postgres.go
+++ b/database/postgres.go
@@ -200,9 +200,10 @@ type sqlExecer interface {
// DB PostgreSQL 数据库操作
type DB struct {
- conn *sql.DB
- driver string
- authCacheScope string
+ conn *sql.DB
+ driver string
+ authCacheScope string
+ runtimeCacheScope string
promptFilterAudit *promptFilterAuditQueue
@@ -513,6 +514,11 @@ func New(driver string, dsn string, schema ...string) (*DB, error) {
_ = conn.Close()
return nil, fmt.Errorf("初始化鉴权缓存修订表失败: %w", err)
}
+ if err := db.initializeRuntimeCacheScope(ctx); err != nil {
+ backgroundTaskCancel()
+ _ = conn.Close()
+ return nil, fmt.Errorf("初始化共享运行态作用域失败: %w", err)
+ }
// 启动批量写入后台协程
db.startLogFlusher()
@@ -1505,6 +1511,7 @@ func (db *DB) migrate(ctx context.Context) error {
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS codex_ws_busy_overflow_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS codex_ws_busy_patience_sec INT DEFAULT 2;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS codex_ws_stateless_slots INT DEFAULT 8;
+ ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS codex_ws_downstream_keepalive_slots INT DEFAULT 8;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS github_token TEXT DEFAULT '';
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS github_proxy_url TEXT DEFAULT '';
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS codex_overload_pause_enabled BOOLEAN DEFAULT FALSE;
@@ -2437,23 +2444,24 @@ type SystemSettings struct {
ShowFullUsageNumbers bool
// ShowUpstreamModelMismatch 控制用量页是否标出上游响应模型与请求模型不一致。
// 只影响展示和筛选,不停止记录。默认开启。
- ShowUpstreamModelMismatch bool
- PublicKeyUsagePageEnabled bool
- PublicImageStudioPageEnabled bool
- PublicAccountPortalPageEnabled bool // 账号自助添加公开门户开关,默认 false
- CodexForceWebsocket bool // 强制 Codex 上游走 WebSocket(复用连接池),默认 false
- CodexRequestCompression bool // HTTP /responses 请求体 zstd 压缩(对齐真实客户端),默认 true
- CodexWSWeakNetworkMode bool // WS 弱网保守复用模式,默认 false
- CodexWSKeepaliveEnabled bool // 启用上游 WS 空闲连接保活(仅 Ping,不发业务帧),默认 false
- CodexWSKeepaliveIntervalSec int // WS 保活 Ping 间隔(秒),默认 60
- CodexWSHideUpstreamErrors bool // 隐藏上游 WS 原始错误,默认 true
- CodexWSSilentRetryEnabled bool // 首包前 WS 上游错误静默换号重试,默认 true
- CodexWSSilentMaxRetries int // WS 静默换号最大重试次数,默认 2
- CodexWSSizeRouterEnabled bool // 1009 自学习体积路由:超大请求直接首发 HTTP,默认 true
- CodexWSBusyAcquireMaxWaitSec int // busy session/容量等待的累计上限(秒),默认 30(issue #413)
- CodexWSBusyOverflowEnabled bool // busy session 溢出到同账号兄弟连接,默认 false(issue #413)
- CodexWSBusyPatienceSec int // 触发溢出前的短等待(秒),默认 2(issue #413)
- CodexWSStatelessSlots int // 无状态请求每 (账号, cacheKey) 的持久连接槽位数,默认 8,范围 1-32(issue #522)
+ ShowUpstreamModelMismatch bool
+ PublicKeyUsagePageEnabled bool
+ PublicImageStudioPageEnabled bool
+ PublicAccountPortalPageEnabled bool // 账号自助添加公开门户开关,默认 false
+ CodexForceWebsocket bool // 强制 Codex 上游走 WebSocket(复用连接池),默认 false
+ CodexRequestCompression bool // HTTP /responses 请求体 zstd 压缩(对齐真实客户端),默认 true
+ CodexWSWeakNetworkMode bool // WS 弱网保守复用模式,默认 false
+ CodexWSKeepaliveEnabled bool // 启用上游 WS 空闲连接保活(仅 Ping,不发业务帧),默认 false
+ CodexWSKeepaliveIntervalSec int // WS 保活 Ping 间隔(秒),默认 60
+ CodexWSHideUpstreamErrors bool // 隐藏上游 WS 原始错误,默认 true
+ CodexWSSilentRetryEnabled bool // 首包前 WS 上游错误静默换号重试,默认 true
+ CodexWSSilentMaxRetries int // WS 静默换号最大重试次数,默认 2
+ CodexWSSizeRouterEnabled bool // 1009 自学习体积路由:超大请求直接首发 HTTP,默认 true
+ CodexWSBusyAcquireMaxWaitSec int // busy session/容量等待的累计上限(秒),默认 30(issue #413)
+ CodexWSBusyOverflowEnabled bool // busy session 溢出到同账号兄弟连接,默认 false(issue #413)
+ CodexWSBusyPatienceSec int // 触发溢出前的短等待(秒),默认 2(issue #413)
+ CodexWSStatelessSlots int // 每账号空白上游 WS 槽位预算,默认 8,范围 0-32
+ CodexWSDownstreamKeepaliveSlots int // 每账号空闲聊天续链连接的独立保留上限,默认 8,范围 0-32
// GithubToken 用于 api.github.com 请求的 Personal Access Token(提升限流配额,
// 只发给 api.github.com,绝不发给镜像/其他主机;空表示未配置,issue #522)。
GithubToken string
@@ -2741,7 +2749,8 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) {
COALESCE(codex_oauth_keepalive_enabled, false),
COALESCE(codex_telemetry_timing_debug, false),
COALESCE(auto_reset_credits_on_exhaustion_enabled, false),
- COALESCE(codex_unified_client_identity_enabled, false)
+ COALESCE(codex_unified_client_identity_enabled, false),
+ COALESCE(codex_ws_downstream_keepalive_slots, 8)
FROM system_settings WHERE id = 1
`).Scan(
&s.SiteName, &s.SiteLogo,
@@ -2831,6 +2840,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) {
&s.CodexTelemetryTimingDebug,
&s.AutoResetCreditsOnExhaustionEnabled,
&s.CodexUnifiedClientIdentityEnabled,
+ &s.CodexWSDownstreamKeepaliveSlots,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -3096,9 +3106,10 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
codex_oauth_keepalive_enabled,
codex_telemetry_timing_debug,
auto_reset_credits_on_exhaustion_enabled,
- codex_unified_client_identity_enabled
+ codex_unified_client_identity_enabled,
+ codex_ws_downstream_keepalive_slots
)
- VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100, $101, $102, $103, $104, $105, $106, $107, $108, $109, $110, $111, $112, $113, $114, $115, $116, $117, $118, $119, $120, $121, $122, $123, $124, $125, $126, $127, $128)
+ VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100, $101, $102, $103, $104, $105, $106, $107, $108, $109, $110, $111, $112, $113, $114, $115, $116, $117, $118, $119, $120, $121, $122, $123, $124, $125, $126, $127, $128, $131)
ON CONFLICT (id) DO UPDATE SET
site_name = EXCLUDED.site_name,
site_logo = EXCLUDED.site_logo,
@@ -3224,7 +3235,8 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
codex_oauth_keepalive_enabled = EXCLUDED.codex_oauth_keepalive_enabled,
codex_telemetry_timing_debug = EXCLUDED.codex_telemetry_timing_debug,
auto_reset_credits_on_exhaustion_enabled = EXCLUDED.auto_reset_credits_on_exhaustion_enabled,
- codex_unified_client_identity_enabled = EXCLUDED.codex_unified_client_identity_enabled
+ codex_unified_client_identity_enabled = EXCLUDED.codex_unified_client_identity_enabled,
+ codex_ws_downstream_keepalive_slots = EXCLUDED.codex_ws_downstream_keepalive_slots
`, NormalizeSiteName(s.SiteName), strings.TrimSpace(s.SiteLogo),
s.MaxConcurrency, s.GlobalRPM, s.TestModel, testContent, s.TestConcurrency, s.ProxyURL, s.PgMaxConns, s.RedisPoolSize,
s.AutoCleanUnauthorized, s.AutoCleanRateLimited, s.AdminSecret, s.AutoCleanFullUsage, s.ProxyPoolEnabled,
@@ -3283,7 +3295,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
s.AutoResetCreditsOnExhaustionEnabled,
s.CodexUnifiedClientIdentityEnabled,
s.PreservePromptFilterCustomPatterns,
- s.PreservePromptFilterReviewAPIKey)
+ s.PreservePromptFilterReviewAPIKey, NormalizeCodexWSDownstreamKeepaliveSlots(s.CodexWSDownstreamKeepaliveSlots))
return err
}
@@ -3449,9 +3461,9 @@ func NormalizeCodexOverloadWindowMinutes(minutes int) int {
return minutes
}
-// NormalizeCodexWSStatelessSlots 把无状态 WS 连接槽位数限制在 1-32,非正值回落默认 8(issue #522)。
+// NormalizeCodexWSStatelessSlots 把账号共享的 WS 槽位预算限制在 0-32,负值回落默认 8。
func NormalizeCodexWSStatelessSlots(slots int) int {
- if slots <= 0 {
+ if slots < 0 {
return 8
}
if slots > 32 {
diff --git a/database/runtime_cache_scope.go b/database/runtime_cache_scope.go
new file mode 100644
index 000000000..7f44a84c5
--- /dev/null
+++ b/database/runtime_cache_scope.go
@@ -0,0 +1,20 @@
+package database
+
+import "context"
+
+func (db *DB) initializeRuntimeCacheScope(ctx context.Context) error {
+ revision, err := db.GetAPIKeyAuthRevision(ctx)
+ if err != nil {
+ return err
+ }
+ db.runtimeCacheScope = revision.Namespace
+ return nil
+}
+
+// RuntimeCacheScope 隔离使用同一个 Redis 的不同数据库,不包含连接凭证原文。
+func (db *DB) RuntimeCacheScope() string {
+ if db == nil {
+ return ""
+ }
+ return db.runtimeCacheScope
+}
diff --git a/database/sqlite.go b/database/sqlite.go
index 6ad90f0b3..14aabf10b 100644
--- a/database/sqlite.go
+++ b/database/sqlite.go
@@ -354,6 +354,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error {
codex_ws_busy_overflow_enabled INTEGER DEFAULT 0,
codex_ws_busy_patience_sec INTEGER DEFAULT 2,
codex_ws_stateless_slots INTEGER DEFAULT 8,
+ codex_ws_downstream_keepalive_slots INTEGER DEFAULT 8,
github_token TEXT DEFAULT '',
github_proxy_url TEXT DEFAULT '',
codex_overload_pause_enabled INTEGER DEFAULT 0,
@@ -680,6 +681,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error {
{"system_settings", "codex_ws_busy_overflow_enabled", "INTEGER DEFAULT 0"},
{"system_settings", "codex_ws_busy_patience_sec", "INTEGER DEFAULT 2"},
{"system_settings", "codex_ws_stateless_slots", "INTEGER DEFAULT 8"},
+ {"system_settings", "codex_ws_downstream_keepalive_slots", "INTEGER DEFAULT 8"},
{"system_settings", "github_token", "TEXT DEFAULT ''"},
{"system_settings", "github_proxy_url", "TEXT DEFAULT ''"},
{"system_settings", "codex_overload_pause_enabled", "INTEGER DEFAULT 0"},
diff --git a/docs/API.md b/docs/API.md
index 2ce3748da..e1d3f326f 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -222,8 +222,8 @@ Messages 的 `tool_use.input` 必须使用对象,因此自由文本工具输
| include | array | 否 | 包含的额外字段 |
| previous_response_id | string | 否 | 上一响应 ID,用于上下文连续 |
-`previous_response_id` 的上下文先查当前进程的有界 L1。已认证请求按 API Key ID 隔离;未配置任何 API Key、显式启用 `CODEX_ALLOW_ANONYMOUS=true` 后放行的请求共用 `anon` 命名空间。Redis 模式在 L1 未命中时可从共享后端重建;后端值未超过重建上限但超过 L1 准入预算时仍可服务本次请求,只是不提升到 L1。Memory 模式没有共享 response context 后备,依赖上下文被判定为超限、已淘汰或缺失时可能返回 HTTP `409 response_context_unavailable`。共享后端暂时不可用且请求依赖该上下文时可能返回 HTTP `503 service_unavailable`。如果账号池存在可用的 relay-style 后备,网关可保留原始 `previous_response_id` 继续转发,而不是立即返回上述错误。客户端原生 Responses WebSocket 入口在上游连接正常时保留 `previous_response_id` 并只发送当轮增量,轮次开始时保留仍有效的 L1 祖先引用,快照合并与序列化在响应成功提交后才执行并写入本地缓存,共享后端(Redis)写入在后台完成,不占首字路径。缓存会收集 `response.output_item.done`,因此最终 `response.output` 为空时也能保留消息、`phase`、工具调用/结果及 Lite `additional_tools` 声明。`response.completed` 和 `response.incomplete` 成功提交后均可写入,仍受写入策略、TTL 和容量限制约束;按需写入模式下,`store` 未显式为 `false` 的 WebSocket 会话从根轮起即有写入资格;显式 `store:false` 的会话(如 Codex CLI 全量上下文)不写缓存,其历史无法事后恢复。续链失效、换号或转为 HTTP 时,只有能够恢复所需上下文才继续发送;缺失、超限或不可移植的加密状态返回 `response_context_unavailable` 错误帧,共享后端故障返回 `service_unavailable`,随后关闭连接(409 使用 1008,后端暂时不可用使用 1011)。客户端应重发完整上下文并开始新的响应链。
-祖先在轮次开始前已过期或被淘汰时,网关不会把增量伪装成完整快照;该链后续健康轮也不能自行补全历史。缓存 TTL 为 10 分钟,Memory 模式重启会丢失缓存。设置环境变量 `CODEX_WS_CONTINUATION_FAIL_OPEN=true` 可退回旧行为:上下文不可恢复时剥离 `previous_response_id` 后按原样转发,上游将看不到历史;这次有损降级产生的响应不写回放缓存,关闭逃生阀后也不会信任残缺快照。
+`previous_response_id` 的上下文先查当前进程的有界 L1。本地与 Redis 共享上下文的默认绝对 TTL 为 45 分钟,覆盖聊天上游连接 30 分钟空闲回收后的恢复窗口,仍受缓存容量和账号兼容性限制。已认证请求按 API Key ID 隔离;未配置任何 API Key、显式启用 `CODEX_ALLOW_ANONYMOUS=true` 后放行的请求共用 `anon` 命名空间。Redis 模式在 L1 未命中时可从共享后端重建;后端值未超过重建上限但超过 L1 准入预算时仍可服务本次请求,只是不提升到 L1。Memory 模式没有共享 response context 后备,依赖上下文被判定为超限、已淘汰或缺失时可能返回 HTTP `409 response_context_unavailable`。共享后端暂时不可用且请求依赖该上下文时可能返回 HTTP `503 service_unavailable`。如果账号池存在可用的 relay-style 后备,网关可保留原始 `previous_response_id` 继续转发,而不是立即返回上述错误。客户端原生 Responses WebSocket 入口在上游连接正常时保留 `previous_response_id` 并只发送当轮增量,轮次开始时保留仍有效的 L1 祖先引用,快照合并与序列化在响应成功提交后才执行并写入本地缓存,共享后端(Redis)写入在后台完成,不占首字路径。缓存会收集 `response.output_item.done`,因此最终 `response.output` 为空时也能保留消息、`phase`、工具调用/结果及 Lite `additional_tools` 声明。`response.completed` 和 `response.incomplete` 成功提交后均可写入,仍受写入策略、TTL 和容量限制约束;按需写入模式下,`store` 未显式为 `false` 的 WebSocket 会话从根轮起即有写入资格;显式 `store:false` 的会话(如 Codex CLI 全量上下文)不写缓存,其历史无法事后恢复。续链失效、换号或转为 HTTP 时,只有能够恢复所需上下文才继续发送;缺失、超限或不可移植的加密状态返回 `response_context_unavailable` 错误帧,共享后端故障返回 `service_unavailable`,随后关闭连接(409 使用 1008,后端暂时不可用使用 1011)。客户端应重发完整上下文并开始新的响应链。
+祖先在轮次开始前已过期或被淘汰时,网关不会把增量伪装成完整快照;该链后续健康轮也不能自行补全历史。缓存 TTL 为 45 分钟,Memory 模式重启会丢失缓存。设置环境变量 `CODEX_WS_CONTINUATION_FAIL_OPEN=true` 可退回旧行为:上下文不可恢复时剥离 `previous_response_id` 后按原样转发,上游将看不到历史;这次有损降级产生的响应不写回放缓存,关闭逃生阀后也不会信任残缺快照。
对于原生 WebSocket 的结构化输出,`gpt-6-astra` 和 `gpt-5.6-luna` 在显式启用 Responses Lite 时保留 JSON Schema 的 `minLength` / `maxLength`。Lite 信号可来自 `client_metadata.ws_request_header_x_openai_internal_codex_responses_lite=true`、`X-OpenAI-Internal-Codex-Responses-Lite: true` 请求头,或由 Payload Rules 注入该元数据标记。该放行仅用于结构化输出;已有工具参数清洗继续使用保守规则。长度约束在入口准备阶段暂时保留,最终出站前才根据最终模型、规则改写后的 Lite 信号、账号 Lite 能力和实际传输统一处理。其他模型、最终未启用 Lite、HTTP 和 Compact 请求沿用原有清洗策略,HTTP 降级请求不会携带不适用的约束。
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 352d66d2b..7747dc433 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -528,14 +528,14 @@ L1 受 4,096 条、16 MiB 逻辑 JSON 字节和单条 64 KiB 预算限制;大
#### Responses 上下文缓存
-`previous_response_id` 连续请求使用一层每进程独立的有界 L1。完成响应时先按完整 call/output 组保留尾部最多 200 个 raw item,再写入 L1;默认总量 64 MiB、单条准入 8 MiB、最多 2,000 条、绝对 TTL 10 分钟。LRU 同时受条数和逻辑字节预算约束,命中不会延长绝对 TTL。
+`previous_response_id` 连续请求使用一层每进程独立的有界 L1。完成响应时先按完整 call/output 组保留尾部最多 200 个 raw item,再写入 L1;默认总量 64 MiB、单条准入 8 MiB、最多 2,000 条、绝对 TTL 45 分钟。LRU 同时受条数和逻辑字节预算约束,命中不会延长绝对 TTL。
```text
完成响应
│
├─ pair-safe 尾部裁剪(最多 200 items)
│
- ├─ 本进程 L1(64 MiB total / 8 MiB entry / 2,000 entries / 10m)
+ ├─ 本进程 L1(64 MiB total / 8 MiB entry / 2,000 entries / 45m)
│
└─ Redis 共享 response context(仅 Redis 模式)
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index d33ddb03f..7f07ee404 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -233,13 +233,17 @@ Responses 连续请求会按 `previous_response_id` 重建上下文。每个 Cod
| `response_cache_reconstruct_max_bytes` | 67,108,864 bytes(64 MiB) | 整数 8-512 MiB | 从共享后端读取并重建一条上下文时允许的逻辑 payload 上限 |
| `response_cache_config_generation` | 1 | 只读 | 配置发生实际变化时递增;客户端不能写入 |
-设置页会把三个预算作为一个原子更新发送。管理 API 也支持只提交部分预算,服务端会在数据库事务中与当前值合并并校验,提交成功后才应用到本实例。固定边界不随这三个设置变化:最多 2,000 条、10 分钟绝对 TTL、每条最多 200 个 raw item。降低预算会立即收缩本地 L1,并可能淘汰已有上下文。
+设置页会把三个预算作为一个原子更新发送。管理 API 也支持只提交部分预算,服务端会在数据库事务中与当前值合并并校验,提交成功后才应用到本实例。固定边界不随这三个设置变化:最多 2,000 条、45 分钟绝对 TTL、每条最多 200 个 raw item。降低预算会立即收缩本地 L1,并可能淘汰已有上下文。
Redis 模式会把 response context 保存到共享后端。后端值在重建上限内但超过 L1 单条或总量准入预算时,仍可服务当前请求,但不会提升到本地 L1。Memory 模式只保留本进程 L1,不存在第二份共享 response context;已知超限/淘汰,或依赖的必需上下文缺失/过期时,可能导致 HTTP `409 response_context_unavailable`。Redis 值损坏或超过重建上限且无法走 relay 后备时也可能返回 409。共享后端暂时不可用时,依赖该上下文且无法走 relay 后备的请求可能返回 HTTP `503`。
只有预算实际变化时才会分配并递增 generation;同值更新或空更新不会递增。当前实例在数据库提交后立即应用,其他实例每 5 秒轮询一次,只应用更新的 generation;单次读取最多等待 3 秒。同步失败时保留最后一次有效配置,并在运维页显示错误,后续轮询成功后自动恢复。
-这些预算覆盖 HTTP Responses/Compact 和原生 Responses WebSocket 的本地回放上下文。健康的原生 WS 续链仍保留 `previous_response_id` 交给上游;需要降级时可使用完整本地快照。原生 WS 显式 `store:false` 的请求不写回放缓存。
+这些预算覆盖 HTTP Responses/Compact 和原生 Responses WebSocket 的回放上下文。健康的原生 WS 续链仍保留 `previous_response_id` 交给原上游连接。显式 `store:false` 的原生 WS 请求会在响应成功过滤并提交后保存完整快照:Memory 使用本进程 L1,Redis 同时保存共享快照,支持下游重连到其他实例时恢复;此快照不受按需写入策略影响。快照按数据库作用域、API Key、权限范围、稳定会话、执行通道和 `stream_id` 隔离,沿用上述预算与绝对 TTL;读取或提升到 L1 不延长原过期时间。超过 L1 预算但仍在重建上限内时可从 Redis 服务,超过条目数或重建上限则拒绝整条快照,不截尾丢失祖先。
+
+Redis 原生 WS 快照将完整历史、账号 ID、凭证代际、兼容域和过期时间原子保存为同一记录。恢复加密 reasoning/compaction 时优先选择原账号,再核对凭证与兼容域;账号不兼容时不丢弃必要推理来强行续链。终态发送前登记共享提交屏障,其他实例等待快照提交后再读取;共享快照暂时不可用且 L1 无法恢复时返回可恢复的 `503`,完整 L1 命中不额外查询 Redis。上游实体连接与连接池仍属于原实例,Redis 分享上下文与协调状态,不迁移实体 WS。
+
+原连接丢失或上游明确拒绝旧 ID 时,网关在尚未交付内容或工具调用的条件下,最多恢复一次:移除 `previous_response_id` 并发送完整快照。加密推理/压缩项仅在原账号、凭证代次和兼容域均匹配时回放。祖先缺失、超限或兼容性无法确认时,返回 `status:409`、`code:previous_response_not_found`,供客户端重发完整输入;健康下游 WS 保持可用。Memory 模式下进程重启会丢失本地快照;Redis 模式可在原绝对 TTL 内跨实例或重启恢复共享快照。
共享后端写入的异步与同步路径统一限制为最多 16 个在途写、64 MiB 在途逻辑正文、64 个等待者;在复制/编码之前获取额度,最多等待 5 秒。超过 64 MiB 的既有合法单条上下文可独占写入器,因此其逻辑上限为普通预算与最大在途单条的较大值,不会静默丢弃大快照。普通写入 I/O deadline 为 2 秒,关停同步写为 500 毫秒。饱和时响应收尾及同 WS 后续轮次可能等待写入额度;写失败后 L1 仍可服务,必须依赖该快照但 L1/共享后端均缺失时返回 503。
@@ -262,6 +266,8 @@ Redis 模式会把 response context 保存到共享后端。后端值在重建
| `CodexForceWebsocket` | bool | false | - | 强制 Codex 上游走 WebSocket 长连接(复用连接池),更接近官方 CLI 体验;关闭时走原有 HTTP 请求 |
| `CodexWSKeepaliveEnabled` | bool | false | - | 启用上游 WS 空闲连接保活(后台仅发 Ping,不发起新请求、不消耗账号额度) |
| `CodexWSKeepaliveIntervalSec` | int | 60 | 10-600 | WS 保活 Ping 间隔(秒),仅在 `CodexWSKeepaliveEnabled` 开启时生效 |
+| `CodexWSDownstreamKeepaliveSlots` | int | 8 | 0-32 | 每账号在推理输出结束后保留的空闲聊天上游连接数,超额按 LRU 回收 |
+| `CodexWSStatelessSlots` | int | 8 | 0-32 | 每账号空白上游连接预算及无状态池扫描窗口 |
| `CodexWSHideUpstreamErrors` | bool | true | - | WS 上游最终失败时向客户端隐藏原始错误,返回统一友好提示;原始错误仍记录在后台日志/用量记录 |
| `CodexWSSilentRetryEnabled` | bool | true | - | WS 首包前遇到限流、额度耗尽、5xx、读取错误或超时时,静默换账号并重建上游 WS |
| `CodexWSSilentMaxRetries` | int | 2 | 0-10 | WS 首包前静默重试上限;`0` 禁用该预算 |
@@ -278,7 +284,7 @@ Redis 模式会把 response context 保存到共享后端。后端值在重建
索引选号的过滤器与准入回调在调度锁外执行,返回后重新检查候选代次、账号状态和并发;`Disabled` / `DispatchPaused` 同样阻止最终占位。已有会话绑定、容量借号保护和有状态续链的账号约束保持生效。
-账号满载时,等待队列同时受全局与单个 API Key 上限约束,所有使用该账号池等待路径的协议和上游共用预算。HTTP 队列溢出返回 `503` 和 `Retry-After: 1`;已提交的 SSE 输出对应协议的失败事件,WebSocket 返回错误帧并以 `1013` 关闭,文案提示 1 秒后重试。该本地过载不会进入持续重试的上游换号循环,也不会被误报为账号额度耗尽。已有等待者不因调低上限而被取消;环境变量不是跨实例配额,也不限制已经在上游执行的请求。
+账号满载时,等待队列同时受全局与单个 API Key 上限约束,所有使用该账号池等待路径的协议和上游共用预算。HTTP 队列溢出返回 `503` 和 `Retry-After: 1`;已提交的 SSE 输出对应协议的失败事件,Responses WebSocket 返回带 `status:503` 的错误帧并保持连接可用,文案提示 1 秒后重试。该本地过载不会进入持续重试的上游换号循环,也不会被误报为账号额度耗尽。已有等待者不因调低上限而被取消;环境变量不是跨实例配额,也不限制已经在上游执行的请求。
队列内按 API Key 轮转,同一 Key 按可尝试请求的入队顺序唤醒。普通单槽释放只唤醒一个等待者;已有续链绑定和排除账号用于跳过不匹配的通知,剩余模型、分组和 scope 过滤仍在锁外执行,失败后把机会交给后续等待者。一轮通知最多尝试当前等待集合一次,同时最多有 8 个通知驱动的选号;密集释放合并为后续容量检查。公平性针对已排队的请求,不承诺绕过快路径新请求的全局先来先服务,也不保证不同过滤条件获得相同吞吐。SSE/WS 心跳不重新入队。每个有等待者的账号池只使用一个每秒恢复检查定时器,空队列自动停止;账号冷却自身的到期恢复通知仍然生效。
@@ -368,8 +374,16 @@ Claude / Grok / Antigravity 等中继型账号不经 Resin,始终按第 2、3
### WebSocket 连接池与 1009 降级
-- 每个账号的上游物理 WebSocket 连接数受其当前 `DynamicConcurrencyLimit` 限制。
-- 新建或复用连接时如果超过新上限,只淘汰最老的空闲连接;当前请求使用的连接和其他活跃连接不会被中断。
+- 管理后台 **设置 → WebSocket(Codex 上游)→ 聊天续链连接保留数**(`codex_ws_downstream_keepalive_slots`)限制每账号在 Codex 输出完毕、等待用户继续输入时保留的空闲聊天上游连接数,默认 8、范围 0~32,所有 Key 共用,保存后热更新。设为 0 时聊天输出收尾即关闭上游连接,后续通过仍有效的上下文恢复。拨号及推理中的连接不占空闲保留额度;普通会话、无状态客户端、overflow、一次性连接与后台执行道的聊天输出收尾后,均按相同规则保留。
+- 原 `codex_ws_stateless_slots`(空白预热连接保留数)独立限制每账号未承载用户聊天上下文的空白连接,并控制无状态连接池扫描窗口,默认 8、范围 0~32。没有聊天输入、指令、工具定义、模板或续链 ID 的明确预热请求使用空白预算。首次在空白连接上开始聊天前,连接退出空白预算,进入推理状态,输出结束后才进入聊天空闲保留池;两个保留池不互相占位。设为 0 时不扫描通用池、不保留空白连接,预热请求仍可临时建连并在收尾后关闭。两项之和是正常空闲保留量,不是全部实体连接的硬上限;推理连接另外由原账号/Key 请求并发约束。
+- 连接预算独立于 `DynamicConcurrencyLimit`。API Key 和账号的请求并发继续沿用原配置、队列与计数方式。例如 4 个 Key 各限并发 3、账号限并发 3,仍最多同时处理 3 个请求;若希望保留 12 个独立聊天会话的上游连接,可将“聊天续链连接保留数”设为 12 或 16,不会自动修改现有配置。
+- 空白拨号仍按原预算原子预留;聊天拨号、在途以及摘池后的收尾只登记实体 socket 生命周期,直到实际关闭后幂等解除登记。聊天推理可以在空闲保留池已满时正常开始,不会因这个设置返回 503;原来的 Key/账号请求并发和 busy 等待规则继续生效。
+- 聊天输出收尾后更新最后业务使用时间,空闲聊天连接超过上限时按 LRU 关闭最久未使用的连接,包括仍有有效续链绑定的连接。回收在账号锁下复验连接身份和 pending 请求,跳过在途请求、拨号及空白连接;旧连接的晚到回调不会影响新连接。被回收会话再次输入时,可从仍有效且兼容的完整上下文快照重建上游连接。空白预算不足时仍可能返回 `503 upstream_ws_connection_capacity_exceeded`。
+- 下调聊天保留数后,取连、输出收尾或每 30 秒后台巡检会按新上限淘汰最旧空闲连接;推理不受影响。普通模式下,曾用于用户聊天的上游 WS 在最后一次业务使用后闲置 30 分钟也会关闭,Ping/Pong、下游连接保持打开和后台元数据不续期。空白连接不受聊天闲置限制,弱网模式及到龄轮转沿用原规则。
+- 空闲回收会实际关闭旧上游 socket,清除对应会话和续链绑定。后续请求重新握手创建新的上游 WS,并在需要时恢复完整上下文;旧连接的晚到清理不会移除同池键下的新连接。
+- `CODEX_WS_STATELESS_ONESHOT=1` 禁止通用槽位复用;响应正常收尾后,有有效续链绑定的连接按聊天空闲上限保留,无绑定的一次性连接直接销毁。异常或未完整消费的响应继续销毁对应坏连接。
+- 连接关闭日志区分本地回收、空闲超时、到龄轮转、上游关闭帧与网络错误,并记录关闭码、续链绑定状态和剩余连接数。Redis 继续用于完整续链快照及恢复元数据,实体连接预算在单实例内协调。本地与 Redis 上下文默认保存 45 分钟,覆盖聊天连接 30 分钟空闲回收后的恢复窗口;缓存容量淘汰、账号不兼容或已过期仍可能使恢复失败。
+- 带 `previous_response_id` 的请求只取回原连接,并复验账号、模型、出口和客户端身份;忙时可取消地等待,遵守现有 busy 等待上限。等待耗尽返回 `status:503`,API Key 并发拒绝返回 `status:429`,均保留下游 WS 供下一轮使用。
- 上游在尚未向下游输出内容时返回 close 1009,或本地读取触发等价的 read-limit 错误,网关会保留同一账号租约和已解析代理,最多降级一次 HTTP。
- 1009 属于传输限制,不降低账号健康度,也不触发鉴权探针;一旦已向下游输出内容,就不会再发起 HTTP 降级,避免重复请求和重复计费。
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 78a398136..f105d55a0 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -4527,8 +4527,10 @@
"githubTokenClear": "Clear",
"githubProxy": "GitHub Proxy",
"githubProxyDesc": "Outbound proxy applied only to github.com / api.github.com / *.githubusercontent.com, decoupled from the global proxy. Empty falls back to the global proxy (online update falls back to env proxy).",
- "codexWSStatelessSlots": "Stateless Connection Slots",
- "codexWSStatelessSlotsDesc": "Persistent WS connection slots per (account, cache key) for stateless requests (1-32, default 8). Higher: more idle connections per account; lower: more handshakes, which may trip upstream handshake rate limits at high RPM. Effective value is still capped by the account's dynamic concurrency limit. Hot-reloads.",
+ "codexWSStatelessSlots": "Blank Prewarm Connections to Keep",
+ "codexWSStatelessSlotsDesc": "Maximum upstream connections kept ready per account before they contain chat content. Set to 0 to close them after each request. Once chat starts, retention follows Chat Continuation Connections to Keep. Default 8, range 0–32. Changes apply after saving.",
+ "codexWSDownstreamKeepaliveSlots": "Chat Continuation Connections to Keep",
+ "codexWSDownstreamKeepaliveSlotsDesc": "Maximum chat connections kept per account after a reply finishes, while waiting for the next input. Shared by all API keys. Close the least recently used connection when over the limit, without interrupting replies in progress. Set to 0 to close after each reply. Connections also close after 30 minutes of inactivity; later input can reconnect using context saved for 45 minutes by default. Default 8, range 0–32. Changes apply after saving.",
"codexWSSilentMaxRetries": "Max WS Silent Retries",
"codexWSSilentMaxRetriesDesc": "Maximum account-switch retries for upstream WS errors before the first token, range 0-10, default 2.",
"schedulerMode": "Scheduler Mode",
diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json
index 483c7233c..d0550bda6 100644
--- a/frontend/src/locales/zh.json
+++ b/frontend/src/locales/zh.json
@@ -4524,8 +4524,10 @@
"githubTokenClear": "清除",
"githubProxy": "GitHub 专用代理",
"githubProxyDesc": "仅对 github.com / api.github.com / *.githubusercontent.com 生效的出站代理,与全局代理解耦;留空则回落全局代理(在线更新回落环境变量代理)。",
- "codexWSStatelessSlots": "无状态连接槽位数",
- "codexWSStatelessSlotsDesc": "无状态请求每 (账号, 缓存键) 维度复用的持久 WS 连接槽位数(1-32,默认 8)。调大:单账号挂更多空闲连接;调小:握手更频繁,高 RPM 下可能触发上游握手限流。实际生效值仍受账号动态并发上限钳制,热更新生效。",
+ "codexWSStatelessSlots": "空白预热连接保留数",
+ "codexWSStatelessSlotsDesc": "每个账号最多保留多少条提前建立、还没有聊天内容的上游连接。设为 0 时不保留,请求结束后关闭。开始聊天后,连接按“聊天续链连接保留数”保留。默认 8,范围 0~32,保存后生效。",
+ "codexWSDownstreamKeepaliveSlots": "聊天续链连接保留数",
+ "codexWSDownstreamKeepaliveSlotsDesc": "每个账号在回复结束后,最多保留多少条等待用户继续输入的聊天连接,所有 API Key 共用。超过数量时关闭最久未使用的一条,不影响正在生成的回复。设为 0 时回复结束就关闭。闲置 30 分钟也会关闭,下次输入可通过默认保存 45 分钟的上下文重新连接。默认 8,范围 0~32,保存后生效。",
"codexWSSilentMaxRetries": "WS 静默最大重试次数",
"codexWSSilentMaxRetriesDesc": "首包前上游错误最多静默换号重试的次数,范围 0–10,默认 2。",
"schedulerMode": "调度模式",
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 7a387ab7a..0b9bfa6fd 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -2622,6 +2622,7 @@ export default function Settings() {
codex_ws_busy_overflow_enabled: false,
codex_ws_busy_patience_sec: 2,
codex_ws_stateless_slots: 8,
+ codex_ws_downstream_keepalive_slots: 8,
github_token_configured: false,
github_proxy_url: '',
codex_overload_pause_enabled: false,
@@ -4242,12 +4243,27 @@ export default function Settings() {
}}
/>
+
+ setSettingsForm(f => ({ ...f, codex_ws_downstream_keepalive_slots: value }))}
+ onValueCommit={(value) => {
+ void autoSaveSettingsPatch({ codex_ws_downstream_keepalive_slots: value })
+ }}
+ />
+
respCache.config.maxItems || responseContextLogicalBytes(items) > respCache.config.reconstructMaxBytes) {
+ if existing := respCache.store[storeKey]; existing != nil {
+ respCache.removeEntryLocked(existing, responseCacheRemovalReplace)
+ }
+ respCache.setMarkerLocked(storeKey, responseCacheLookupKnownOversize, time.Now().Add(respCache.config.ttl))
+ respCache.stats.OversizeSkips++
+ respCache.stats.OversizeRejections++
+ return nil, false, true, serial
+ }
items = trimResponseContextTail(items, respCache.config.maxItems)
items, hashes, normalized := respCache.normalizeResponseContextItemsLocked(items)
var entryBytes int64
diff --git a/proxy/response_cache_budget_test.go b/proxy/response_cache_budget_test.go
index 242973270..c10d0f83c 100644
--- a/proxy/response_cache_budget_test.go
+++ b/proxy/response_cache_budget_test.go
@@ -53,8 +53,8 @@ func TestResponseCacheDefaultBudget(t *testing.T) {
if config.maxEntries != 2000 {
t.Fatalf("maxEntries = %d, want 2000", config.maxEntries)
}
- if config.ttl != 10*time.Minute {
- t.Fatalf("ttl = %s, want 10m", config.ttl)
+ if config.ttl != 45*time.Minute {
+ t.Fatalf("ttl = %s, want 45m", config.ttl)
}
if config.maxItems != 200 {
t.Fatalf("maxItems = %d, want 200", config.maxItems)
diff --git a/proxy/responses_continuation.go b/proxy/responses_continuation.go
new file mode 100644
index 000000000..1c4d9ba63
--- /dev/null
+++ b/proxy/responses_continuation.go
@@ -0,0 +1,41 @@
+package proxy
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/codex2api/api"
+)
+
+// ResponsesContinuationLostError 表示连接内续链状态已失效;不能携带旧 ID 重拨。
+type ResponsesContinuationLostError struct {
+ Reason string
+}
+
+type ResponsesContinuationBusyError struct{}
+
+func (*ResponsesContinuationBusyError) Error() string {
+ return "original websocket connection remained busy until the wait deadline"
+}
+
+func (*ResponsesContinuationBusyError) Unwrap() error {
+ return &Error{Code: ErrorCodeAccountPoolConcurrencySaturated, Type: ErrorTypeServerError,
+ HTTPStatus: http.StatusServiceUnavailable, Message: "Original WebSocket connection is busy, please retry later"}
+}
+
+func (err *ResponsesContinuationLostError) Error() string {
+ return fmt.Sprintf("websocket continuation unavailable: %s", err.Reason)
+}
+
+func (err *ResponsesContinuationLostError) Unwrap() error {
+ return &Error{Code: "previous_response_not_found", Type: ErrorTypeInvalidRequest,
+ HTTPStatus: http.StatusConflict, Message: "Previous response context is unavailable"}
+}
+
+func nativeResponsesWSContextError(err *api.APIError) *api.APIError {
+ if err == nil || err.Code == api.ErrCodeServiceUnavailable {
+ return err
+ }
+ return api.NewAPIErrorWithDetails(api.ErrorCode("previous_response_not_found"),
+ err.Message, api.ErrorTypeInvalidRequest, err.Details)
+}
diff --git a/proxy/responses_ws.go b/proxy/responses_ws.go
index ecf0d5438..4bdd2b611 100644
--- a/proxy/responses_ws.go
+++ b/proxy/responses_ws.go
@@ -82,7 +82,8 @@ func isPreviousResponseNotFoundBody(payload []byte) bool {
for _, path := range []string{"error.message", "message"} {
message := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, path).String()))
if message == "previous_response_id is not available for this user" ||
- strings.Contains(message, "previous response with id") {
+ strings.Contains(message, "previous response with id") ||
+ strings.TrimSuffix(strings.NewReplacer("`", "", "\"", "", "'", "").Replace(message), ".") == "invalid previous_response_id" {
return true
}
}
@@ -443,6 +444,9 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
turnContinuation := codexWSTurnContinuationToken(rawBody) != ""
_, turnHasBinding := h.store.SessionAffinityAccountID(affinityKey)
respCacheOwner := responseCacheOwner(apiKeyID)
+ if gjson.GetBytes(rawBody, "store").Type == gjson.False {
+ respCacheOwner = nativeWSTurnCacheOwner(c, respCacheOwner, nativeWSTurnScope{body: rawBody, identity: sessionIdentity, databaseScope: h.db.RuntimeCacheScope()})
+ }
markResponsesWSContinuationCapable(respCacheOwner, rawBody)
ruleIdentity := h.payloadRuleIdentity(c)
// 上下文压缩轮豁免首字超时看门狗(issue #381):压缩首帧天然慢,超时换号无益。
@@ -454,8 +458,7 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
}
codexBody, naturalImageIntent := prepareResponsesWebSocketTurnBody(rawBody)
- // Pin an available L1 ancestor before upstream generation; defer backend
- // lookup, merging and serialization until a snapshot is actually needed.
+ // 生成前固定祖先;原生 WS 必要时读共享快照,历史合并与序列化保持按需执行。
// strip 策略:剥离图片工具能力声明后作为普通文本请求继续(issue #411)。
codexBody = applyImageGenerationStripPolicy(c, codexBody)
if err := validateResponsesImageGenerationSizes(codexBody); err != nil {
@@ -503,7 +506,7 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
releaseAPIKeyConcurrency, concurrencyErr, ok := h.acquireAPIKeyConcurrencyForWebSocket(c)
if !ok {
_ = writeResponsesWSError(conn, concurrencyErr)
- return newResponsesWSCloseError(websocket.CloseTryAgainLater, concurrencyErr.Message, concurrencyErr)
+ return nil
}
if releaseAPIKeyConcurrency != nil {
defer releaseAPIKeyConcurrency()
@@ -577,7 +580,11 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
// 忽略本地 WHAM 100% 快照;previous_response_id 本身不足以证明这是活跃 turn。
continuationPinned := turnContinuation && turnHasBinding
continuationDegraded := false
+ if err := waitNativeWSCommit(c.Request.Context(), respCacheOwner, gjson.GetBytes(codexBody, "previous_response_id").String()); err != nil {
+ return writeResponsesWSError(conn, nativeResponsesWSContextError(responsesWSContextUnavailable(http.StatusServiceUnavailable, "context_commit_pending")))
+ }
turnReplay := newResponsesWSReplaySource(codexBody, respCacheOwner)
+ accountFilter = nativeWSReplayAccountFilter(accountFilter, turnReplay)
degradeContinuation := func(reason string, attempt int) *api.APIError {
expanded, lost, contextErr := degradeResponsesWSContinuationWithSource(codexBody, respCacheOwner, turnReplay)
if contextErr != nil {
@@ -586,11 +593,11 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
continuationDegraded = true
continuationPinned = false
codexBody = expanded
- turnReplay = newResponsesWSReplaySourceFromInput(responsesInputRaw(expanded))
+ turnReplay = newResponsesWSReplaySourceFromRecovery(responsesInputRaw(expanded), turnReplay)
if lost {
turnReplay = newResponsesWSReplaySourceFromInput("")
}
- log.Printf("Responses WebSocket continuation degraded: %s, stripped previous_response_id and retried once (attempt %d)", reason, attempt)
+ log.Printf("Responses WebSocket continuation degraded: reason=%s, stripped previous_response_id and retried once (attempt %d)", reason, attempt)
return nil
}
preserveContinuationBinding := func() bool {
@@ -628,8 +635,7 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
if boundID, bound := h.store.SessionAffinityAccountID(affinityKey); bound {
if exclude := retryExclusions.ForSelection(); exclude[boundID] {
if contextErr := degradeContinuation(fmt.Sprintf("bound account %d excluded by this request", boundID), attempt+1); contextErr != nil {
- _ = writeResponsesWSError(conn, contextErr)
- return newResponsesWSCloseError(responsesWSContextCloseCode(contextErr), contextErr.Message, contextErr)
+ return writeResponsesWSRecoveryError(conn, contextErr)
}
}
}
@@ -672,13 +678,18 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
return errResponsesWSClientGone
}
_ = writeResponsesWSError(conn, apiErr)
- return newResponsesWSCloseError(websocket.CloseTryAgainLater, apiErr.Message, apiErr)
+ return nil
}
if attempt > 0 {
clearNewAPIUpstreamCyberPolicyDecision(c)
}
h.AcquireAPIKeyScopeConcurrency(c, account)
+ turnReplay.setAccount(account)
+ if continuationDegraded && turnReplay.Input() == "" && turnReplay.err != nil {
+ h.store.Release(account)
+ return writeResponsesWSRecoveryError(conn, turnReplay.err)
+ }
start := time.Now()
proxyURL := h.resolveProxyForAttempt(account, stickyProxyURL)
if !retainedHTTPFallback && !continuousRetryBuffersAttempts(continuousRetryPolicy) {
@@ -738,18 +749,15 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
ttftGuard.Stop()
upstreamCancel()
h.store.Release(account)
- _ = writeResponsesWSError(conn, contextErr)
- return newResponsesWSCloseError(responsesWSContextCloseCode(contextErr), contextErr.Message, contextErr)
+ return writeResponsesWSRecoveryError(conn, contextErr)
}
// 降级时快照已经算过一次,直接复用展开后的 input,不再二次过滤。
- attemptReplay = newResponsesWSReplaySourceFromInput(responsesInputRaw(upstreamBody))
+ attemptReplay = newResponsesWSReplaySourceFromRecovery(responsesInputRaw(upstreamBody), turnReplay)
+ attemptReplay.setAccount(account)
if lost {
attemptReplay = nil
}
}
- if gjson.GetBytes(rawBody, "store").Type == gjson.False {
- attemptReplay = nil
- }
// service_tier 记账按 payload 规则改写后的值归因(覆写 service_tier 的规则才生效)。
serviceTier = EffectiveRequestedServiceTier(upstreamBody, effectiveModel, downstreamHeaders, attemptIdentity)
// 在 useWebsocket 最终确定后再派生上游身份键:与 handler.go 的
@@ -776,6 +784,33 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
}
if reqErr != nil {
+ var capacityErr *UpstreamWSConnectionCapacityError
+ if errors.As(reqErr, &capacityErr) {
+ ttftGuard.Stop()
+ upstreamCancel()
+ h.store.Release(account)
+ return writeResponsesWSError(conn, api.NewAPIError(api.ErrorCode(ErrorCodeUpstreamWSConnectionCapacity), capacityErr.Error(), api.ErrorTypeServer))
+ }
+ var continuationBusy *ResponsesContinuationBusyError
+ if errors.As(reqErr, &continuationBusy) {
+ ttftGuard.Stop()
+ upstreamCancel()
+ h.store.Release(account)
+ return writeResponsesWSError(conn, api.NewAPIError(api.ErrCodeServiceUnavailable, continuationBusy.Error(), api.ErrorTypeServer))
+ }
+ var continuationLost *ResponsesContinuationLostError
+ if errors.As(reqErr, &continuationLost) {
+ ttftGuard.Stop()
+ upstreamCancel()
+ h.store.Release(account)
+ if canDegradeContinuation() {
+ if contextErr := degradeContinuation(continuationLost.Reason, attempt+1); contextErr != nil {
+ return writeResponsesWSRecoveryError(conn, contextErr)
+ }
+ continue
+ }
+ return writeResponsesWSRecoveryError(conn, nativeResponsesWSContextError(responsesWSContextUnavailable(http.StatusConflict, continuationLost.Reason)))
+ }
if quotaErr := apiKeyModelRequestError(reqErr); quotaErr != nil {
ttftGuard.Stop()
h.store.Release(account)
@@ -903,8 +938,7 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
if canDegradeContinuation() && isPreviousResponseNotFoundBody(errBody) {
if contextErr := degradeContinuation(fmt.Sprintf("upstream rejected previous_response_id on account %d", account.ID()), attempt+1); contextErr != nil {
h.store.Release(account)
- _ = writeResponsesWSError(conn, contextErr)
- return newResponsesWSCloseError(responsesWSContextCloseCode(contextErr), contextErr.Message, contextErr)
+ return writeResponsesWSRecoveryError(conn, contextErr)
}
SyncCodexUsageState(h.store, account, resp)
h.store.Release(account)
@@ -996,8 +1030,7 @@ func (h *Handler) forwardResponsesWebSocketTurn(c *gin.Context, conn *websocket.
// 账号已在流内释放,未记失败也未解绑:剥离续链 id 后原地再试一次。
// turn-state 钉号同样走这条路:上游已经说找不到 id,换号无益,剥 id 才能继续。
if contextErr := degradeContinuation(fmt.Sprintf("upstream rejected previous_response_id on account %d", account.ID()), attempt+1); contextErr != nil {
- _ = writeResponsesWSError(conn, contextErr)
- return newResponsesWSCloseError(responsesWSContextCloseCode(contextErr), contextErr.Message, contextErr)
+ return writeResponsesWSRecoveryError(conn, contextErr)
}
continue
}
@@ -1156,7 +1189,12 @@ func (h *Handler) streamResponsesWSUpstream(
if continuousRetryBuffersAttempts(continuousRetryPolicy) {
wsReplay = h.newContinuousRetryWSReplay()
}
+ finishContextCommit := func() {}
+ defer func() { finishContextCommit() }()
writeClientMessage := func(payload []byte) error {
+ if isResponsesSuccessTerminalEvent(gjson.GetBytes(payload, "type").String()) {
+ finishContextCommit = beginNativeWSCommit(respCacheOwner, gjson.GetBytes(payload, "response.id").String())
+ }
return writeResponsesWSMessage(conn, payload)
}
// 首 token 前收到不可重试的 response.failed 时置位:不把原始失败帧透传给客户端,
@@ -1492,10 +1530,12 @@ func (h *Handler) streamResponsesWSUpstream(
// Committed completed/incomplete responses can be continuation history.
// Failed attempts and locally blocked/unwritable replays leave no cache.
if !outputCollector.overflow {
- cacheResponsesWSCompletedResponse(respCacheOwner, replayInput.Input(), completedResponsePayload, outputCollector.Items())
+ cacheCommittedResponsesWSContext(nativeWSCompletedContext{owner: respCacheOwner, input: replayInput.Input(),
+ completed: completedResponsePayload, outputs: outputCollector.Items(), provenance: replayInput.committedProvenance()})
}
}
}
+ finishContextCommit()
_ = wsReplay.Close()
if continuousRetryBufferedAttemptCommitted(continuousRetryPolicy, outcome) {
h.store.BindSessionAffinityWithGuard(affinityKey, account, proxyURL, affinityGuard)
@@ -1612,6 +1652,9 @@ func (h *Handler) streamResponsesWSUpstream(
return errResponsesWSClientGone
}
_ = writeResponsesWSError(conn, clientErr)
+ if preserveErrorCode {
+ return nil
+ }
return newResponsesWSCloseError(responsesWSCloseCodeForStatus(outcome.logStatusCode), clientErr.Message, apiErr)
}
if outcome.logStatusCode != http.StatusOK && !hideUpstreamErrors && len(terminalFailureClientPayload) > 0 && !downstreamWrote {
@@ -1764,11 +1807,13 @@ func writeResponsesWSErrorWithin(conn *websocket.Conn, apiErr *api.APIError, tim
apiErr = api.NewAPIError(api.ErrCodeServerError, "Internal server error", api.ErrorTypeServer)
}
payload, err := json.Marshal(struct {
- Type string `json:"type"`
- Error *api.APIError `json:"error"`
+ Type string `json:"type"`
+ Status int `json:"status"`
+ Error *api.APIError `json:"error"`
}{
- Type: "error",
- Error: apiErr,
+ Type: "error",
+ Status: responsesWSErrorStatus(apiErr),
+ Error: apiErr,
})
if err != nil {
return err
diff --git a/proxy/responses_ws_context.go b/proxy/responses_ws_context.go
index b7f9b8305..fbdfda798 100644
--- a/proxy/responses_ws_context.go
+++ b/proxy/responses_ws_context.go
@@ -10,7 +10,6 @@ import (
"time"
"github.com/codex2api/api"
- "github.com/gorilla/websocket"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -19,15 +18,28 @@ import (
// incremental request sent over a healthy upstream WebSocket. Missing ancestry
// must not turn a partial input into a supposedly complete cached conversation.
func responsesWSReplayInput(body []byte, owner string) (string, *api.APIError) {
+ if strings.HasPrefix(owner, nativeWSCachePrefix) {
+ return responsesWSReplayInputWithLookup(body, owner, nativeWSContextLookup)
+ }
return responsesWSReplayInputWithLookup(body, owner, getResponseCacheForReplay)
}
func responsesWSReplayInputWithLookup(body []byte, owner string, lookupFn func(string, string) responseCacheLookupResult) (string, *api.APIError) {
- current := gjson.GetBytes(body, "input")
+ return responsesWSReplayInputChecked(body, responsesWSReplayOptions{owner: owner, lookup: lookupFn})
+}
+
+type responsesWSReplayOptions struct {
+ owner string
+ lookup func(string, string) responseCacheLookupResult
+ allowOpaque bool
+}
+
+func responsesWSReplayInputChecked(body []byte, options responsesWSReplayOptions) (string, *api.APIError) {
+ owner := options.owner
var items []json.RawMessage
previousID := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String())
if previousID != "" {
- lookup := lookupFn(owner, previousID)
+ lookup := options.lookup(owner, previousID)
if lookup.Kind != responseCacheLookupHit {
prepared := responsesBodyPreparation{PreviousResponseID: previousID, CacheLookup: lookup, RequiresLocalContext: true}
status, reason, _ := responseCachePreparationFailure(prepared)
@@ -35,18 +47,13 @@ func responsesWSReplayInputWithLookup(body []byte, owner string, lookupFn func(s
}
items = append(items, lookup.Items...)
}
- var input []json.RawMessage
- current.ForEach(func(_, item gjson.Result) bool {
- if raw, ok := replayableCachedInputItem(item); ok {
- input = append(input, raw)
- }
- return true
- })
- items = mergeResponsesWSContext(items, input)
- for _, item := range items {
- if gjson.GetBytes(item, "encrypted_content").String() != "" {
- return "", responsesWSContextUnavailable(http.StatusConflict, "nonportable_encrypted_context")
- }
+ input, itemErr := responsesWSReplayItems(gjson.GetBytes(body, "input"), options)
+ if itemErr != nil {
+ return "", itemErr
+ }
+ items, itemErr = validateResponsesWSReplayItems(mergeResponsesWSContext(items, input), options)
+ if itemErr != nil {
+ return "", itemErr
}
respCache.mu.RLock()
config := respCache.config
@@ -61,6 +68,40 @@ func responsesWSReplayInputWithLookup(body []byte, owner string, lookupFn func(s
return string(raw), nil
}
+func responsesWSReplayItems(current gjson.Result, options responsesWSReplayOptions) ([]json.RawMessage, *api.APIError) {
+ var input []json.RawMessage
+ var itemErr *api.APIError
+ current.ForEach(func(_, item gjson.Result) bool {
+ if strings.HasPrefix(options.owner, nativeWSCachePrefix) {
+ raw, err := nativeWSReplayItemChecked(item, options.allowOpaque)
+ if err != nil {
+ itemErr = err
+ return false
+ }
+ input = append(input, raw)
+ } else if raw, ok := replayableCachedInputItem(item); ok {
+ input = append(input, raw)
+ }
+ return true
+ })
+ return input, itemErr
+}
+
+func validateResponsesWSReplayItems(items []json.RawMessage, options responsesWSReplayOptions) ([]json.RawMessage, *api.APIError) {
+ for index, item := range items {
+ if strings.HasPrefix(options.owner, nativeWSCachePrefix) {
+ raw, err := nativeWSReplayItemChecked(gjson.ParseBytes(item), options.allowOpaque)
+ if err != nil {
+ return nil, err
+ }
+ items[index] = raw
+ } else if gjson.GetBytes(item, "encrypted_content").String() != "" {
+ return nil, responsesWSContextUnavailable(http.StatusConflict, "nonportable_encrypted_context")
+ }
+ }
+ return items, nil
+}
+
func responsesWSContextUnavailable(status int, reason string) *api.APIError {
code, kind, message := api.ErrCodeResponseContextUnavailable, api.ErrorTypeInvalidRequest, "Previous response context is unavailable"
if status == http.StatusServiceUnavailable {
@@ -109,6 +150,10 @@ func mergeResponsesWSContext(previous, current []json.RawMessage) []json.RawMess
// replay sanitizer: this snapshot may be replayed on a different account.
// Call only after the attempt and downstream output have committed successfully.
func cacheResponsesWSCompletedResponse(owner, input string, completed []byte, outputItems []json.RawMessage) {
+ if strings.HasPrefix(owner, nativeWSCachePrefix) {
+ cacheNativeWSContext(nativeWSCompletedContext{owner: owner, input: input, completed: completed, outputs: outputItems})
+ return
+ }
responseID := gjson.GetBytes(completed, "response.id").String()
if responseID == "" || input == "" {
return
@@ -165,14 +210,19 @@ func degradeResponsesWSContinuationWithSource(body []byte, owner string, source
}
var input string
var apiErr *api.APIError
- if source != nil && source.previous != nil {
+ if source != nil {
input = source.Input()
- recordResponseCacheLookup(owner, *source.previous)
+ if source.previous != nil {
+ recordResponseCacheLookup(owner, *source.previous)
+ }
apiErr = source.err
} else {
input, apiErr = responsesWSReplayInput(body, owner)
}
if apiErr != nil {
+ if strings.HasPrefix(owner, nativeWSCachePrefix) {
+ return nil, false, nativeResponsesWSContextError(apiErr)
+ }
if !responsesWSContinuationFailOpen() {
return nil, false, apiErr
}
@@ -225,10 +275,8 @@ func responsesWSContextReason(apiErr *api.APIError) string {
return apiErr.Message
}
-// markResponsesWSContinuationCapable 给可能续链的 WS 会话在根轮就授予 on_demand
-// 写入资格。Codex CLI 全量上下文每轮 store:false,永远不会带 previous_response_id
-// 回来,不能为它开闸;store 非 false 的会话才有机会续链,若根轮不入缓存,
-// 之后每轮都因祖先缺失无法成快照,降级必然失败。
+// markResponsesWSContinuationCapable 为允许持久存储的会话授予 on_demand 写入资格。
+// store:false 的原生 WS 续链单独使用完整快照,并复用共享后端。
func markResponsesWSContinuationCapable(owner string, rawBody []byte) {
if store := gjson.GetBytes(rawBody, "store"); store.Exists() && store.Type == gjson.False {
return
@@ -236,17 +284,18 @@ func markResponsesWSContinuationCapable(owner string, rawBody []byte) {
markResponseCacheChainOwnerIfOnDemand(owner)
}
-// responsesWSReplaySource pins a live L1 ancestor at turn admission. Its immutable
-// bodies survive expiry/eviction during generation; merging and backend fallback
-// remain lazy and do not count as client replay hits or misses.
+// 入场时固定不可变祖先;原生 WS 缺少 L1 时读取共享快照以保留加密上下文的账号。
+// 旧续链后端查询、历史合并与序列化仍按需执行,不提前记为客户端回放命中。
type responsesWSReplaySource struct {
- body []byte
- owner string
- precomputed bool
- once sync.Once
- input string
- previous *responseCacheLookupResult
- err *api.APIError
+ body []byte
+ owner string
+ precomputed bool
+ once sync.Once
+ input string
+ previous *responseCacheLookupResult
+ err *api.APIError
+ attemptProvenance *nativeWSReplayProvenance
+ recoveryProvenance *nativeWSReplayProvenance
}
func newResponsesWSReplaySource(body []byte, owner string) *responsesWSReplaySource {
@@ -254,9 +303,13 @@ func newResponsesWSReplaySource(body []byte, owner string) *responsesWSReplaySou
if previousID := gjson.GetBytes(body, "previous_response_id").String(); previousID != "" {
respCache.mu.RLock()
if entry := respCache.store[responseCacheStoreKey(owner, previousID)]; entry != nil && time.Now().Before(entry.expiresAt) {
- source.previous = &responseCacheLookupResult{Kind: responseCacheLookupHit, Source: responseCacheSourceLocal, Items: append([]json.RawMessage(nil), entry.items...)}
+ source.previous = &responseCacheLookupResult{Kind: responseCacheLookupHit, Source: responseCacheSourceLocal, Items: append([]json.RawMessage(nil), entry.items...), nativeProvenance: entry.nativeProvenance}
}
respCache.mu.RUnlock()
+ if source.previous == nil && strings.HasPrefix(owner, nativeWSCachePrefix) {
+ lookup := nativeWSContextLookup(owner, previousID)
+ source.previous = &lookup
+ }
}
return source
}
@@ -271,25 +324,30 @@ func (s *responsesWSReplaySource) Input() string {
return ""
}
if s.precomputed {
- return s.input
+ return s.nativeValidatedInput()
}
s.once.Do(func() {
- s.input, s.err = responsesWSReplayInputWithLookup(s.body, s.owner, func(owner, id string) responseCacheLookupResult {
+ if strings.HasPrefix(s.owner, nativeWSCachePrefix) && s.previous == nil {
+ if id := gjson.GetBytes(s.body, "previous_response_id").String(); id != "" {
+ lookup := nativeWSContextLookup(s.owner, id)
+ s.previous = &lookup
+ }
+ }
+ options := responsesWSReplayOptions{owner: s.owner, allowOpaque: s.nativeReplayProvenance().matches(s.attemptProvenance)}
+ options.lookup = func(owner, id string) responseCacheLookupResult {
if s.previous != nil {
return *s.previous
}
- return lookupResponseCacheResultWithOwnership(owner, id, true)
- })
+ lookup := nativeWSLocalLookup(owner, id)
+ if !strings.HasPrefix(owner, nativeWSCachePrefix) {
+ lookup = lookupResponseCacheResultWithOwnership(owner, id, true)
+ } else {
+ lookup = nativeWSContextLookup(owner, id)
+ }
+ s.previous = &lookup
+ return lookup
+ }
+ s.input, s.err = responsesWSReplayInputChecked(s.body, options)
})
- return s.input
-}
-
-// Record failures only when returning them to a client, not while considering a
-// best-effort snapshot after an otherwise successful upstream turn.
-func responsesWSContextCloseCode(apiErr *api.APIError) int {
- if apiErr.Code == api.ErrCodeServiceUnavailable {
- return websocket.CloseInternalServerErr
- }
- recordResponseCacheKnownUnavailableError()
- return websocket.ClosePolicyViolation
+ return s.nativeValidatedInput()
}
diff --git a/proxy/responses_ws_local_context.go b/proxy/responses_ws_local_context.go
new file mode 100644
index 000000000..8ef2b7909
--- /dev/null
+++ b/proxy/responses_ws_local_context.go
@@ -0,0 +1,235 @@
+package proxy
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/codex2api/api"
+ "github.com/codex2api/auth"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+)
+
+const nativeWSCachePrefix = "native-ws:"
+const nativeWSCommitMaxEntries = 2000
+const nativeWSCommitWait = 5 * time.Second
+
+var nativeWSCommits = struct {
+ sync.Mutex
+ pending map[string]chan struct{}
+}{pending: make(map[string]chan struct{})}
+
+func nativeWSCacheOwner(owner string, identity requestSessionIdentity) string {
+ if identity.explicitUpstreamID == "" && !identity.hasDownstreamAffinity {
+ return nativeWSCachePrefix + owner
+ }
+ hash := sha256.Sum256([]byte(identity.affinityID))
+ return nativeWSCachePrefix + owner + ":" + hex.EncodeToString(hash[:])
+}
+
+type nativeWSTurnScope struct {
+ body []byte
+ identity requestSessionIdentity
+ databaseScope string
+}
+
+func nativeWSTurnCacheOwner(c *gin.Context, owner string, scope nativeWSTurnScope) string {
+ keyIdentity := deterministicPromptCacheKey(strings.TrimPrefix(strings.TrimSpace(downstreamAuthorizationHeader(c.Request)), "Bearer "), nil)
+ parts := []string{nativeWSCacheOwner(owner, scope.identity), responsesWSSessionPreemptScopeHash(c, scope.identity),
+ responsesWSTransportLane(c, scope.body, scope.identity), gjson.GetBytes(scope.body, "stream_id").String(), keyIdentity, scope.databaseScope}
+ hash := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
+ return nativeWSCachePrefix + hex.EncodeToString(hash[:])
+}
+
+func nativeWSLocalLookup(owner, responseID string) responseCacheLookupResult {
+ key := responseCacheStoreKey(owner, responseID)
+ respCache.mu.Lock()
+ defer respCache.mu.Unlock()
+ if entry := respCache.store[key]; entry != nil && time.Now().Before(entry.expiresAt) {
+ respCache.lru.MoveToFront(entry.element)
+ return responseCacheLookupResult{Kind: responseCacheLookupHit, Source: responseCacheSourceLocal,
+ Items: append([]json.RawMessage(nil), entry.items...), nativeProvenance: entry.nativeProvenance}
+ }
+ if marker := respCache.markers[key]; marker != nil && time.Now().Before(marker.expiresAt) {
+ return responseCacheLookupResult{Kind: marker.kind}
+ }
+ return responseCacheLookupResult{Kind: responseCacheLookupMiss}
+}
+
+// 终态暴露前建立屏障,跨下游重连须等提交完成再读取快照。
+func beginNativeWSCommit(owner, responseID string) func() {
+ if !strings.HasPrefix(owner, nativeWSCachePrefix) || responseID == "" {
+ return func() {}
+ }
+ key := responseCacheStoreKey(owner, responseID)
+ nativeWSCommits.Lock()
+ if nativeWSCommits.pending[key] != nil || len(nativeWSCommits.pending) >= nativeWSCommitMaxEntries {
+ nativeWSCommits.Unlock()
+ return func() {}
+ }
+ done := make(chan struct{})
+ nativeWSCommits.pending[key] = done
+ nativeWSCommits.Unlock()
+ finishShared := beginNativeWSSharedCommit(key)
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ finishShared()
+ nativeWSCommits.Lock()
+ delete(nativeWSCommits.pending, key)
+ close(done)
+ nativeWSCommits.Unlock()
+ })
+ }
+}
+
+func waitNativeWSCommit(ctx context.Context, owner, responseID string) error {
+ nativeWSCommits.Lock()
+ done := nativeWSCommits.pending[responseCacheStoreKey(owner, responseID)]
+ nativeWSCommits.Unlock()
+ if done == nil {
+ return waitNativeWSSharedCommit(ctx, owner, responseID)
+ }
+ timer := time.NewTimer(nativeWSCommitWait)
+ defer timer.Stop()
+ select {
+ case <-done:
+ return waitNativeWSSharedCommit(ctx, owner, responseID)
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return &ResponsesContinuationLostError{Reason: "context_commit_pending"}
+ }
+}
+
+func nativeWSReplayItem(item gjson.Result) (json.RawMessage, *api.APIError) {
+ return nativeWSReplayItemChecked(item, false)
+}
+
+func nativeWSReplayItemChecked(item gjson.Result, allowOpaque bool) (json.RawMessage, *api.APIError) {
+ if (item.Get("encrypted_content").String() != "" && !allowOpaque) || item.Get("type").String() == "item_reference" {
+ return nil, nativeResponsesWSContextError(responsesWSContextUnavailable(http.StatusConflict, "nonportable_context"))
+ }
+ raw, ok := normalizeReplayableCachedFunctionCall(json.RawMessage(item.Raw))
+ if !ok {
+ return nil, nativeResponsesWSContextError(responsesWSContextUnavailable(http.StatusConflict, "invalid_context_item"))
+ }
+ raw, ok = stripResponseItemID(raw)
+ if !ok {
+ return nil, nativeResponsesWSContextError(responsesWSContextUnavailable(http.StatusConflict, "invalid_context_item"))
+ }
+ return raw, nil
+}
+
+type nativeWSCompletedContext struct {
+ owner string
+ input string
+ completed []byte
+ outputs []json.RawMessage
+ provenance *nativeWSReplayProvenance
+}
+
+type nativeWSReplayProvenance struct {
+ accountID int64
+ generation int64
+ domain string
+}
+
+func nativeWSAccountProvenance(account *auth.Account) *nativeWSReplayProvenance {
+ if account == nil {
+ return nil
+ }
+ return &nativeWSReplayProvenance{accountID: account.ID(), generation: account.GetCredentialGeneration(), domain: accountCompactionDomain(account)}
+}
+
+func (provenance *nativeWSReplayProvenance) matches(current *nativeWSReplayProvenance) bool {
+ return provenance != nil && current != nil && *provenance == *current
+}
+
+func cacheNativeWSContext(snapshot nativeWSCompletedContext) {
+ owner, input, completed, outputs := snapshot.owner, snapshot.input, snapshot.completed, snapshot.outputs
+ responseID := gjson.GetBytes(completed, "response.id").String()
+ if input == "" || responseID == "" {
+ return
+ }
+ var items []json.RawMessage
+ if json.Unmarshal([]byte(input), &items) != nil {
+ return
+ }
+ terminal := gjson.GetBytes(completed, "response.output").Array()
+ if len(terminal) > len(outputs) {
+ outputs = nil
+ for _, item := range terminal {
+ outputs = append(outputs, json.RawMessage(item.Raw))
+ }
+ }
+ // 保留不透明输出,恢复时核对账号兼容性;不能丢掉必要推理后声称上下文完整。
+ items = mergeResponsesWSContext(items, outputs)
+ key := responseCacheStoreKey(owner, responseID)
+ items, admitted, _, serial := admitResponseCacheWithTicket(key, items)
+ respCache.mu.RLock()
+ expiresAt := time.Now().Add(respCache.config.ttl)
+ respCache.mu.RUnlock()
+ setNativeWSLocalMetadata(key, nativeWSLocalMetadata{serial: serial, provenance: snapshot.provenance, expiresAt: expiresAt})
+ record := nativeWSSharedContext{Version: nativeWSContextVersion, Items: items, ExpiresAt: expiresAt}
+ if snapshot.provenance != nil {
+ record.AccountID, record.Generation, record.Domain = snapshot.provenance.accountID, snapshot.provenance.generation, snapshot.provenance.domain
+ }
+ if !admitted && items == nil || len(record.Domain) > nativeWSDomainMaxBytes {
+ record.Items, record.Domain, record.Unavailable = nil, "", "oversize"
+ }
+ persistNativeWSContext(nativeWSSharedWrite{key: key, response: responseID, serial: serial, record: record})
+}
+
+func cacheCommittedResponsesWSContext(snapshot nativeWSCompletedContext) {
+ if strings.HasPrefix(snapshot.owner, nativeWSCachePrefix) {
+ cacheNativeWSContext(snapshot)
+ return
+ }
+ cacheResponsesWSCompletedResponse(snapshot.owner, snapshot.input, snapshot.completed, snapshot.outputs)
+}
+
+func (s *responsesWSReplaySource) setAccount(account *auth.Account) {
+ if s != nil {
+ s.attemptProvenance = nativeWSAccountProvenance(account)
+ }
+}
+
+func (s *responsesWSReplaySource) nativeReplayProvenance() *nativeWSReplayProvenance {
+ if s.previous != nil {
+ return s.previous.nativeProvenance
+ }
+ if s.recoveryProvenance != nil {
+ return s.recoveryProvenance
+ }
+ return s.attemptProvenance
+}
+
+func (s *responsesWSReplaySource) nativeValidatedInput() string {
+ if strings.Contains(s.input, `"encrypted_content"`) && !s.nativeReplayProvenance().matches(s.attemptProvenance) {
+ s.err = nativeResponsesWSContextError(responsesWSContextUnavailable(http.StatusConflict, "incompatible_encrypted_context"))
+ return ""
+ }
+ return s.input
+}
+
+func (s *responsesWSReplaySource) committedProvenance() *nativeWSReplayProvenance {
+ if s == nil {
+ return nil
+ }
+ return s.attemptProvenance
+}
+
+func newResponsesWSReplaySourceFromRecovery(input string, source *responsesWSReplaySource) *responsesWSReplaySource {
+ replay := newResponsesWSReplaySourceFromInput(input)
+ if source != nil && strings.Contains(input, `"encrypted_content"`) {
+ replay.recoveryProvenance = source.nativeReplayProvenance()
+ }
+ return replay
+}
diff --git a/proxy/responses_ws_native_context_test.go b/proxy/responses_ws_native_context_test.go
new file mode 100644
index 000000000..c6c3a80fb
--- /dev/null
+++ b/proxy/responses_ws_native_context_test.go
@@ -0,0 +1,256 @@
+package proxy
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/codex2api/auth"
+ "github.com/codex2api/config"
+ "github.com/codex2api/database"
+ "github.com/gin-gonic/gin"
+ "github.com/gorilla/websocket"
+ "github.com/tidwall/gjson"
+)
+
+type nativeWSFixture struct {
+ url string
+ account *auth.Account
+}
+
+func newNativeWSFixture(t *testing.T, execute func(context.Context, []byte) (*http.Response, error)) nativeWSFixture {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ resetResponseCacheForTest()
+ oldExecutor := WebsocketExecuteFunc
+ t.Cleanup(func() {
+ WebsocketExecuteFunc = oldExecutor
+ resetResponseCacheForTest()
+ })
+ WebsocketExecuteFunc = func(ctx context.Context, account *auth.Account, body []byte, _, _, _ string, _ *DeviceProfileConfig, _ http.Header, _ string) (*http.Response, error) {
+ if int64(1) != account.GetActiveRequests() {
+ t.Error("upstream request did not occupy account capacity")
+ }
+ return execute(ctx, body)
+ }
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 1})
+ t.Cleanup(store.Stop)
+ account := &auth.Account{DBID: 1, AccessToken: "fixture", PlanType: "plus"}
+ store.AddAccount(account)
+ handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil)
+ router := gin.New()
+ handler.RegisterRoutes(router)
+ server := httptest.NewServer(router)
+ t.Cleanup(server.Close)
+ return nativeWSFixture{url: "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses", account: account}
+}
+
+func (fixture nativeWSFixture) connect(t *testing.T) *websocket.Conn {
+ t.Helper()
+ conn, _, err := websocket.DefaultDialer.Dial(fixture.url, http.Header{"Session-Id": {"native-context"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { conn.Close() })
+ return conn
+}
+
+func nativeWSTurn(t *testing.T, conn *websocket.Conn, input string) []byte {
+ t.Helper()
+ var request map[string]any
+ if err := json.Unmarshal([]byte(input), &request); err != nil {
+ t.Fatal(err)
+ }
+ request["type"], request["model"], request["store"] = "response.create", "gpt-5.5", false
+ if err := conn.WriteJSON(request); err != nil {
+ t.Fatal(err)
+ }
+ conn.SetReadDeadline(time.Now().Add(3 * time.Second))
+ return readResponsesWSTerminalEvent(t, conn)
+}
+
+func nativeWSResponse(id string, outputs ...string) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(wsContextTestSSE(id, outputs...)))}
+}
+
+func TestNativeWSStoreFalseRecoversToolChainAfterReconnect(t *testing.T) {
+ for _, failure := range []string{"binding_lost", "invalid_id"} {
+ t.Run(failure, func(t *testing.T) {
+ var calls atomic.Int32
+ fixture := newNativeWSFixture(t, func(_ context.Context, body []byte) (*http.Response, error) {
+ switch calls.Add(1) {
+ case 1:
+ return nativeWSResponse("one", `{"type":"reasoning","encrypted_content":"fixture-opaque"}`, `{"type":"function_call","call_id":"a","name":"exec","arguments":"{}"}`), nil
+ case 2:
+ if int64(1) != gjson.GetBytes(body, "input.#").Int() || "one" != gjson.GetBytes(body, "previous_response_id").String() {
+ t.Error("healthy continuation repeated history")
+ }
+ return nativeWSResponse("two", `{"type":"function_call","call_id":"b","name":"exec","arguments":"{}"}`), nil
+ case 3:
+ return nativeWSContinuationFailure(failure), nativeWSContinuationError(failure)
+ default:
+ if gjson.GetBytes(body, "previous_response_id").Exists() || int64(7) != gjson.GetBytes(body, "input.#").Int() || "fixture-opaque" != gjson.GetBytes(body, "input.2.encrypted_content").String() {
+ t.Errorf("incomplete or duplicate recovery: %s", body)
+ }
+ return nativeWSResponse("recovered"), nil
+ }
+ })
+ conn := fixture.connect(t)
+ assertNativeWSSuccess(t, nativeWSTurn(t, conn, `{"input":[{"type":"additional_tools","tools":[]},{"role":"user","content":"root"}]}`))
+ assertNativeWSSuccess(t, nativeWSTurn(t, conn, `{"previous_response_id":"one","input":[{"type":"function_call_output","call_id":"a","output":"1"}]}`))
+ conn.Close()
+ conn = fixture.connect(t)
+ assertNativeWSSuccess(t, nativeWSTurn(t, conn, `{"previous_response_id":"two","input":[{"type":"function_call_output","call_id":"b","output":"2"}]}`))
+ if int32(4) != calls.Load() {
+ t.Fatal("recovery repeated")
+ }
+ })
+ }
+}
+
+func TestNativeWSEncryptedRecoveryRequiresCompatibleAccount(t *testing.T) {
+ resetResponseCacheForTest()
+ t.Cleanup(resetResponseCacheForTest)
+ owner := nativeWSCachePrefix + "fixture"
+ account := &auth.Account{DBID: 1, CredentialGeneration: 1}
+ cacheNativeWSContext(nativeWSCompletedContext{owner: owner, input: `[{"role":"user","content":"root"}]`,
+ completed: []byte(`{"response":{"id":"encrypted","output":[{"type":"reasoning","encrypted_content":"fixture"}]}}`),
+ provenance: nativeWSAccountProvenance(account)})
+ body := []byte(`{"previous_response_id":"encrypted","input":[{"role":"user","content":"next"}]}`)
+ source := newResponsesWSReplaySource(body, owner)
+ source.setAccount(account)
+ input := source.Input()
+ if "fixture" != gjson.Get(input, "1.encrypted_content").String() {
+ t.Fatal("compatible recovery discarded encrypted reasoning")
+ }
+ recovered := newResponsesWSReplaySourceFromRecovery(input, source)
+ for _, changed := range []*auth.Account{{DBID: 2, CredentialGeneration: 1}, {DBID: 1, CredentialGeneration: 2}} {
+ for _, snapshot := range []*responsesWSReplaySource{source, recovered} {
+ snapshot.setAccount(changed)
+ if "" != snapshot.Input() || snapshot.err == nil {
+ t.Fatal("encrypted context replayed on incompatible account")
+ }
+ }
+ }
+}
+
+func TestNativeWSCacheSeparatesStreamAndExecutionLane(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
+ identity := requestSessionIdentity{affinityID: "thread", explicitUpstreamID: "thread"}
+ root := nativeWSTurnCacheOwner(c, "key:1", nativeWSTurnScope{body: []byte(`{"stream_id":"main"}`), identity: identity})
+ for _, body := range []string{`{"stream_id":"other"}`, `{"stream_id":"main","client_metadata":{"x-codex-turn-metadata":"{\"request_kind\":\"memory\"}"}}`} {
+ if root == nativeWSTurnCacheOwner(c, "key:1", nativeWSTurnScope{body: []byte(body), identity: identity}) {
+ t.Fatalf("execution lane shared native context: %s", body)
+ }
+ }
+ for _, key := range []string{"fixture-key-one", "fixture-key-two"} {
+ c.Request.Header.Set("Authorization", "Bearer "+key)
+ other := nativeWSTurnCacheOwner(c, "key:1", nativeWSTurnScope{body: []byte(`{"stream_id":"main"}`), identity: identity})
+ if root == other {
+ t.Fatal("static API keys shared native context")
+ }
+ root = other
+ }
+}
+
+func nativeWSContinuationFailure(failure string) *http.Response {
+ if failure == "binding_lost" {
+ return nil
+ }
+ return &http.Response{StatusCode: http.StatusBadRequest, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Invalid `previous_response_id`.\"}}"))}
+}
+
+func nativeWSContinuationError(failure string) error {
+ if failure == "binding_lost" {
+ return &ResponsesContinuationLostError{Reason: "original_connection_unavailable"}
+ }
+ return nil
+}
+
+func assertNativeWSSuccess(t *testing.T, terminal []byte) {
+ t.Helper()
+ if "response.completed" != gjson.GetBytes(terminal, "type").String() {
+ t.Fatalf("unexpected terminal: %s", terminal)
+ }
+}
+
+func TestNativeWSMissingContextKeepsDownstreamUsable(t *testing.T) {
+ fixture := newNativeWSFixture(t, func(_ context.Context, body []byte) (*http.Response, error) {
+ if gjson.GetBytes(body, "previous_response_id").Exists() {
+ return nil, &ResponsesContinuationLostError{Reason: "original_connection_unavailable"}
+ }
+ return nativeWSResponse("fresh"), nil
+ })
+ conn := fixture.connect(t)
+ terminal := nativeWSTurn(t, conn, `{"previous_response_id":"missing","input":[{"role":"user","content":"next"}]}`)
+ if "previous_response_not_found" != gjson.GetBytes(terminal, "error.code").String() || int64(409) != gjson.GetBytes(terminal, "status").Int() {
+ t.Fatalf("missing continuation classification: %s", terminal)
+ }
+ assertNativeWSSuccess(t, nativeWSTurn(t, conn, `{"input":[{"role":"user","content":"full history"}]}`))
+}
+
+func TestNativeWSCacheRejectsIncompleteSnapshotsAndIsolatesLegacyNamespace(t *testing.T) {
+ resetResponseCacheForTest()
+ t.Cleanup(resetResponseCacheForTest)
+ backend := newRecordingResponseContextBackend(true)
+ SetResponseContextCache(backend)
+ t.Cleanup(func() { backend.TokenCache.Close() })
+ owner := nativeWSCachePrefix + "key:1"
+ input := `[{"role":"user","content":"root"}]`
+ completed := []byte(`{"response":{"id":"one","output":[]}}`)
+ cacheResponsesWSCompletedResponse(owner, input, completed, []json.RawMessage{json.RawMessage(`{"type":"reasoning","encrypted_content":"opaque"}`)})
+ next := []byte(`{"previous_response_id":"one","input":[]}`)
+ if _, _, err := degradeResponsesWSContinuationWithSource(next, owner, newResponsesWSReplaySource(next, owner)); err == nil || err.Code != "previous_response_not_found" {
+ t.Fatal("opaque reasoning was silently removed")
+ }
+ missing := []byte(`{"previous_response_id":"missing","input":[]}`)
+ _, _, _ = degradeResponsesWSContinuationWithSource(missing, owner, newResponsesWSReplaySource(missing, owner))
+ drainResponseCacheBackendWrites()
+ if sets, gets := backend.counts(); sets != 0 || gets != 0 {
+ t.Fatalf("native context accessed legacy namespace: writes=%d reads=%d", sets, gets)
+ }
+ config := defaultResponseCacheConfig()
+ config.maxItems = 1
+ configureResponseCacheForTest(config)
+ cacheResponsesWSCompletedResponse(owner, input, completed, []json.RawMessage{json.RawMessage(`{"role":"assistant","content":"reply"}`)})
+ if responseCacheLookupHit == nativeWSLocalLookup(owner, "one").Kind {
+ t.Fatal("tail-trimmed context admitted")
+ }
+}
+
+func TestNativeWSCommitBarrierAndScope(t *testing.T) {
+ owner := nativeWSCacheOwner("key:1", requestSessionIdentity{affinityID: "thread", explicitUpstreamID: "thread"})
+ other := nativeWSCacheOwner("key:1", requestSessionIdentity{affinityID: "other", explicitUpstreamID: "other"})
+ if owner == other || owner == nativeWSCacheOwner("key:2", requestSessionIdentity{affinityID: "thread", explicitUpstreamID: "thread"}) {
+ t.Fatal("cache scope is not isolated")
+ }
+ finish := beginNativeWSCommit(owner, "pending")
+ defer finish()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := waitNativeWSCommit(ctx, owner, "pending"); err != context.Canceled {
+ t.Fatalf("commit barrier ignored cancellation: %v", err)
+ }
+ finish()
+ if err := waitNativeWSCommit(context.Background(), owner, "pending"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPreviousIDErrorRecognitionIsNarrow(t *testing.T) {
+ for _, message := range []string{"Invalid `previous_response_id`.", "Invalid previous_response_id", "Invalid model", "Invalid request", "previous_response_id format unsupported"} {
+ payload, _ := json.Marshal(map[string]any{"error": map[string]string{"type": "invalid_request_error", "message": message}})
+ want := strings.HasPrefix(message, "Invalid ") && strings.Contains(message, "previous_response_id")
+ if want != isPreviousResponseNotFoundBody(payload) {
+ t.Fatal(fmt.Sprintf("wrong classification: %s", payload))
+ }
+ }
+}
diff --git a/proxy/responses_ws_recovery_error.go b/proxy/responses_ws_recovery_error.go
new file mode 100644
index 000000000..4b42fcc54
--- /dev/null
+++ b/proxy/responses_ws_recovery_error.go
@@ -0,0 +1,31 @@
+package proxy
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/codex2api/api"
+ "github.com/gorilla/websocket"
+)
+
+func responsesWSErrorStatus(err *api.APIError) int {
+ switch string(err.Code) {
+ case "previous_response_not_found", string(api.ErrCodeResponseContextUnavailable):
+ return http.StatusConflict
+ case ErrorCodeAccountPoolConcurrencySaturated, ErrorCodeUpstreamWSConnectionCapacity:
+ return http.StatusServiceUnavailable
+ }
+ if err.Type == api.ErrorTypeRateLimit {
+ return http.StatusTooManyRequests
+ }
+ if err.Type == api.ErrorTypeInvalidRequest && api.HTTPStatusCode(err.Code) == http.StatusInternalServerError {
+ return http.StatusBadRequest
+ }
+ return api.HTTPStatusCode(err.Code)
+}
+
+func writeResponsesWSRecoveryError(conn *websocket.Conn, err *api.APIError) error {
+ recordResponseCacheKnownUnavailableError()
+ log.Printf("Responses WebSocket context recovery unavailable: code=%s reason=%s", err.Code, responsesWSContextReason(err))
+ return writeResponsesWSError(conn, err)
+}
diff --git a/proxy/responses_ws_shared_context.go b/proxy/responses_ws_shared_context.go
new file mode 100644
index 000000000..604f1d5c4
--- /dev/null
+++ b/proxy/responses_ws_shared_context.go
@@ -0,0 +1,253 @@
+package proxy
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/codex2api/auth"
+ "github.com/codex2api/cache"
+ "github.com/google/uuid"
+)
+
+const (
+ nativeWSContextNamespace = "native-ws-context"
+ nativeWSPendingNamespace = "native-ws-context-pending"
+ nativeWSContextVersion = 1
+ nativeWSMetadataBytes = int64(4096)
+ nativeWSDomainMaxBytes = 1024
+ nativeWSPendingMaxBytes = int64(64)
+ nativeWSCommitPoll = 100 * time.Millisecond
+)
+
+type nativeWSSharedContext struct {
+ Version int `json:"version"`
+ Items []json.RawMessage `json:"items"`
+ AccountID int64 `json:"account_id,omitempty"`
+ Generation int64 `json:"generation,omitempty"`
+ Domain string `json:"domain,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Unavailable string `json:"unavailable,omitempty"`
+}
+
+type nativeWSSharedWrite struct {
+ key string
+ response string
+ serial uint64
+ record nativeWSSharedContext
+}
+
+func nativeWSReplayAccountFilter(filter auth.AccountFilter, source *responsesWSReplaySource) auth.AccountFilter {
+ if source == nil || source.previous == nil || source.previous.nativeProvenance == nil {
+ return filter
+ }
+ previous := source.previous
+ hasOpaque := false
+ for _, item := range previous.Items {
+ if strings.Contains(string(item), `"encrypted_content"`) {
+ hasOpaque = true
+ break
+ }
+ }
+ if !hasOpaque || previous.nativeProvenance.accountID <= 0 {
+ return filter
+ }
+ return func(account *auth.Account) bool {
+ return account.ID() == previous.nativeProvenance.accountID && (filter == nil || filter(account))
+ }
+}
+
+func nativeWSSharedBackend() cache.TokenCache {
+ respCache.mu.RLock()
+ defer respCache.mu.RUnlock()
+ return respCache.runtimeCache
+}
+
+func nativeWSContextLookup(owner, responseID string) responseCacheLookupResult {
+ local := nativeWSLocalLookup(owner, responseID)
+ if local.Kind == responseCacheLookupHit || responseID == "" {
+ return local
+ }
+ backend := nativeWSSharedBackend()
+ if backend == nil {
+ return local
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), responseCacheBackendSyncTimeout)
+ defer cancel()
+ respCache.mu.RLock()
+ config := respCache.config
+ respCache.mu.RUnlock()
+ key := responseCacheStoreKey(owner, responseID)
+ raw, found, err := readNativeWSRuntime(ctx, backend, nativeWSRuntimeRead{namespace: nativeWSContextNamespace, key: key,
+ maxBytes: responseContextWireLimit(config) + nativeWSMetadataBytes})
+ if errors.Is(err, cache.ErrRuntimeSnapshotTooLarge) {
+ return responseCacheLookupResult{Kind: responseCacheLookupReconstructionTooLarge}
+ }
+ if err != nil {
+ return responseCacheLookupResult{Kind: responseCacheLookupBackendError, Err: err}
+ }
+ if !found {
+ if local.Kind == responseCacheLookupBackendError {
+ return local
+ }
+ return responseCacheLookupResult{Kind: responseCacheLookupMiss, remoteMiss: true}
+ }
+ return hydrateNativeWSContext(key, raw, config)
+}
+
+type nativeWSRuntimeRead struct {
+ namespace string
+ key string
+ maxBytes int64
+}
+
+func readNativeWSRuntime(ctx context.Context, backend cache.TokenCache, read nativeWSRuntimeRead) ([]byte, bool, error) {
+ if bounded, ok := backend.(cache.BoundedRuntimeReader); ok {
+ return bounded.GetRuntimeBounded(ctx, read.namespace, read.key, read.maxBytes)
+ }
+ raw, found, err := backend.GetRuntime(ctx, read.namespace, read.key)
+ if int64(len(raw)) > read.maxBytes {
+ return nil, false, cache.ErrRuntimeSnapshotTooLarge
+ }
+ return raw, found, err
+}
+
+func hydrateNativeWSContext(key string, raw []byte, config responseCacheConfig) responseCacheLookupResult {
+ var record nativeWSSharedContext
+ if json.Unmarshal(raw, &record) != nil || record.Version != nativeWSContextVersion || record.ExpiresAt.IsZero() || len(record.Domain) > nativeWSDomainMaxBytes {
+ return responseCacheLookupResult{Kind: responseCacheLookupBackendCorrupt}
+ }
+ if !time.Now().Before(record.ExpiresAt) {
+ return responseCacheLookupResult{Kind: responseCacheLookupExpired}
+ }
+ if record.Unavailable != "" {
+ kind := responseCacheLookupBackendError
+ if record.Unavailable == "oversize" {
+ kind = responseCacheLookupReconstructionTooLarge
+ }
+ return responseCacheLookupResult{Kind: kind}
+ }
+ items, err := cache.NormalizeResponseContextItems(record.Items)
+ if err != nil {
+ return responseCacheLookupResult{Kind: responseCacheLookupBackendCorrupt}
+ }
+ if len(items) > config.maxItems || responseContextLogicalBytes(items) > config.reconstructMaxBytes {
+ return responseCacheLookupResult{Kind: responseCacheLookupReconstructionTooLarge}
+ }
+ provenance := &nativeWSReplayProvenance{accountID: record.AccountID, generation: record.Generation, domain: record.Domain}
+ items, admitted, oversize, serial := admitResponseCacheWithTicket(key, items)
+ setNativeWSLocalMetadata(key, nativeWSLocalMetadata{serial: serial, provenance: provenance, expiresAt: record.ExpiresAt})
+ return responseCacheLookupResult{Kind: responseCacheLookupHit, Source: responseCacheSourceBackend,
+ Items: items, nativeProvenance: provenance, Promoted: admitted, oversizeBypass: !admitted && oversize}
+}
+
+type nativeWSLocalMetadata struct {
+ serial uint64
+ provenance *nativeWSReplayProvenance
+ expiresAt time.Time
+}
+
+func setNativeWSLocalMetadata(key string, metadata nativeWSLocalMetadata) {
+ respCache.mu.Lock()
+ defer respCache.mu.Unlock()
+ if entry := respCache.store[key]; entry != nil && entry.serial == metadata.serial {
+ entry.nativeProvenance = metadata.provenance
+ entry.expiresAt = metadata.expiresAt
+ }
+}
+
+func persistNativeWSContext(write nativeWSSharedWrite) {
+ backend := nativeWSSharedBackend()
+ if backend == nil {
+ return
+ }
+ if err := writeNativeWSContext(backend, write); err != nil {
+ write.record.Items = nil
+ write.record.Unavailable = "backend_error"
+ ctx, cancel := context.WithTimeout(context.Background(), responseCacheBackendSyncTimeout)
+ defer cancel()
+ _ = storeNativeWSRecord(ctx, backend, write)
+ }
+}
+
+func writeNativeWSContext(backend cache.TokenCache, write nativeWSSharedWrite) (err error) {
+ writer := responseCacheBackendWriter
+ writer.start()
+ defer writer.finish()
+ defer func() { recordResponseCacheBackendWriteResult(write.key, write.response, write.serial, err) }()
+ bytes := responseContextLogicalBytes(write.record.Items)
+ waitCtx, cancel := context.WithTimeout(context.Background(), responseCacheBackendWaitTimeout)
+ _, err = writer.acquire(waitCtx, bytes)
+ cancel()
+ if err != nil {
+ return err
+ }
+ defer writer.release(bytes)
+ ctx, cancel := context.WithTimeout(context.Background(), responseCacheBackendWriteTimeout)
+ defer cancel()
+ return storeNativeWSRecord(ctx, backend, write)
+}
+
+func storeNativeWSRecord(ctx context.Context, backend cache.TokenCache, write nativeWSSharedWrite) error {
+ raw, err := json.Marshal(write.record)
+ if err != nil {
+ return err
+ }
+ ttl := time.Until(write.record.ExpiresAt)
+ if ttl <= 0 {
+ return nil
+ }
+ return backend.SetRuntime(ctx, nativeWSContextNamespace, write.key, raw, ttl)
+}
+
+func beginNativeWSSharedCommit(key string) func() {
+ backend := nativeWSSharedBackend()
+ owners, ok := backend.(cache.RuntimeOwnerStore)
+ if !ok {
+ return func() {}
+ }
+ owner, _ := json.Marshal(uuid.NewString())
+ ctx, cancel := context.WithTimeout(context.Background(), responseCacheBackendSyncTimeout)
+ _, err := owners.ClaimRuntimeOwner(ctx, nativeWSPendingNamespace, key, owner, nativeWSCommitWait+responseCacheBackendWaitTimeout+responseCacheBackendWriteTimeout)
+ cancel()
+ if err != nil {
+ log.Printf("登记 Redis 原生 WS 提交屏障失败: %v", err)
+ return func() {}
+ }
+ return func() {
+ ctx, cancel := context.WithTimeout(context.Background(), responseCacheBackendSyncTimeout)
+ defer cancel()
+ _, _ = owners.CompareAndDeleteRuntimeOwner(ctx, nativeWSPendingNamespace, key, owner)
+ }
+}
+
+func waitNativeWSSharedCommit(ctx context.Context, owner, responseID string) error {
+ backend := nativeWSSharedBackend()
+ if backend == nil || responseID == "" || !strings.HasPrefix(owner, nativeWSCachePrefix) {
+ return nil
+ }
+ if nativeWSLocalLookup(owner, responseID).Kind == responseCacheLookupHit {
+ return nil
+ }
+ waitCtx, cancel := context.WithTimeout(ctx, nativeWSCommitWait)
+ defer cancel()
+ ticker := time.NewTicker(nativeWSCommitPoll)
+ defer ticker.Stop()
+ for {
+ readCtx, readCancel := context.WithTimeout(waitCtx, responseCacheBackendSyncTimeout)
+ _, pending, err := readNativeWSRuntime(readCtx, backend,
+ nativeWSRuntimeRead{namespace: nativeWSPendingNamespace, key: responseCacheStoreKey(owner, responseID), maxBytes: nativeWSPendingMaxBytes})
+ readCancel()
+ if err != nil || !pending {
+ return err
+ }
+ select {
+ case <-waitCtx.Done():
+ return waitCtx.Err()
+ case <-ticker.C:
+ }
+ }
+}
diff --git a/proxy/review_regression_test.go b/proxy/review_regression_test.go
index 035e21081..a1ed54190 100644
--- a/proxy/review_regression_test.go
+++ b/proxy/review_regression_test.go
@@ -277,8 +277,11 @@ func newReviewWSClient(t *testing.T, execute func(context.Context, *auth.Account
func TestReviewWSHTTPFallbackMissingContextReleasesLease(t *testing.T) {
resetResponseCacheForTest()
- conn, account := newReviewWSClient(t, func(context.Context, *auth.Account, []byte, string, string, string, *DeviceProfileConfig, http.Header, string) (*http.Response, error) {
- return nil, errors.New("websocket: close 1009 (message too big)")
+ conn, account := newReviewWSClient(t, func(_ context.Context, _ *auth.Account, body []byte, _, _, _ string, _ *DeviceProfileConfig, _ http.Header, _ string) (*http.Response, error) {
+ if gjson.GetBytes(body, "previous_response_id").Exists() {
+ return nil, errors.New("websocket: close 1009 (message too big)")
+ }
+ return nativeWSResponse("fresh"), nil
})
if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"gpt-5.5","previous_response_id":"missing","input":[{"type":"message","role":"user","content":"next"}]}`)); err != nil {
t.Fatal(err)
@@ -288,13 +291,13 @@ func TestReviewWSHTTPFallbackMissingContextReleasesLease(t *testing.T) {
if err != nil || gjson.GetBytes(body, "error.code").String() != "response_context_unavailable" {
t.Fatalf("missing fail-closed error: %s %v", body, err)
}
- _, _, err = conn.ReadMessage()
- if !websocket.IsCloseError(err, websocket.ClosePolicyViolation) || account.GetActiveRequests() != 0 {
- t.Fatalf("fallback lease leaked: %d, %v", account.GetActiveRequests(), err)
+ if int64(http.StatusConflict) != gjson.GetBytes(body, "status").Int() || int64(0) != account.GetActiveRequests() {
+ t.Fatalf("fallback status or lease invalid: %s, active=%d", body, account.GetActiveRequests())
}
if GetResponseCacheStats().KnownUnavailableErrors != 1 {
t.Fatal("WS failure was not counted")
}
+ assertNativeWSSuccess(t, nativeWSTurn(t, conn, `{"input":[{"role":"user","content":"fresh history"}]}`))
}
func TestReviewWSStoreFalseSkipsAlwaysCache(t *testing.T) {
diff --git a/proxy/runtime_config.go b/proxy/runtime_config.go
index 3655f83c1..ee128a842 100644
--- a/proxy/runtime_config.go
+++ b/proxy/runtime_config.go
@@ -101,10 +101,10 @@ type RuntimeSettings struct {
CodexWSBusyMaxWaitSec int // busy session/容量等待的累计上限秒数(默认 30,issue #413)
CodexWSBusyOverflow bool // busy session 溢出到同账号兄弟连接(默认 false)
CodexWSBusyPatienceSec int // 触发溢出前的短等待秒数(默认 2)
- // CodexWSStatelessSlots 无状态请求每 (账号, cacheKey) 维度的持久连接槽位数
- // (默认 8,范围 1-32,issue #522)。调大→单账号挂更多空闲连接;调小→握手更频繁,
- // 高 RPM 下可能触发上游握手限流。实际生效值仍受账号动态并发上限钳制。
+ // CodexWSStatelessSlots 限制每账号的空白连接预算及通用池扫描窗口。
CodexWSStatelessSlots int
+ // CodexWSDownstreamKeepaliveSlots 独立限制每账号输出收尾后保留的空闲聊天连接。
+ CodexWSDownstreamKeepaliveSlots int
// GithubToken 用于 api.github.com 请求的 Personal Access Token(提升 API 限流配额;
// 只发给 api.github.com,绝不发给镜像或其他主机;空表示未配置,issue #522)。
GithubToken string
@@ -202,6 +202,7 @@ func DefaultRuntimeSettings() RuntimeSettings {
CodexWSBusyMaxWaitSec: defaultCodexWSBusyMaxWaitSec,
CodexWSBusyPatienceSec: defaultCodexWSBusyPatienceSec,
CodexWSStatelessSlots: defaultCodexWSStatelessSlots,
+ CodexWSDownstreamKeepaliveSlots: database.DefaultCodexWSDownstreamKeepaliveSlots,
CodexOverloadThresholdPercent: database.NormalizeCodexOverloadThresholdPercent(0),
CodexOverloadPauseMinutes: database.NormalizeCodexOverloadPauseMinutes(0),
CodexOverloadWindowMinutes: database.NormalizeCodexOverloadWindowMinutes(0),
@@ -321,12 +322,13 @@ func NormalizeRuntimeSettings(settings RuntimeSettings) RuntimeSettings {
if settings.CodexWSBusyPatienceSec > maxCodexWSBusyWaitSec {
settings.CodexWSBusyPatienceSec = maxCodexWSBusyWaitSec
}
- if settings.CodexWSStatelessSlots <= 0 {
+ if settings.CodexWSStatelessSlots < 0 {
settings.CodexWSStatelessSlots = defaultCodexWSStatelessSlots
}
if settings.CodexWSStatelessSlots > maxCodexWSStatelessSlots {
settings.CodexWSStatelessSlots = maxCodexWSStatelessSlots
}
+ settings.CodexWSDownstreamKeepaliveSlots = database.NormalizeCodexWSDownstreamKeepaliveSlots(settings.CodexWSDownstreamKeepaliveSlots)
settings.CodexOverloadThresholdPercent = database.NormalizeCodexOverloadThresholdPercent(settings.CodexOverloadThresholdPercent)
settings.CodexOverloadPauseMinutes = database.NormalizeCodexOverloadPauseMinutes(settings.CodexOverloadPauseMinutes)
settings.CodexOverloadWindowMinutes = database.NormalizeCodexOverloadWindowMinutes(settings.CodexOverloadWindowMinutes)
@@ -376,6 +378,7 @@ func ApplyRuntimeSettingsFromSystem(settings *database.SystemSettings) RuntimeSe
next.CodexWSBusyOverflow = settings.CodexWSBusyOverflowEnabled
next.CodexWSBusyPatienceSec = settings.CodexWSBusyPatienceSec
next.CodexWSStatelessSlots = settings.CodexWSStatelessSlots
+ next.CodexWSDownstreamKeepaliveSlots = settings.CodexWSDownstreamKeepaliveSlots
next.GithubToken = strings.TrimSpace(settings.GithubToken)
next.GithubProxyURL = strings.TrimSpace(settings.GithubProxyURL)
next.CodexOverloadPauseEnabled = settings.CodexOverloadPauseEnabled
diff --git a/proxy/scheduler_queue_error_test.go b/proxy/scheduler_queue_error_test.go
index 27863f812..08c9f3ba0 100644
--- a/proxy/scheduler_queue_error_test.go
+++ b/proxy/scheduler_queue_error_test.go
@@ -220,9 +220,15 @@ func TestSchedulerQueueOverloadResponsesWebSocket(t *testing.T) {
if json.Unmarshal(payload, &event) != nil || event["type"] != "error" || !strings.Contains(string(payload), schedulerQueueFullMessage) {
t.Fatalf("WS overload frame = %s", payload)
}
- _, _, err = conn.ReadMessage()
- if !websocket.IsCloseError(err, websocket.CloseTryAgainLater) {
- t.Fatalf("WS overload close = %v", err)
+ if float64(http.StatusServiceUnavailable) != event["status"] {
+ t.Fatalf("WS overload status = %v", event["status"])
+ }
+ if err := conn.WriteJSON(map[string]any{"type": "response.create", "model": "gpt-5.5", "input": "retry"}); err != nil {
+ t.Fatal(err)
+ }
+ _, payload, err = conn.ReadMessage()
+ if err != nil || !strings.Contains(string(payload), schedulerQueueFullMessage) {
+ t.Fatalf("WS overload prevented another turn: %s %v", payload, err)
}
}
diff --git a/proxy/upstream_ws_capacity.go b/proxy/upstream_ws_capacity.go
new file mode 100644
index 000000000..8528a095f
--- /dev/null
+++ b/proxy/upstream_ws_capacity.go
@@ -0,0 +1,22 @@
+package proxy
+
+import (
+ "fmt"
+ "net/http"
+)
+
+const ErrorCodeUpstreamWSConnectionCapacity = "upstream_ws_connection_capacity_exceeded"
+
+// UpstreamWSConnectionCapacityError 是本地连接容量拒绝,不是上游账号故障。
+type UpstreamWSConnectionCapacityError struct {
+ Limit int
+}
+
+func (err *UpstreamWSConnectionCapacityError) Error() string {
+ return fmt.Sprintf("Upstream WebSocket connection capacity reached (max %d); existing continuations are protected, please retry later", err.Limit)
+}
+
+func (err *UpstreamWSConnectionCapacityError) Unwrap() error {
+ return &Error{Code: ErrorCodeUpstreamWSConnectionCapacity, Type: ErrorTypeServerError,
+ HTTPStatus: http.StatusServiceUnavailable, Message: err.Error()}
+}
diff --git a/proxy/wsrelay/acquire_wakeup_test.go b/proxy/wsrelay/acquire_wakeup_test.go
index 07b0334f0..99f3e65b1 100644
--- a/proxy/wsrelay/acquire_wakeup_test.go
+++ b/proxy/wsrelay/acquire_wakeup_test.go
@@ -58,9 +58,13 @@ func TestAccountWaitSignalClosedByPoolChanges(t *testing.T) {
// 拨号占位归还 → 唤醒
third := manager.accountWaitSignal(accountID)
- manager.releaseAccountConnectionCapacity(accountID)
+ capacity := manager.reserveAccountConnectionCapacity(connectionCapacityRequest{accountID: accountID, limit: connectionLimit(capacityChat)})
+ if capacity == nil {
+ t.Fatal("dial capacity reservation failed")
+ }
+ capacity.release()
if !signalClosed(third) {
- t.Fatal("releaseAccountConnectionCapacity did not wake account waiters")
+ t.Fatal("capacity release did not wake account waiters")
}
// 没有等待者时 notify 是空操作,不应留下条目。
diff --git a/proxy/wsrelay/busy_policy.go b/proxy/wsrelay/busy_policy.go
index 832dfbc64..047dbdf79 100644
--- a/proxy/wsrelay/busy_policy.go
+++ b/proxy/wsrelay/busy_policy.go
@@ -55,11 +55,8 @@ func isBusyOverflowSessionKey(sessionKey string) bool {
return strings.Contains(sessionKey, busyOverflowKeyInfix)
}
-// statelessConnectionSlots 返回无状态连接槽位数(系统设置 codex_ws_stateless_slots,
-// 热更新生效,RuntimeSettings 已钳 1~32;0 值兜底回内置常量,issue #522)。
+// statelessConnectionSlots 返回账号空白连接预算及通用池扫描窗口(系统设置 codex_ws_stateless_slots,
+// 热更新生效,范围 0~32;0 表示不保留空白连接、不扫描通用池)。
func statelessConnectionSlots() int {
- if v := proxy.CurrentRuntimeSettings().CodexWSStatelessSlots; v > 0 {
- return v
- }
- return StatelessConnectionSlots
+ return proxy.CurrentRuntimeSettings().CodexWSStatelessSlots
}
diff --git a/proxy/wsrelay/chat_retention.go b/proxy/wsrelay/chat_retention.go
new file mode 100644
index 000000000..736646ab3
--- /dev/null
+++ b/proxy/wsrelay/chat_retention.go
@@ -0,0 +1,79 @@
+package wsrelay
+
+import "sort"
+
+// 调用方持有账号锁。推理、拨号及尚未承载聊天的连接不占空闲保留额度。
+func (m *Manager) trimIdleChatConnections(input connectionCapacityRequest) {
+ input.kind = capacityChat
+ idle := m.idleCapacityConnections(input)
+ sort.Slice(idle, func(i, j int) bool {
+ return idle[i].lastUsed < idle[j].lastUsed
+ })
+ excess := len(idle) - input.limit
+ for _, candidate := range idle {
+ if excess <= 0 {
+ return
+ }
+ if m.discardIdleChatConnection(candidate.wc) {
+ excess--
+ }
+ }
+}
+
+// 即使存在续链绑定也可按 LRU 回收;账号锁阻止候选被新推理同时取走。
+func (m *Manager) discardIdleChatConnection(wc *WsConnection) bool {
+ m.respConnMu.Lock()
+ current, exists := m.connections.Load(wc.PoolKey)
+ if !exists || current != wc || wc.session.PendingCount() != 0 {
+ m.respConnMu.Unlock()
+ return false
+ }
+ wc.responseBindingsDisabled = true
+ m.respConnMu.Unlock()
+ m.discardConnectionFor(wc, closeIdleChatLRU)
+ return true
+}
+
+func (m *Manager) retainReleasedConnection(wc *WsConnection) {
+ lock, release := m.accountLock(wc.session.AccountID)
+ defer release()
+ lock.Lock()
+ defer lock.Unlock()
+ current, exists := m.connections.Load(wc.PoolKey)
+ if !exists || current != wc || !wc.IsConnected() {
+ return
+ }
+ if connectionBudgetKind(wc) == capacityBlank && connectionLimit(capacityBlank) == 0 && wc.session.PendingCount() == 0 {
+ m.discardConnectionFor(wc, closeCapacityReclaim)
+ return
+ }
+ wc.Touch()
+ input := connectionCapacityRequest{accountID: wc.session.AccountID, limit: connectionLimit(capacityChat)}
+ m.trimIdleChatConnections(input)
+}
+
+// 保存较小的设置后,即使没有新请求,后台巡检也会收缩空闲保留池。
+func (m *Manager) trimAllIdleChatConnections() {
+ accounts := make(map[int64]struct{})
+ m.connections.Range(func(_, value any) bool {
+ wc := value.(*WsConnection)
+ if wc.session != nil {
+ accounts[wc.session.AccountID] = struct{}{}
+ }
+ return true
+ })
+ for accountID := range accounts {
+ m.trimAccountIdleChatConnections(accountID)
+ }
+}
+
+func (m *Manager) trimAccountIdleChatConnections(accountID int64) {
+ lock, release := m.accountLock(accountID)
+ defer release()
+ lock.Lock()
+ defer lock.Unlock()
+ m.trimIdleChatConnections(connectionCapacityRequest{accountID: accountID, limit: connectionLimit(capacityChat)})
+ if connectionLimit(capacityBlank) == 0 {
+ m.reclaimConnectionCapacity(connectionCapacityRequest{accountID: accountID, kind: capacityBlank})
+ }
+}
diff --git a/proxy/wsrelay/client_identity.go b/proxy/wsrelay/client_identity.go
index 50bc47918..b9aca7cd6 100644
--- a/proxy/wsrelay/client_identity.go
+++ b/proxy/wsrelay/client_identity.go
@@ -26,6 +26,9 @@ type websocketContinuation struct {
accountID int64
apiKey string
identity string
+ model string
+ url string
+ proxyURL string
}
func (e *Executor) acquireClientContinuation(input websocketContinuation) (*WsConnection, *PendingRequest, string) {
diff --git a/proxy/wsrelay/conn_lifetime_test.go b/proxy/wsrelay/conn_lifetime_test.go
index 9d01fd249..542b3b497 100644
--- a/proxy/wsrelay/conn_lifetime_test.go
+++ b/proxy/wsrelay/conn_lifetime_test.go
@@ -77,9 +77,8 @@ func TestEvictExpiredRotatesIdleOverAgeConnection(t *testing.T) {
}
}
-// TestPingIdleConnectionsSkipsOverAgeConnection 验证保活不给到龄连接续命:
-// Pong 会刷新 lastUsed 让连接永不空闲过期,若继续 Ping,开保活的实例上所有
-// 连接最终都会撞上游 60 分钟寿命上限。
+// TestPingIdleConnectionsSkipsOverAgeConnection 验证到龄连接不再保活,
+// 由清理器轮转,避免撞上游 60 分钟寿命上限。
func TestPingIdleConnectionsSkipsOverAgeConnection(t *testing.T) {
m := NewManager()
t.Cleanup(m.Stop)
diff --git a/proxy/wsrelay/connection_budget.go b/proxy/wsrelay/connection_budget.go
new file mode 100644
index 000000000..b17a413ff
--- /dev/null
+++ b/proxy/wsrelay/connection_budget.go
@@ -0,0 +1,137 @@
+package wsrelay
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/codex2api/proxy"
+ "github.com/tidwall/gjson"
+)
+
+type connectionCapacityKind int32
+
+const (
+ capacityChat connectionCapacityKind = iota
+ capacityBlank
+)
+
+func (kind connectionCapacityKind) String() string {
+ if kind == capacityBlank {
+ return "blank"
+ }
+ return "chat"
+}
+
+type connectionCapacityContextKey struct{}
+
+func requestConnectionCapacityKind(ctx context.Context) connectionCapacityKind {
+ if kind, ok := ctx.Value(connectionCapacityContextKey{}).(connectionCapacityKind); ok {
+ return kind
+ }
+ return capacityChat
+}
+
+// 只有没有聊天内容的明确预热使用空白预算;携带上下文仍属于聊天连接。
+func withConnectionCapacityKind(ctx context.Context, headers http.Header, body []byte) context.Context {
+ kind := capacityChat
+ prewarm := gjson.GetBytes(body, "generate").Type == gjson.False || proxy.IsCodexWebsocketPrewarmRequest(headers, body)
+ if prewarm && !prewarmHasChatContext(body) {
+ kind = capacityBlank
+ }
+ return context.WithValue(ctx, connectionCapacityContextKey{}, kind)
+}
+
+func prewarmHasChatContext(body []byte) bool {
+ for _, key := range []string{"input", "instructions", "tools", "prompt", "previous_response_id"} {
+ value := gjson.GetBytes(body, key)
+ if !value.Exists() || value.Type == gjson.Null {
+ continue
+ }
+ if value.IsArray() && len(value.Array()) == 0 || value.Type == gjson.String && value.String() == "" {
+ continue
+ }
+ return true
+ }
+ return false
+}
+
+func connectionLimit(kind connectionCapacityKind) int {
+ if kind == capacityBlank {
+ return statelessConnectionSlots()
+ }
+ return proxy.CurrentRuntimeSettings().CodexWSDownstreamKeepaliveSlots
+}
+
+func connectionBudgetKind(wc *WsConnection) connectionCapacityKind {
+ if slot := wc.capacitySlot.Load(); slot != nil {
+ return connectionCapacityKind(slot.kind.Load())
+ }
+ if wc.session != nil && wc.session.hasUserContext() {
+ return capacityChat
+ }
+ return capacityBlank
+}
+
+func (m *Manager) budgetConnectionCountLocked(accountID int64, kind connectionCapacityKind) int {
+ count := 0
+ for slot := range m.connectionSlots[accountID] {
+ if connectionCapacityKind(slot.kind.Load()) == kind {
+ count++
+ }
+ }
+ return count
+}
+
+func (m *Manager) budgetConnectionCount(input connectionCapacityRequest) int {
+ m.capacityMu.Lock()
+ defer m.capacityMu.Unlock()
+ return m.budgetConnectionCountLocked(input.accountID, input.kind)
+}
+
+// 首次用户使用空白连接前转为聊天连接;推理不受空闲保留额度限制。
+func (m *Manager) ensureConnectionBudget(wc *WsConnection, kind connectionCapacityKind) error {
+ m.adoptConnectionCapacity(wc)
+ if kind == capacityBlank || connectionBudgetKind(wc) == capacityChat {
+ return nil
+ }
+ m.capacityMu.Lock()
+ defer m.capacityMu.Unlock()
+ slot := wc.capacitySlot.Load()
+ if slot.released.Load() || !wc.IsConnected() {
+ return errors.New("websocket disconnected before budget transfer")
+ }
+ slot.kind.Store(int32(capacityChat))
+ return nil
+}
+
+// 未发送请求就被本地额度拒绝的新连接没有用户上下文,可退回空白预算。
+func (m *Manager) releaseUnusedChatCapacity(wc *WsConnection) {
+ if wc.session.hasUserContext() || wc.session.PendingCount() != 0 {
+ return
+ }
+ m.capacityMu.Lock()
+ slot := wc.capacitySlot.Load()
+ keep := slot != nil && !slot.released.Load() && wc.IsConnected()
+ if keep && connectionCapacityKind(slot.kind.Load()) == capacityChat {
+ keep = m.budgetConnectionCountLocked(slot.accountID, capacityBlank) < connectionLimit(capacityBlank)
+ if keep {
+ slot.kind.Store(int32(capacityBlank))
+ }
+ }
+ m.capacityMu.Unlock()
+ if !keep {
+ m.discardConnectionFor(wc, closeCapacityReclaim)
+ }
+}
+
+func isConnectionCapacityError(err error) bool {
+ var capacityErr *proxy.UpstreamWSConnectionCapacityError
+ return errors.As(err, &capacityErr)
+}
+
+func (m *Manager) discardAcquisitionFailure(wc *WsConnection, err error) {
+ if !isConnectionCapacityError(err) {
+ m.DiscardConnection(wc)
+ }
+}
diff --git a/proxy/wsrelay/connection_capacity.go b/proxy/wsrelay/connection_capacity.go
new file mode 100644
index 000000000..19b9efa83
--- /dev/null
+++ b/proxy/wsrelay/connection_capacity.go
@@ -0,0 +1,242 @@
+package wsrelay
+
+import (
+ "sort"
+ "sync/atomic"
+ "time"
+
+ "github.com/codex2api/proxy"
+)
+
+type idleAccountConnection struct {
+ wc *WsConnection
+ lastUsed int64
+}
+
+func isOneShotPoolConn(wc *WsConnection) bool {
+ return wc != nil && wc.session != nil && proxy.IsStatelessWebsocketSessionID(wc.session.ID)
+}
+
+// 无绑定的一次性连接先回收,其余按业务使用时间排序。
+func sortIdleForEviction(idle []idleAccountConnection) {
+ sort.Slice(idle, func(i, j int) bool {
+ one, other := isOneShotPoolConn(idle[i].wc), isOneShotPoolConn(idle[j].wc)
+ if one != other {
+ return one
+ }
+ return idle[i].lastUsed < idle[j].lastUsed
+ })
+}
+
+func (m *Manager) hasLiveResponseBinding(wc *WsConnection) bool {
+ if m == nil || wc == nil {
+ return false
+ }
+ m.respConnMu.Lock()
+ defer m.respConnMu.Unlock()
+ return m.hasLiveResponseBindingLocked(wc)
+}
+
+func (m *Manager) hasLiveResponseBindingLocked(wc *WsConnection) bool {
+ if wc.session != nil && !wc.session.hasUserContext() {
+ return false
+ }
+ now := time.Now()
+ for _, binding := range m.respConnBindings {
+ if binding.conn == wc && now.Before(binding.expiresAt) {
+ return true
+ }
+ }
+ return false
+}
+
+// 实体连接登记覆盖拨号、入池和摘池后的收尾,直到 socket 实际关闭。
+type connectionCapacitySlot struct {
+ manager *Manager
+ accountID int64
+ kind atomic.Int32
+ released atomic.Bool
+}
+
+func (slot *connectionCapacitySlot) release() {
+ if slot == nil || !slot.released.CompareAndSwap(false, true) {
+ return
+ }
+ m := slot.manager
+ m.capacityMu.Lock()
+ delete(m.connectionSlots[slot.accountID], slot)
+ if len(m.connectionSlots[slot.accountID]) == 0 {
+ delete(m.connectionSlots, slot.accountID)
+ }
+ m.capacityMu.Unlock()
+ m.notifyAccountWaiters(slot.accountID)
+}
+
+func (slot *connectionCapacitySlot) attach(wc *WsConnection) {
+ m := slot.manager
+ m.capacityMu.Lock()
+ wc.capacitySlot.Store(slot)
+ m.connectionSlots[slot.accountID][slot] = wc
+ m.capacityMu.Unlock()
+ if wc.state.Load() == int32(StateDisconnected) {
+ slot.release()
+ }
+}
+
+func (m *Manager) newCapacitySlotLocked(accountID int64) *connectionCapacitySlot {
+ if m.connectionSlots == nil {
+ m.connectionSlots = make(map[int64]map[*connectionCapacitySlot]*WsConnection)
+ }
+ if m.connectionSlots[accountID] == nil {
+ m.connectionSlots[accountID] = make(map[*connectionCapacitySlot]*WsConnection)
+ }
+ slot := &connectionCapacitySlot{manager: m, accountID: accountID}
+ m.connectionSlots[accountID][slot] = nil
+ return slot
+}
+
+// 兼容已存在于池中的连接;生产拨号在永久 reader 启动前完成登记。
+func (m *Manager) adoptConnectionCapacity(wc *WsConnection) {
+ kind := connectionBudgetKind(wc)
+ m.capacityMu.Lock()
+ if wc.capacitySlot.Load() != nil {
+ m.capacityMu.Unlock()
+ return
+ }
+ slot := m.newCapacitySlotLocked(wc.session.AccountID)
+ slot.kind.Store(int32(kind))
+ wc.capacitySlot.Store(slot)
+ m.connectionSlots[slot.accountID][slot] = wc
+ m.capacityMu.Unlock()
+ if wc.state.Load() == int32(StateDisconnected) {
+ slot.release()
+ }
+}
+
+func (m *Manager) accountConnectionCount(accountID int64) int {
+ m.capacityMu.Lock()
+ defer m.capacityMu.Unlock()
+ return len(m.connectionSlots[accountID])
+}
+
+type connectionCapacityRequest struct {
+ accountID int64
+ limit int
+ protectedKey string
+ extraPending int
+ freeSlots int
+ kind connectionCapacityKind
+}
+
+func (m *Manager) idleCapacityConnections(input connectionCapacityRequest) []idleAccountConnection {
+ var idle []idleAccountConnection
+ m.connections.Range(func(_, value any) bool {
+ wc, ok := value.(*WsConnection)
+ if !ok || wc == nil || wc.session == nil || wc.session.AccountID != input.accountID {
+ return true
+ }
+ m.adoptConnectionCapacity(wc)
+ if connectionBudgetKind(wc) != input.kind {
+ return true
+ }
+ if wc.session.PendingCount() != 0 {
+ return true
+ }
+ if input.kind == capacityChat && !wc.session.hasUserContext() {
+ return true
+ }
+ if !wc.IsConnected() || wc.IsExpired() || wc.IsOverAge() {
+ m.discardConnectionFor(wc, connectionExpiryReason(wc))
+ return true
+ }
+ if input.kind == capacityChat || wc.PoolKey != input.protectedKey && !m.hasLiveResponseBinding(wc) {
+ idle = append(idle, idleAccountConnection{wc: wc, lastUsed: wc.lastUsed.Load()})
+ }
+ return true
+ })
+ return idle
+}
+
+// 绑定检查与禁止晚到绑定原子完成,避免终态绑定和容量回收交错。
+func (m *Manager) discardUnboundIdleConnection(wc *WsConnection) {
+ m.respConnMu.Lock()
+ if wc.session.PendingCount() != 0 || m.hasLiveResponseBindingLocked(wc) {
+ m.respConnMu.Unlock()
+ return
+ }
+ current, exists := m.connections.Load(wc.PoolKey)
+ if !exists || current != wc {
+ m.respConnMu.Unlock()
+ return
+ }
+ wc.responseBindingsDisabled = true
+ m.respConnMu.Unlock()
+ m.discardConnectionFor(wc, closeCapacityReclaim)
+}
+
+func (m *Manager) reclaimConnectionCapacity(input connectionCapacityRequest) bool {
+ if input.limit < 0 {
+ input.limit = 0
+ }
+ if input.kind == capacityChat {
+ m.trimIdleChatConnections(input)
+ return true
+ }
+ idle := m.idleCapacityConnections(input)
+ sortIdleForEviction(idle)
+ for _, candidate := range idle {
+ if input.limit > 0 && m.budgetConnectionCount(input)+input.extraPending+input.freeSlots <= input.limit {
+ return true
+ }
+ m.discardUnboundIdleConnection(candidate.wc)
+ }
+ // 0 关闭空白保留池,但仍允许请求临时拨号;收尾时关闭连接。
+ return input.limit == 0 || m.budgetConnectionCount(input)+input.extraPending+input.freeSlots <= input.limit
+}
+
+func (m *Manager) ensureAccountConnectionCapacity(input connectionCapacityRequest) bool {
+ input.freeSlots = 1
+ return m.reclaimConnectionCapacity(input)
+}
+
+func (m *Manager) trimIdleAccountConnections(accountID int64, limit int, protected *WsConnection) {
+ input := connectionCapacityRequest{accountID: accountID, limit: limit}
+ if protected != nil {
+ input.protectedKey = protected.PoolKey
+ input.kind = connectionBudgetKind(protected)
+ }
+ m.reclaimConnectionCapacity(input)
+}
+
+// 调用方持有账号锁;聊天拨号只登记实体连接,空白拨号仍检查原预算。
+func (m *Manager) reserveAccountConnectionCapacity(input connectionCapacityRequest) *connectionCapacitySlot {
+ if !m.ensureAccountConnectionCapacity(input) {
+ return nil
+ }
+ m.capacityMu.Lock()
+ defer m.capacityMu.Unlock()
+ select {
+ case <-m.stopCleanup:
+ return nil
+ default:
+ slot := m.newCapacitySlotLocked(input.accountID)
+ slot.kind.Store(int32(input.kind))
+ return slot
+ }
+}
+
+func (m *Manager) closeCapacityConnections() {
+ var connections []*WsConnection
+ m.capacityMu.Lock()
+ for _, slots := range m.connectionSlots {
+ for _, wc := range slots {
+ if wc != nil {
+ connections = append(connections, wc)
+ }
+ }
+ }
+ m.capacityMu.Unlock()
+ for _, wc := range connections {
+ m.discardConnectionFor(wc, closeManagerShutdown)
+ }
+}
diff --git a/proxy/wsrelay/connection_close.go b/proxy/wsrelay/connection_close.go
new file mode 100644
index 000000000..681c9aff0
--- /dev/null
+++ b/proxy/wsrelay/connection_close.go
@@ -0,0 +1,114 @@
+package wsrelay
+
+import (
+ "context"
+ "errors"
+ "log"
+ "time"
+
+ "github.com/codex2api/proxy"
+ "github.com/gorilla/websocket"
+)
+
+const (
+ closeCapacityReclaim = "capacity_reclaim"
+ closeIdleChatLRU = "idle_chat_lru"
+ closeIdleTimeout = "idle_timeout"
+ closeLifetimeRotation = "lifetime_rotation"
+ closeDisconnected = "disconnected"
+ closeManagerShutdown = "manager_shutdown"
+ closeProbeFailed = "probe_failed"
+ closePoolRemoved = "pool_removed"
+ closeUnusable = "unusable_connection"
+ closeUnconsumed = "unconsumed_response"
+ closeOneShotComplete = "unbound_oneshot_complete"
+)
+
+type connectionCloseCause struct {
+ source string
+ reason string
+ code int
+ bound bool
+}
+
+func (wc *WsConnection) recordCloseCause(cause connectionCloseCause) {
+ wc.closeCause.CompareAndSwap(nil, &cause)
+}
+
+func (wc *WsConnection) recordDirectClose() {
+ if wc.closeCause.Load() != nil {
+ return
+ }
+ cause := connectionCloseCause{source: "local", reason: "explicit_close"}
+ if slot := wc.capacitySlot.Load(); slot != nil {
+ cause.bound = slot.manager.hasLiveResponseBinding(wc)
+ }
+ wc.recordCloseCause(cause)
+}
+
+func (m *Manager) discardConnectionFor(wc *WsConnection, reason string) {
+ if wc == nil {
+ return
+ }
+ wc.recordCloseCause(connectionCloseCause{source: "local", reason: reason, bound: m.hasLiveResponseBinding(wc)})
+ m.DiscardConnection(wc)
+}
+
+func connectionExpiryReason(wc *WsConnection) string {
+ if !wc.IsConnected() {
+ return closeDisconnected
+ }
+ if wc.IsExpired() {
+ return closeIdleTimeout
+ }
+ return closeLifetimeRotation
+}
+
+func (wc *WsConnection) recordReadClose(err error) {
+ cause := connectionCloseCause{source: "network", reason: "read_failure"}
+ var peer *websocket.CloseError
+ if errors.As(err, &peer) {
+ cause.code = peer.Code
+ if peer.Code != websocket.CloseAbnormalClosure && peer.Code != websocket.CloseNoStatusReceived && peer.Code != websocket.CloseTLSHandshake {
+ cause.source, cause.reason = "upstream", "peer_close"
+ }
+ }
+ if slot := wc.capacitySlot.Load(); slot != nil {
+ cause.bound = slot.manager.hasLiveResponseBinding(wc)
+ }
+ wc.recordCloseCause(cause)
+}
+
+func (wc *WsConnection) logClose() {
+ cause := wc.closeCause.Load()
+ if cause == nil {
+ cause = &connectionCloseCause{source: "local", reason: "explicit_close"}
+ }
+ accountID, pending, remaining := int64(0), 0, 0
+ if wc.session != nil {
+ accountID, pending = wc.session.AccountID, wc.session.PendingCount()
+ }
+ if slot := wc.capacitySlot.Load(); slot != nil {
+ remaining = slot.manager.accountConnectionCount(accountID)
+ }
+ age, idle := time.Duration(0), time.Duration(0)
+ if wc.createdAt > 0 {
+ age = time.Since(time.Unix(0, wc.createdAt))
+ }
+ if used := wc.lastUsed.Load(); used > 0 {
+ idle = time.Since(time.Unix(0, used))
+ }
+ log.Printf("[WS] 连接关闭 account=%d connection=%p source=%s reason=%s code=%d bound=%t pending=%d age_ms=%d idle_ms=%d connections=%d chat_idle_limit=%d blank_limit=%d",
+ accountID, wc, cause.source, cause.reason, cause.code, cause.bound, pending, age.Milliseconds(), idle.Milliseconds(), remaining, connectionLimit(capacityChat), connectionLimit(capacityBlank))
+}
+
+func (m *Manager) connectionCapacityError(accountID int64, kind connectionCapacityKind) error {
+ select {
+ case <-m.stopCleanup:
+ return context.Canceled
+ default:
+ }
+ limit := connectionLimit(kind)
+ log.Printf("[WS] 新建连接容量拒绝 account=%d budget=%s connections=%d limit=%d", accountID, kind.String(), m.budgetConnectionCount(connectionCapacityRequest{accountID: accountID, kind: kind}), limit)
+ return &proxy.UpstreamWSConnectionCapacityError{Limit: limit}
+}
diff --git a/proxy/wsrelay/connection_dial.go b/proxy/wsrelay/connection_dial.go
new file mode 100644
index 000000000..c04bf01cc
--- /dev/null
+++ b/proxy/wsrelay/connection_dial.go
@@ -0,0 +1,97 @@
+package wsrelay
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "github.com/codex2api/auth"
+ "github.com/codex2api/proxy"
+ "github.com/gorilla/websocket"
+)
+
+type connectionDialRequest struct {
+ account *auth.Account
+ url string
+ sessionKey string
+ headers http.Header
+ proxyOverride string
+ capacity *connectionCapacitySlot
+}
+
+func (m *Manager) createConnection(ctx context.Context, input connectionDialRequest) (wc *WsConnection, err error) {
+ defer func() {
+ if err != nil {
+ input.capacity.release()
+ }
+ }()
+ dialer, err := m.connectionDialer(input)
+ if err != nil {
+ return nil, err
+ }
+ session, poolKey := m.newDialSession(input)
+ conn, resp, err := dialer.DialContext(ctx, input.url, input.headers)
+ if err != nil {
+ m.sessions.CompareAndDelete(poolKey, session)
+ session.Close()
+ return nil, formatDialHandshakeError(err, resp)
+ }
+ logCompressionNegotiation(resp, input.account.ID())
+ wc = m.wrapDialedConnection(dialedConnection{conn: conn, response: resp, session: session, poolKey: poolKey, request: input})
+ input.capacity.attach(wc)
+ select {
+ case <-m.stopCleanup:
+ m.DiscardConnection(wc)
+ return nil, context.Canceled
+ default:
+ wc.installControlHandlers()
+ wc.StartReadPump()
+ return wc, nil
+ }
+}
+
+func (m *Manager) connectionDialer(input connectionDialRequest) (*websocket.Dialer, error) {
+ dialer := *m.dialer
+ proxyURL := effectiveProxyURL(input.account, input.proxyOverride)
+ if dialProxy := proxy.CodexDialProxyURL(input.account, proxyURL); dialProxy != "" {
+ if err := configureWebsocketDialerProxy(&dialer, dialProxy); err != nil {
+ return nil, err
+ }
+ }
+ return &dialer, nil
+}
+
+func (m *Manager) newDialSession(input connectionDialRequest) (*Session, string) {
+ poolKey := m.poolKey(input.account.ID(), input.url, input.sessionKey, effectiveProxyURL(input.account, input.proxyOverride))
+ if value, ok := m.sessions.Load(poolKey); ok {
+ value.(*Session).Close()
+ }
+ session := NewSession(input.account.ID(), m)
+ if key := strings.TrimSpace(input.sessionKey); key != "" {
+ session.ID = key
+ }
+ m.sessions.Store(poolKey, session)
+ return session, poolKey
+}
+
+type dialedConnection struct {
+ conn *websocket.Conn
+ response *http.Response
+ session *Session
+ poolKey string
+ request connectionDialRequest
+}
+
+func (m *Manager) wrapDialedConnection(input dialedConnection) *WsConnection {
+ wc := NewWsConnection(input.conn, input.session, input.request.url)
+ wc.account = input.request.account
+ wc.PoolKey = input.poolKey
+ wc.upstreamUserAgent = strings.TrimSpace(input.request.headers.Get("User-Agent"))
+ wc.upstreamUserAgentKnown = true
+ wc.upstreamClientIdentity = websocketClientIdentity(input.request.headers)
+ wc.httpResp = input.response
+ wc.onDisconnected = m.getOnDisconnected()
+ wc.onReadFailure = m.DiscardConnection
+ input.session.SetConnected(true)
+ return wc
+}
diff --git a/proxy/wsrelay/continuation.go b/proxy/wsrelay/continuation.go
new file mode 100644
index 000000000..f5d5a75d3
--- /dev/null
+++ b/proxy/wsrelay/continuation.go
@@ -0,0 +1,79 @@
+package wsrelay
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/codex2api/proxy"
+)
+
+// acquireContinuation 只等待原连接;绑定失效后由调用方恢复完整上下文。
+func (e *Executor) acquireContinuation(ctx context.Context, input websocketContinuation) (*WsConnection, *PendingRequest, string, error) {
+ parent := ctx
+ ctx, cancel := context.WithTimeout(ctx, busyAcquireMaxWait())
+ defer cancel()
+ start, waited := time.Now(), false
+ for {
+ if err := ctx.Err(); err != nil {
+ return nil, nil, "", err
+ }
+ wake := e.manager.accountWaitSignal(input.accountID)
+ wc, _ := e.manager.lookupContinuationConn(input)
+ if wc == nil {
+ return nil, nil, "", &proxy.ResponsesContinuationLostError{Reason: "original_connection_unavailable"}
+ }
+ connection, pending, key := e.manager.acquirePreferredConnection(input)
+ if connection != nil {
+ if waited {
+ log.Printf("[WS] 续链等待后复用原连接 account=%d waited_ms=%d", input.accountID, time.Since(start).Milliseconds())
+ }
+ return connection, pending, key, nil
+ }
+ waited = true
+ if _, err := waitForAccountChange(ctx, wake, AcquireMaxBackoff); err != nil {
+ if parent.Err() == nil && ctx.Err() == context.DeadlineExceeded {
+ return nil, nil, "", &proxy.ResponsesContinuationBusyError{}
+ }
+ return nil, nil, "", err
+ }
+ }
+}
+
+func (r *WsResponse) bindCompletedResponse(responseID string) {
+ if responseID == "" || r.manager == nil || r.conn == nil || r.conn.session == nil {
+ return
+ }
+ r.manager.bindResponseConn(responseID, responseConnBinding{conn: r.conn, sessionKey: r.sessionID,
+ accountID: r.conn.session.AccountID, apiKey: r.apiKey, model: r.model})
+}
+
+func (m *Manager) lookupContinuationConn(input websocketContinuation) (*WsConnection, string) {
+ wc, key := m.lookupResponseConn(input.responseID, input.accountID, input.apiKey)
+ if wc == nil || (input.identity != "" && wc.upstreamClientIdentity != input.identity) {
+ return nil, ""
+ }
+ if input.url != "" && wc.PoolKey != m.poolKey(input.accountID, input.url, key, input.proxyURL) {
+ return nil, ""
+ }
+ m.respConnMu.Lock()
+ binding, exists := m.respConnBindings[input.responseID]
+ m.respConnMu.Unlock()
+ if !exists || binding.conn != wc || (binding.model != "" && input.model != "" && binding.model != input.model) {
+ return nil, ""
+ }
+ return wc, key
+}
+
+// 发送失败后可取消地退避,避免瞬间重连风暴。
+func waitWebsocketSendRetry(ctx context.Context, retry int) error {
+ const sendRetryBackoff = 200 * time.Millisecond
+ timer := time.NewTimer(time.Duration(retry+1) * sendRetryBackoff)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/proxy/wsrelay/continuation_test.go b/proxy/wsrelay/continuation_test.go
new file mode 100644
index 000000000..df4cee2ca
--- /dev/null
+++ b/proxy/wsrelay/continuation_test.go
@@ -0,0 +1,198 @@
+package wsrelay
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/codex2api/auth"
+ "github.com/codex2api/proxy"
+)
+
+func continuationFixture(t *testing.T) (*Executor, *WsConnection) {
+ t.Helper()
+ manager := NewManager()
+ t.Cleanup(manager.Stop)
+ manager.probeFunc = func(*WsConnection) bool { return true }
+ wc := newBoundTestConn(t, manager, 7, "continuation")
+ wc.session.ID = "continuation"
+ wc.upstreamClientIdentity = "client"
+ manager.BindResponseConn("previous", wc, wc.session.ID, 7, "key")
+ return NewExecutorWithManager(manager), wc
+}
+
+func TestContinuationWaitsForOriginalConnection(t *testing.T) {
+ executor, original := continuationFixture(t)
+ busy, err := executor.manager.addPendingAndBeginReadLease(original, "busy", capacityChat)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ done := make(chan *WsConnection, 1)
+ go func() {
+ wc, pending, _, err := executor.acquireContinuation(ctx, websocketContinuation{responseID: "previous", accountID: 7, apiKey: "key", identity: "client"})
+ if err == nil {
+ original.cancelUnsentReadLease(pending.RequestID)
+ original.session.RemovePendingRequest(pending.RequestID)
+ }
+ done <- wc
+ }()
+ select {
+ case <-done:
+ t.Fatal("busy original connection fell through")
+ case <-time.After(20 * time.Millisecond):
+ }
+ original.cancelUnsentReadLease(busy.RequestID)
+ original.session.RemovePendingRequest(busy.RequestID)
+ select {
+ case got := <-done:
+ if original != got {
+ t.Fatal("continuation did not reuse original connection")
+ }
+ case <-ctx.Done():
+ t.Fatal("release did not wake continuation")
+ }
+}
+
+func TestContinuationCancellationAndLostBinding(t *testing.T) {
+ executor, original := continuationFixture(t)
+ busy, _ := executor.manager.addPendingAndBeginReadLease(original, "busy", capacityChat)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _, _, err := executor.acquireContinuation(ctx, websocketContinuation{responseID: "previous", accountID: 7, apiKey: "key", identity: "client"})
+ if !errors.Is(err, context.Canceled) || original.session.PendingCount() != 1 {
+ t.Fatalf("cancellation leaked a reservation: %v", err)
+ }
+ original.cancelUnsentReadLease(busy.RequestID)
+ original.session.RemovePendingRequest(busy.RequestID)
+ executor.manager.DiscardConnection(original)
+ _, _, _, err = executor.acquireContinuation(context.Background(), websocketContinuation{responseID: "previous", accountID: 7, apiKey: "key", identity: "client"})
+ var lost *proxy.ResponsesContinuationLostError
+ if !errors.As(err, &lost) {
+ t.Fatalf("lost binding returned %v", err)
+ }
+}
+
+func TestContinuationSendFailureDoesNotRedialWithOldID(t *testing.T) {
+ executor, original := continuationFixture(t)
+ account := &auth.Account{DBID: 7, AccessToken: "fixture-token"}
+ headers := executor.prepareWebsocketHeaders(context.Background(), account.AccessToken, account, "", "session", "key", nil, http.Header{}, nil, "")
+ original.upstreamClientIdentity = websocketClientIdentity(headers)
+ wsURL, err := buildWebsocketURL(proxy.CodexBaseURL + CodexWsEndpoint)
+ if err != nil {
+ t.Fatal(err)
+ }
+ executor.manager.connections.Delete(original.PoolKey)
+ executor.manager.sessions.Delete(original.PoolKey)
+ original.PoolKey = executor.manager.poolKey(account.ID(), wsURL, original.session.ID, "")
+ executor.manager.connections.Store(original.PoolKey, original)
+ executor.manager.sessions.Store(original.PoolKey, original.session)
+ original.conn = newClosedTestWebsocketConn(t)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ _, err = executor.ExecuteRequestViaWebsocket(ctx, account, []byte(`{"model":"gpt-5.5","previous_response_id":"previous","input":[]}`), "session", "", "key", nil, http.Header{}, "")
+ var lost *proxy.ResponsesContinuationLostError
+ if !errors.As(err, &lost) || lost.Reason != "original_connection_send_failed" {
+ t.Fatalf("send retried stale ID: %v", err)
+ }
+ if executor.manager.ConnectionCount() != 0 || original.session.PendingCount() != 0 {
+ t.Fatal("failed original retained connection or reservation")
+ }
+}
+
+func TestContinuationBindingPrecedesTerminalDelivery(t *testing.T) {
+ executor, original := continuationFixture(t)
+ response := &WsResponse{conn: original, manager: executor.manager, sessionID: original.session.ID, apiKey: "key"}
+ err := response.handleMessage([]byte(`{"type":"response.completed","response":{"id":"latest"}}`), func([]byte) bool {
+ got, _ := executor.manager.lookupResponseConn("latest", 7, "key")
+ if original != got {
+ t.Fatal("terminal exposed before binding")
+ }
+ if err := response.Close(); err != nil || !original.IsConnected() {
+ t.Fatal("terminal-triggered cancellation discarded completed connection")
+ }
+ return false
+ })
+ if !errors.Is(err, io.EOF) {
+ t.Fatal(err)
+ }
+ if got, _ := executor.manager.lookupResponseConn("latest", 7, "key"); original != got {
+ t.Fatal("consumed terminal lost reusable connection")
+ }
+}
+
+func TestContinuationBusyTimeoutPreservesOriginal(t *testing.T) {
+ previous := proxy.CurrentRuntimeSettings()
+ proxy.UpdateRuntimeSettings(func(settings proxy.RuntimeSettings) proxy.RuntimeSettings {
+ settings.CodexWSBusyMaxWaitSec = 1
+ return settings
+ })
+ t.Cleanup(func() { proxy.ApplyRuntimeSettings(previous) })
+ executor, original := continuationFixture(t)
+ busy, err := executor.manager.addPendingAndBeginReadLease(original, "busy", capacityChat)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer original.session.RemovePendingRequest(busy.RequestID)
+ defer original.cancelUnsentReadLease(busy.RequestID)
+ _, _, _, err = executor.acquireContinuation(context.Background(), websocketContinuation{responseID: "previous", accountID: 7, apiKey: "key", identity: "client"})
+ var full *proxy.ResponsesContinuationBusyError
+ if !errors.As(err, &full) || !original.IsConnected() || original.session.PendingCount() != 1 {
+ t.Fatalf("busy continuation changed original or misclassified timeout: %v", err)
+ }
+}
+
+func TestContinuationChecksModelAndEgress(t *testing.T) {
+ executor, original := continuationFixture(t)
+ executor.manager.bindResponseConn("previous", responseConnBinding{conn: original, sessionKey: original.session.ID,
+ accountID: 7, apiKey: "key", model: "model-a"})
+ base := websocketContinuation{responseID: "previous", accountID: 7, apiKey: "key", identity: "client", model: "model-a", url: original.URL}
+ for _, changed := range []websocketContinuation{
+ {responseID: "previous", accountID: 7, apiKey: "key", identity: "client", model: "model-b"},
+ {responseID: "previous", accountID: 7, apiKey: "key", identity: "client", url: "wss://other.test/responses"},
+ {responseID: "previous", accountID: 7, apiKey: "key", identity: "client", url: original.URL, proxyURL: "http://other.test:8080"},
+ } {
+ if got, _ := executor.manager.lookupContinuationConn(changed); got != nil {
+ t.Fatal("changed model or egress reused original")
+ }
+ }
+ if got, _ := executor.manager.lookupContinuationConn(base); original != got {
+ t.Fatal("matching model and egress lost original")
+ }
+}
+
+func TestIncompleteResponseEndsReadLeaseAndBinds(t *testing.T) {
+ executor, original := continuationFixture(t)
+ response := &WsResponse{conn: original, manager: executor.manager, sessionID: original.session.ID, apiKey: "key"}
+ payload := []byte(`{"type":"response.incomplete","response":{"id":"partial"}}`)
+ if !isReadLeaseTerminal(payload) || response.handleMessage(payload, func([]byte) bool { return true }) != io.EOF {
+ t.Fatal("incomplete response retained active stream")
+ }
+ if got, _ := executor.manager.lookupResponseConn("partial", 7, "key"); original != got {
+ t.Fatal("incomplete response lost continuation binding")
+ }
+}
+
+func TestIdleChatRetentionEvictsOldestBoundAndProtectsActive(t *testing.T) {
+ executor, bound := continuationFixture(t)
+ manager := executor.manager
+ active := newBoundTestConn(t, manager, 7, "active")
+ active.session.AddPendingRequest("active")
+ active.lastUsed.Store(time.Now().Add(-2 * connectionIdleTimeout()).UnixNano())
+ bound.lastUsed.Store(time.Now().Add(-2 * time.Minute).UnixNano())
+ unbound := newBoundTestConn(t, manager, 7, "unbound")
+ unbound.lastUsed.Store(time.Now().Add(-time.Minute).UnixNano())
+ if !manager.ensureAccountConnectionCapacity(connectionCapacityRequest{accountID: 7, limit: 1}) {
+ t.Fatal("idle retention should not reject new inference")
+ }
+ if !active.IsConnected() || bound.IsConnected() || !unbound.IsConnected() {
+ t.Fatal("LRU should evict the oldest bound chat and preserve active requests")
+ }
+ if got, _ := manager.lookupResponseConn("previous", 7, "key"); got == bound {
+ t.Fatal("evicted connection retained its continuation binding")
+ }
+}
diff --git a/proxy/wsrelay/executor.go b/proxy/wsrelay/executor.go
index c81ee7f64..f153707c3 100644
--- a/proxy/wsrelay/executor.go
+++ b/proxy/wsrelay/executor.go
@@ -183,13 +183,18 @@ func (e *Executor) ExecuteRequestViaWebsocket(
// 同线程上的后台副请求(request_kind=memory、guardian 子代理)另成一道,
// 不与用户在飞轮次同键排队;Desktop 走 HTTP 时元数据只在请求体里。
poolSessionID := proxy.ResolveCodexWebsocketTransportSessionKeyWithBody(sessionID, ginHeaders, wsBody)
+ ctx = withConnectionCapacityKind(ctx, ginHeaders, wsBody)
var wc *WsConnection
var pr *PendingRequest
var err2 error
acquireStart := time.Now()
if prevRespID := strings.TrimSpace(gjson.GetBytes(wsBody, "previous_response_id").String()); prevRespID != "" {
- if pwc, ppr, slotKey := e.acquireClientContinuation(websocketContinuation{responseID: prevRespID, accountID: account.ID(), apiKey: apiKey, identity: websocketClientIdentity(headers)}); pwc != nil {
- wc, pr, poolSessionID = pwc, ppr, slotKey
+ wc, pr, poolSessionID, err2 = e.acquireContinuation(ctx, websocketContinuation{
+ responseID: prevRespID, accountID: account.ID(), apiKey: apiKey, identity: websocketClientIdentity(headers),
+ model: gjson.GetBytes(wsBody, "model").String(), url: wsURL, proxyURL: effectiveProxyURL(account, proxyOverride),
+ })
+ if err2 != nil {
+ return nil, err2
}
}
baseKey := strings.TrimSpace(poolRouteKey)
@@ -236,18 +241,21 @@ func (e *Executor) ExecuteRequestViaWebsocket(
e.manager.DiscardConnection(wc)
}
wc.session.RemovePendingRequest(pr.RequestID)
+ e.manager.releaseUnusedChatCapacity(wc)
return nil, err
}
sendErr := e.sendRequest(wc, wsBody, pr.RequestID)
+ if sendErr != nil && gjson.GetBytes(wsBody, "previous_response_id").String() != "" {
+ wc.session.RemovePendingRequest(pr.RequestID)
+ e.manager.DiscardConnection(wc)
+ return nil, &proxy.ResponsesContinuationLostError{Reason: "original_connection_send_failed"}
+ }
for retries := 0; shouldRetryWebsocketSendError(sendErr) && retries < 2; retries++ {
wc.session.RemovePendingRequest(pr.RequestID)
e.manager.DiscardConnection(wc)
- // 短暂退避,避免瞬间重连风暴
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- case <-time.After(time.Duration(retries+1) * 200 * time.Millisecond):
+ if err := waitWebsocketSendRetry(ctx, retries); err != nil {
+ return nil, err
}
reacquireStart := time.Now()
@@ -276,6 +284,7 @@ func (e *Executor) ExecuteRequestViaWebsocket(
sessionID: poolSessionID,
manager: e.manager,
apiKey: apiKey,
+ model: gjson.GetBytes(wsBody, "model").String(),
readErrChan: make(chan error, 1),
}, nil
}
@@ -451,6 +460,7 @@ func (e *Executor) sendRequest(wc *WsConnection, body []byte, requestID string)
// WsResponse WebSocket 响应包装器
type WsResponse struct {
+ model string
conn *WsConnection
pendingReq *PendingRequest
sessionID string
@@ -531,7 +541,9 @@ func (r *WsResponse) handleMessage(payload []byte, callback func(data []byte) bo
r.markConnBroken()
}
// 把错误内容作为 SSE 数据写给下游,让客户端看到完整错误 JSON。
- callback(errEvent)
+ if !callback(errEvent) {
+ r.markConnBroken()
+ }
// 错误即终止:结束流(等价于 response.failed)。
return io.EOF
}
@@ -539,29 +551,29 @@ func (r *WsResponse) handleMessage(payload []byte, callback func(data []byte) bo
// 标准化完成事件类型
payload = normalizeCompletionEvent(payload)
+ // 终态先记录绑定与完成状态,再交给下游。
+ eventType := gjson.GetBytes(payload, "type").String()
+ terminal := isReadLeaseTerminal(payload)
+ if eventType == "response.completed" || eventType == "response.incomplete" {
+ r.bindCompletedResponse(gjson.GetBytes(payload, "response.id").String())
+ }
+ if terminal {
+ // 下游读到终态即可取消请求;提前标记,避免取消回调误销毁已完成连接。
+ r.markStreamCompleted()
+ }
// 调用回调
if !callback(payload) {
// 下游写入失败(broken pipe / 客户端断开):响应流在非终止边界被截断,
// 上游仍会在这条连接上继续推送本响应的剩余帧。连接必须销毁,
// 归还池中复用会把残留帧串给下一个请求(issue #308)。
- r.markConnBroken()
+ if !terminal {
+ r.markConnBroken()
+ }
return io.EOF
}
// 检查是否是终止事件
- eventType := gjson.GetBytes(payload, "type").String()
- if eventType == "response.completed" || eventType == "response.failed" {
- // 续链亲和:记录本响应由哪条连接产出,后续带 previous_response_id 的
- // 请求可回到原连接(上游无服务端存储时上下文只存活在连接内)。
- if eventType == "response.completed" && r.manager != nil && r.conn != nil {
- if respID := gjson.GetBytes(payload, "response.id").String(); respID != "" {
- accountID := int64(0)
- if r.conn.session != nil {
- accountID = r.conn.session.AccountID
- }
- r.manager.BindResponseConn(respID, r.conn, r.sessionID, accountID, r.apiKey)
- }
- }
+ if terminal {
return io.EOF
}
@@ -650,6 +662,9 @@ func (r *WsResponse) markConnBroken() {
r.mu.Lock()
r.connBroken = true
r.mu.Unlock()
+ if r.manager != nil && r.conn != nil {
+ r.manager.DiscardConnection(r.conn)
+ }
}
// markStreamCompleted 标记读流已消费到明确的终止边界(幂等,受 mu 保护)。
@@ -685,7 +700,11 @@ func (r *WsResponse) Close() error {
if !r.connBroken && r.streamCompleted && !r.shouldDiscardOneShotConn() {
r.manager.ReleaseConnection(r.conn)
} else {
- r.manager.DiscardConnection(r.conn)
+ reason := closeUnconsumed
+ if !r.connBroken && r.streamCompleted {
+ reason = closeOneShotComplete
+ }
+ r.manager.discardConnectionFor(r.conn, reason)
}
}
@@ -696,14 +715,11 @@ func (r *WsResponse) Close() error {
// 这类连接的池键每请求唯一,归还池后不可能再被按键复用,只会占用账号连接名额
// 直到空闲超时;唯一的保留价值是 response_id 续链亲和(上游无服务端存储时,
// previous_response_id 的上下文只存活在产出响应的那条连接里),因此有存活绑定时
-// 仍归还池。CODEX_WS_STATELESS_ONESHOT 模式显式承诺用完即毁,无条件销毁。
+// 仍归还池;一次性模式只禁止通用槽位复用,不应破坏已经产生的续链。
func (r *WsResponse) shouldDiscardOneShotConn() bool {
if r.conn == nil || r.conn.session == nil || !proxy.IsStatelessWebsocketSessionID(r.conn.session.ID) {
return false
}
- if statelessOneShotEnabled() {
- return true
- }
return r.manager == nil || !r.manager.hasLiveResponseBinding(r.conn)
}
diff --git a/proxy/wsrelay/issue436_pending_protect_test.go b/proxy/wsrelay/issue436_pending_protect_test.go
index 4ebfd60a7..bfc7667ae 100644
--- a/proxy/wsrelay/issue436_pending_protect_test.go
+++ b/proxy/wsrelay/issue436_pending_protect_test.go
@@ -165,6 +165,7 @@ func TestEvictExpiredSkipsConnectionsWithPendingRequests(t *testing.T) {
wc := NewWsConnection(nil, session, "ws://example")
wc.PoolKey = "key-busy"
wc.SetState(StateConnected)
+ wc.Touch()
// 伪造超龄空闲时间戳:lastUsed 早于 IdleTimeout,LastActiveAt 同步做旧
wc.lastUsed.Store(time.Now().Add(-2 * IdleTimeout).UnixNano())
session.mu.Lock()
diff --git a/proxy/wsrelay/keepalive.go b/proxy/wsrelay/keepalive.go
index 3b870f66b..773f19b70 100644
--- a/proxy/wsrelay/keepalive.go
+++ b/proxy/wsrelay/keepalive.go
@@ -42,9 +42,8 @@ func (m *Manager) PingIdleConnections() (pinged int, failed int) {
if !wc.IsConnected() {
return true
}
- // 到龄连接不续命:Pong 会刷新 lastUsed 让连接永不空闲过期,最终撞上游
- // 60 分钟寿命上限;跳过 Ping,交给 evictExpired 按到龄轮转销毁。
- if wc.IsOverAge() {
+ // 业务空闲过期或到龄的连接不再保活,交给 evictExpired 关闭并归还槽位。
+ if wc.IsExpired() || wc.IsOverAge() {
return true
}
// 只保活空闲连接:有 pending 请求时跳过(该连接本就活跃,无需额外 Ping)。
diff --git a/proxy/wsrelay/manager.go b/proxy/wsrelay/manager.go
index ef9191c10..25532355e 100644
--- a/proxy/wsrelay/manager.go
+++ b/proxy/wsrelay/manager.go
@@ -6,14 +6,12 @@ import (
"log"
"net"
"net/http"
- "sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/codex2api/auth"
- "github.com/codex2api/proxy"
"github.com/codex2api/security"
"github.com/gorilla/websocket"
)
@@ -41,8 +39,8 @@ type WsConnection struct {
upstreamUserAgentKnown bool
upstreamClientIdentity string
- // 创建/复用该连接的账号。仅用于读取当前动态并发上限,让 response_id
- // 续链复用路径也能在账号上限下调后收敛空闲连接数。
+ // 创建/复用该连接的账号。请求并发由调度层独立管理,
+ // response_id 复用按共享连接槽位预算收敛无绑定的空闲连接。
account *auth.Account
// 会话
@@ -57,7 +55,10 @@ type WsConnection struct {
// 连接状态
state atomic.Int32
- // 最后使用时间
+ capacitySlot atomic.Pointer[connectionCapacitySlot]
+ closeCause atomic.Pointer[connectionCloseCause]
+
+ // 最后业务使用时间,心跳和空闲元数据不续期。
lastUsed atomic.Int64
// 最近入站活动时间(数据帧/对端 Ping/Pong 回执,UnixNano)。仅供 probe
@@ -150,9 +151,13 @@ func NewWsConnection(conn *websocket.Conn, session *Session, wsURL string) *WsCo
return wc
}
-// Touch 更新最后使用时间
+// Touch 同步更新连接和会话的最后业务使用时间。
func (wc *WsConnection) Touch() {
wc.lastUsed.Store(time.Now().UnixNano())
+ if wc.session != nil {
+ slot := wc.capacitySlot.Load()
+ wc.session.touchActivity(slot == nil || connectionCapacityKind(slot.kind.Load()) == capacityChat)
+ }
}
// touchInbound 记录一次入站活动(数据帧/对端 Ping/我方 Ping 的 Pong 回执)。
@@ -169,10 +174,13 @@ func (wc *WsConnection) recentInboundWithin(window time.Duration) bool {
return time.Since(time.Unix(0, ts)) <= window
}
-// IsExpired 检查连接是否过期
+// IsExpired 只对曾用于聊天的连接执行业务空闲回收;空白槽位正常保活。
func (wc *WsConnection) IsExpired() bool {
+ if !weakNetworkModeEnabled() && (wc.session == nil || !wc.session.hasUserContext()) {
+ return false
+ }
lastUsed := time.Unix(0, wc.lastUsed.Load())
- return time.Since(lastUsed) > connectionIdleTimeout()
+ return time.Since(lastUsed) >= connectionIdleTimeout()
}
// IsOverAge 检查连接是否超过当前模式的最大寿命。到龄连接不能再接新请求:
@@ -195,11 +203,14 @@ func (wc *WsConnection) Close() error {
return nil
}
wc.closeOnce.Do(func() {
+ wc.recordDirectClose()
wc.state.Store(int32(StateClosing))
if wc.conn != nil {
wc.closeErr = wc.conn.Close()
}
wc.state.Store(int32(StateDisconnected))
+ wc.capacitySlot.Load().release()
+ wc.logClose()
})
if wc.onDisconnected != nil && wc.session != nil && wc.disconnectNotified.CompareAndSwap(false, true) {
wc.onDisconnected(wc.session.AccountID)
@@ -229,6 +240,9 @@ func (wc *WsConnection) WriteMessage(messageType int, data []byte) error {
defer wc.conn.SetWriteDeadline(time.Time{})
writeErr := wc.conn.WriteMessage(messageType, data)
+ if writeErr == nil && (messageType == websocket.TextMessage || messageType == websocket.BinaryMessage) {
+ wc.Touch()
+ }
if tracksLease {
return wc.completeReadLeaseWrite(leaseID, writeErr)
}
@@ -269,9 +283,9 @@ type Manager struct {
keyLocks refCountedLockRegistry[string]
// 账号级串行化连接获取,确保跨 session/pool key 创建连接时仍能严格执行
// 每账号连接上限,避免大量短会话各留一条空闲连接。
- accountLocks refCountedLockRegistry[int64]
- capacityMu sync.Mutex
- pendingCreates map[int64]int
+ accountLocks refCountedLockRegistry[int64]
+ capacityMu sync.Mutex
+ connectionSlots map[int64]map[*connectionCapacitySlot]*WsConnection
// 账号级等待唤醒:busy/容量等待者在同账号连接释放、销毁、新建或在途请求结束时
// 立即被唤醒重新选连,不再空等整个退避间隔(最多 AcquireMaxBackoff)。
@@ -306,12 +320,13 @@ type responseConnBinding struct {
sessionKey string
accountID int64
apiKey string
+ model string
expiresAt time.Time
}
const (
// responseConnBindingTTL 续链绑定的存活时间。上游空闲连接本身在 IdleTimeout
- // (5min) 后被清理,绑定活得再久也无意义,与其对齐。
+ // (30min) 后被清理,绑定活得再久也无意义,与其对齐。
responseConnBindingTTL = IdleTimeout
// responseConnBindingMaxEntries 绑定表上限,防止内存膨胀。
responseConnBindingMaxEntries = 4096
@@ -370,9 +385,7 @@ func (m *Manager) evictExpired() {
return true
}
if wc.IsExpired() || !wc.IsConnected() || isRotatableOverAge(wc) {
- m.connections.CompareAndDelete(key, wc)
- m.removeResponseConnBindings(wc)
- wc.Close()
+ m.discardConnectionFor(wc, connectionExpiryReason(wc))
if wc.session != nil {
m.notifyAccountWaiters(wc.session.AccountID)
}
@@ -392,6 +405,7 @@ func (m *Manager) evictExpired() {
return true
})
m.evictResponseConnBindings(time.Now())
+ m.trimAllIdleChatConnections()
}
// Stop 停止管理器
@@ -404,11 +418,10 @@ func (m *Manager) Stop() {
// closeAll 关闭所有连接
func (m *Manager) closeAll() {
+ m.closeCapacityConnections()
m.connections.Range(func(key, value any) bool {
wc := value.(*WsConnection)
- m.connections.CompareAndDelete(key, wc)
- m.removeResponseConnBindings(wc)
- wc.Close()
+ m.discardConnectionFor(wc, closeManagerShutdown)
return true
})
m.respConnMu.Lock()
@@ -494,178 +507,6 @@ func (m *Manager) accountLock(accountID int64) (*sync.Mutex, func()) {
return m.accountLocks.retain(accountID)
}
-func accountConnectionLimit(account *auth.Account) int {
- if account != nil {
- if limit := account.GetDynamicConcurrencyLimit(); limit > 0 {
- return int(limit)
- }
- }
- // 尚未完成调度快照初始化的账号保留原有槽位上限,生产请求进入账号池后
- // DynamicConcurrencyLimit 会始终为正数。
- return statelessConnectionSlots()
-}
-
-type idleAccountConnection struct {
- wc *WsConnection
- lastUsed int64
-}
-
-// isOneShotPoolConn 判断连接是否挂在每请求唯一的 stateless 池键下(8 槽全忙时的
-// fallback 一次性连接,或直连 stateless key 的路径)。这类连接的池键不可能被后续
-// 请求按键命中,仅 response_id 续链亲和可能定向取回。
-func isOneShotPoolConn(wc *WsConnection) bool {
- return wc != nil && wc.session != nil && proxy.IsStatelessWebsocketSessionID(wc.session.ID)
-}
-
-// sortIdleForEviction 决定容量裁剪的逐出顺序:一次性池键连接优先,组内按 LRU。
-// 纯 LRU 会出现倒挂——刚用完的一次性连接比几分钟前用过的可复用槽连接"更新",
-// 结果保住永远不会被按键复用的僵尸、逐出真正的热槽,拖垮复用率。
-func sortIdleForEviction(idle []idleAccountConnection) {
- sort.Slice(idle, func(i, j int) bool {
- oi, oj := isOneShotPoolConn(idle[i].wc), isOneShotPoolConn(idle[j].wc)
- if oi != oj {
- return oi
- }
- return idle[i].lastUsed < idle[j].lastUsed
- })
-}
-
-// hasLiveResponseBinding 是否存在未过期的 response_id 续链绑定指向该连接。
-func (m *Manager) hasLiveResponseBinding(wc *WsConnection) bool {
- if m == nil || wc == nil {
- return false
- }
- now := time.Now()
- m.respConnMu.Lock()
- defer m.respConnMu.Unlock()
- for _, b := range m.respConnBindings {
- if b.conn == wc && now.Before(b.expiresAt) {
- return true
- }
- }
- return false
-}
-
-// ensureAccountConnectionCapacity 为即将创建的新连接腾出一个账号级槽位。
-// 只淘汰没有在途请求的最久未使用连接,绝不打断活跃响应。调用方必须持有该账号的
-// accountLock,因此不同 session 同时建连也不会越过动态并发上限。
-func (m *Manager) ensureAccountConnectionCapacity(accountID int64, limit int, protectedKey string, pendingCreates int) bool {
- if limit < 1 {
- limit = 1
- }
- count := 0
- stale := make([]*WsConnection, 0)
- idle := make([]idleAccountConnection, 0)
- m.connections.Range(func(_, value any) bool {
- wc, ok := value.(*WsConnection)
- if !ok || wc == nil || wc.session == nil || wc.session.AccountID != accountID {
- return true
- }
- if !wc.IsConnected() || wc.IsExpired() || isRotatableOverAge(wc) {
- stale = append(stale, wc)
- return true
- }
- count++
- if wc.PoolKey != protectedKey && wc.session.PendingCount() == 0 {
- idle = append(idle, idleAccountConnection{wc: wc, lastUsed: wc.lastUsed.Load()})
- }
- return true
- })
- for _, wc := range stale {
- m.DiscardConnection(wc)
- }
- if count+pendingCreates < limit {
- return true
- }
- sortIdleForEviction(idle)
- for _, candidate := range idle {
- if count+pendingCreates < limit {
- break
- }
- wc := candidate.wc
- if wc == nil || wc.session == nil || wc.session.PendingCount() != 0 || !wc.IsConnected() {
- continue
- }
- if current, ok := m.connections.Load(wc.PoolKey); !ok || current != wc {
- continue
- }
- m.DiscardConnection(wc)
- count--
- }
- return count+pendingCreates < limit
-}
-
-// trimIdleAccountConnections 在复用现有连接时把账号连接数收敛到当前动态并发上限。
-// 当前要复用的连接始终受保护;其它仍有在途请求的连接也不会被打断。若活跃连接数
-// 本身已经超过上限,本次只清理能够安全回收的空闲连接,后续 acquire 再继续收敛。
-// 调用方必须持有该账号的 accountLock。
-func (m *Manager) trimIdleAccountConnections(accountID int64, limit int, protected *WsConnection) {
- if limit < 1 {
- limit = 1
- }
- count := 0
- idle := make([]idleAccountConnection, 0)
- m.connections.Range(func(_, value any) bool {
- wc, ok := value.(*WsConnection)
- if !ok || wc == nil || wc.session == nil || wc.session.AccountID != accountID {
- return true
- }
- count++
- if wc != protected && wc.session.PendingCount() == 0 {
- idle = append(idle, idleAccountConnection{wc: wc, lastUsed: wc.lastUsed.Load()})
- }
- return true
- })
- if count <= limit {
- return
- }
-
- sortIdleForEviction(idle)
- for _, candidate := range idle {
- if count <= limit {
- break
- }
- wc := candidate.wc
- if wc == nil || wc == protected || wc.session == nil || wc.session.PendingCount() != 0 {
- continue
- }
- if current, ok := m.connections.Load(wc.PoolKey); !ok || current != wc {
- continue
- }
- m.DiscardConnection(wc)
- count--
- }
-}
-
-func (m *Manager) reserveAccountConnectionCapacity(accountID int64, limit int, protectedKey string) bool {
- m.capacityMu.Lock()
- pending := m.pendingCreates[accountID]
- m.capacityMu.Unlock()
- if !m.ensureAccountConnectionCapacity(accountID, limit, protectedKey, pending) {
- return false
- }
- m.capacityMu.Lock()
- if m.pendingCreates == nil {
- m.pendingCreates = make(map[int64]int)
- }
- m.pendingCreates[accountID]++
- m.capacityMu.Unlock()
- return true
-}
-
-func (m *Manager) releaseAccountConnectionCapacity(accountID int64) {
- m.capacityMu.Lock()
- if pending := m.pendingCreates[accountID]; pending <= 1 {
- delete(m.pendingCreates, accountID)
- } else {
- m.pendingCreates[accountID] = pending - 1
- }
- m.capacityMu.Unlock()
- m.notifyAccountWaiters(accountID)
-}
-
-// accountWaitSignal 返回账号当前的等待唤醒 channel:下一次 notifyAccountWaiters 会关闭它。
-// 等待者必须在检查池状态之前取到 channel,才不会漏掉检查与等待之间发生的变化。
func (m *Manager) accountWaitSignal(accountID int64) <-chan struct{} {
m.waitNotifyMu.Lock()
defer m.waitNotifyMu.Unlock()
@@ -721,6 +562,7 @@ func (m *Manager) AcquireConnection(
headers http.Header,
proxyOverride string,
) (*WsConnection, *PendingRequest, error) {
+ kind := requestConnectionCapacityKind(ctx)
key := m.poolKey(account.ID(), wsURL, sessionKey, effectiveProxyURL(account, proxyOverride))
lock, releaseKeyLock := m.keyLock(key)
defer releaseKeyLock()
@@ -749,26 +591,29 @@ func (m *Manager) AcquireConnection(
lock.Unlock()
continue
}
- pr, leaseErr := m.addPendingAndBeginReadLease(wc, sessionKey)
+ pr, leaseErr := m.addPendingAndBeginReadLease(wc, sessionKey, kind)
if leaseErr == nil {
wc.account = account
wc.Touch()
- m.trimIdleAccountConnections(account.ID(), accountConnectionLimit(account), wc)
+ m.trimIdleAccountConnections(account.ID(), connectionLimit(connectionBudgetKind(wc)), wc)
accountLock.Unlock()
lock.Unlock()
return wc, pr, nil
}
- m.DiscardConnection(wc)
+ m.discardAcquisitionFailure(wc, leaseErr)
accountLock.Unlock()
lock.Unlock()
+ if isConnectionCapacityError(leaseErr) {
+ return nil, nil, leaseErr
+ }
continue
}
// 探活失败,清理死连接
- m.DiscardConnection(wc)
+ m.discardConnectionFor(wc, closeProbeFailed)
lock.Unlock()
continue
}
- if wc.IsConnected() && !wc.IsExpired() && wc.session != nil && wc.session.PendingCount() > 0 && !isRotatableOverAge(wc) {
+ if wc.session != nil && wc.session.PendingCount() > 0 {
lock.Unlock()
// 连接被同 session 的前一个请求占用:指数退避轮询等待其空闲,
// 累计等待超过上限则返回错误,避免无界阻塞与固定间隔空转抢锁。
@@ -804,33 +649,18 @@ func (m *Manager) AcquireConnection(
m.DiscardConnection(wc)
}
accountLock.Lock()
- if !m.reserveAccountConnectionCapacity(account.ID(), accountConnectionLimit(account), key) {
+ capacity := m.reserveAccountConnectionCapacity(connectionCapacityRequest{accountID: account.ID(), limit: connectionLimit(kind), protectedKey: key, kind: kind})
+ if capacity == nil {
accountLock.Unlock()
lock.Unlock()
- if maxWait := busyAcquireMaxWait(); waited >= maxWait {
- return nil, nil, fmt.Errorf("acquire websocket connection timed out after %s waiting for account connection capacity", maxWait)
- }
- // 同账号连接销毁/拨号占位归还时立即唤醒重试容量预留,退避只是兜底。
- slept, waitErr := waitForAccountChange(ctx, wake, wait)
- if waitErr != nil {
- return nil, nil, waitErr
- }
- waited += slept
- if wait < AcquireMaxBackoff {
- wait *= 2
- if wait > AcquireMaxBackoff {
- wait = AcquireMaxBackoff
- }
- }
- continue
+ return nil, nil, m.connectionCapacityError(account.ID(), kind)
}
- // 容量已预留,拨号期间不再持有账号锁;其他 session 可以复用已有连接,
- // 但会把本次 pending create 计入上限,避免并发握手越界。
+ // 实体连接已登记,拨号期间释放账号锁,其他 session 可继续取连。
+ // 空白连接的拨号登记占用预算;聊天推理不占空闲保留额度。
accountLock.Unlock()
- wc, err := m.createConnection(ctx, account, wsURL, sessionKey, headers, proxyOverride)
+ wc, err := m.createConnection(ctx, connectionDialRequest{account: account, url: wsURL, sessionKey: sessionKey, headers: headers, proxyOverride: proxyOverride, capacity: capacity})
if err != nil {
- m.releaseAccountConnectionCapacity(account.ID())
lock.Unlock()
return nil, nil, err
}
@@ -840,14 +670,13 @@ func (m *Manager) AcquireConnection(
if m.afterConnectionStored != nil {
m.afterConnectionStored(wc)
}
- pr, leaseErr := m.addPendingAndBeginReadLease(wc, sessionKey)
+ pr, leaseErr := m.addPendingAndBeginReadLease(wc, sessionKey, kind)
if leaseErr == nil {
if earlyErr := wc.waitForEarlyReadFailure(ctx, newConnectionReadFailureGrace); earlyErr != nil {
wc.session.RemovePendingRequest(pr.RequestID)
leaseErr = earlyErr
}
}
- m.releaseAccountConnectionCapacity(account.ID())
if leaseErr != nil {
m.DiscardConnection(wc)
lock.Unlock()
@@ -889,7 +718,8 @@ func (m *Manager) tryAcquireBusyOverflow(
proxyOverride string,
) (*WsConnection, *PendingRequest, bool) {
proxyURL := effectiveProxyURL(account, proxyOverride)
- accountLimit := accountConnectionLimit(account)
+ kind := requestConnectionCapacityKind(ctx)
+ accountLimit := connectionLimit(kind)
accountLock, releaseAccountLock := m.accountLock(account.ID())
defer releaseAccountLock()
for i := 1; i <= BusyOverflowSlots; i++ {
@@ -901,33 +731,37 @@ func (m *Manager) tryAcquireBusyOverflow(
if v, ok := m.connections.Load(key); ok {
wc := v.(*WsConnection)
if canReuseConnection(wc) {
+ if m.hasLiveResponseBinding(wc) {
+ lock.Unlock()
+ continue
+ }
if m.probe(wc) {
accountLock.Lock()
current, exists := m.connections.Load(key)
- if exists && current == wc && canReuseConnection(wc) {
- pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession)
+ if exists && current == wc && canReuseConnection(wc) && !m.hasLiveResponseBinding(wc) {
+ pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession, kind)
if leaseErr == nil {
wc.account = account
wc.Touch()
- m.trimIdleAccountConnections(account.ID(), accountLimit, wc)
+ m.trimIdleAccountConnections(account.ID(), connectionLimit(connectionBudgetKind(wc)), wc)
accountLock.Unlock()
lock.Unlock()
return wc, pr, true
}
- m.DiscardConnection(wc)
+ m.discardAcquisitionFailure(wc, leaseErr)
}
accountLock.Unlock()
lock.Unlock()
continue
}
- m.DiscardConnection(wc)
- } else if wc.IsConnected() && !wc.IsExpired() && wc.session != nil && wc.session.PendingCount() > 0 && !isRotatableOverAge(wc) {
+ m.discardConnectionFor(wc, closeProbeFailed)
+ } else if wc.session != nil && wc.session.PendingCount() > 0 {
// 兄弟槽位也在忙:换下一个槽位
lock.Unlock()
continue
} else {
// 死/到龄/过期连接:清掉腾出槽位,下方直接新建
- m.DiscardConnection(wc)
+ m.discardConnectionFor(wc, connectionExpiryReason(wc))
}
}
accountLock.Lock()
@@ -936,16 +770,16 @@ func (m *Manager) tryAcquireBusyOverflow(
lock.Unlock()
continue
}
- if !m.reserveAccountConnectionCapacity(account.ID(), accountLimit, key) {
+ capacity := m.reserveAccountConnectionCapacity(connectionCapacityRequest{accountID: account.ID(), limit: accountLimit, protectedKey: key, kind: kind})
+ if capacity == nil {
// 账号连接容量已满:不为 overflow 挤占更多连接,放弃降级回到等待
accountLock.Unlock()
lock.Unlock()
return nil, nil, false
}
accountLock.Unlock()
- wc, err := m.createConnection(ctx, account, wsURL, slotSession, headers, proxyOverride)
+ wc, err := m.createConnection(ctx, connectionDialRequest{account: account, url: wsURL, sessionKey: slotSession, headers: headers, proxyOverride: proxyOverride, capacity: capacity})
if err != nil {
- m.releaseAccountConnectionCapacity(account.ID())
lock.Unlock()
log.Printf("[WS] busy overflow 新建连接失败,回落等待原连接 (account=%d): %v", account.ID(), err)
return nil, nil, false
@@ -954,14 +788,13 @@ func (m *Manager) tryAcquireBusyOverflow(
if m.afterConnectionStored != nil {
m.afterConnectionStored(wc)
}
- pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession)
+ pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession, kind)
if leaseErr == nil {
if earlyErr := wc.waitForEarlyReadFailure(ctx, newConnectionReadFailureGrace); earlyErr != nil {
wc.session.RemovePendingRequest(pr.RequestID)
leaseErr = earlyErr
}
}
- m.releaseAccountConnectionCapacity(account.ID())
if leaseErr != nil {
m.DiscardConnection(wc)
lock.Unlock()
@@ -992,7 +825,7 @@ const newConnectionReadFailureGrace = 5 * time.Millisecond
// AcquireReusableConnection 在固定槽位内复用或创建连接,返回实际使用的 session key。
// 第一遍只复用已存在且空闲的连接;第二遍在空槽位新建持久连接;槽位全忙时回退到
-// fallbackKey 的临时连接。所有路径仍受账号动态并发对应的连接总数上限约束。
+// fallbackKey 的临时连接。空白预热共用账号空白预算;聊天收尾后按空闲上限保留。
func (m *Manager) AcquireReusableConnection(
ctx context.Context,
account *auth.Account,
@@ -1004,9 +837,10 @@ func (m *Manager) AcquireReusableConnection(
proxyOverride string,
) (*WsConnection, *PendingRequest, string, error) {
proxyURL := effectiveProxyURL(account, proxyOverride)
- accountLimit := accountConnectionLimit(account)
- if slots < 1 || slots > accountLimit {
- slots = accountLimit
+ kind := requestConnectionCapacityKind(ctx)
+ accountLimit := connectionLimit(kind)
+ if slots < 1 || slots > statelessConnectionSlots() {
+ slots = statelessConnectionSlots()
}
accountLock, releaseAccountLock := m.accountLock(account.ID())
defer releaseAccountLock()
@@ -1020,31 +854,35 @@ func (m *Manager) AcquireReusableConnection(
if v, ok := m.connections.Load(key); ok {
wc := v.(*WsConnection)
if canReuseConnection(wc) {
+ if m.hasLiveResponseBinding(wc) {
+ lock.Unlock()
+ continue
+ }
if m.probe(wc) {
accountLock.Lock()
current, exists := m.connections.Load(key)
- if !exists || current != wc || !canReuseConnection(wc) {
+ if !exists || current != wc || !canReuseConnection(wc) || m.hasLiveResponseBinding(wc) {
accountLock.Unlock()
lock.Unlock()
continue
}
- pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession)
+ pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession, kind)
if leaseErr == nil {
wc.account = account
wc.Touch()
- m.trimIdleAccountConnections(account.ID(), accountLimit, wc)
+ m.trimIdleAccountConnections(account.ID(), connectionLimit(connectionBudgetKind(wc)), wc)
accountLock.Unlock()
lock.Unlock()
return wc, pr, slotSession, nil
}
- m.DiscardConnection(wc)
+ m.discardAcquisitionFailure(wc, leaseErr)
accountLock.Unlock()
lock.Unlock()
continue
}
- m.DiscardConnection(wc)
- } else if !wc.IsConnected() || wc.IsExpired() || isRotatableOverAge(wc) || wc.session == nil || wc.session.PendingCount() == 0 {
- m.DiscardConnection(wc)
+ m.discardConnectionFor(wc, closeProbeFailed)
+ } else if wc.session == nil || wc.session.PendingCount() == 0 {
+ m.discardConnectionFor(wc, connectionExpiryReason(wc))
}
}
lock.Unlock()
@@ -1066,15 +904,15 @@ func (m *Manager) AcquireReusableConnection(
lock.Unlock()
continue
}
- if !m.reserveAccountConnectionCapacity(account.ID(), accountLimit, key) {
+ capacity := m.reserveAccountConnectionCapacity(connectionCapacityRequest{accountID: account.ID(), limit: accountLimit, protectedKey: key, kind: kind})
+ if capacity == nil {
accountLock.Unlock()
lock.Unlock()
continue
}
accountLock.Unlock()
- wc, err := m.createConnection(ctx, account, wsURL, slotSession, headers, proxyOverride)
+ wc, err := m.createConnection(ctx, connectionDialRequest{account: account, url: wsURL, sessionKey: slotSession, headers: headers, proxyOverride: proxyOverride, capacity: capacity})
if err != nil {
- m.releaseAccountConnectionCapacity(account.ID())
lock.Unlock()
return nil, nil, "", err
}
@@ -1082,14 +920,13 @@ func (m *Manager) AcquireReusableConnection(
if m.afterConnectionStored != nil {
m.afterConnectionStored(wc)
}
- pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession)
+ pr, leaseErr := m.addPendingAndBeginReadLease(wc, slotSession, kind)
if leaseErr == nil {
if earlyErr := wc.waitForEarlyReadFailure(ctx, newConnectionReadFailureGrace); earlyErr != nil {
wc.session.RemovePendingRequest(pr.RequestID)
leaseErr = earlyErr
}
}
- m.releaseAccountConnectionCapacity(account.ID())
if leaseErr != nil {
m.DiscardConnection(wc)
lock.Unlock()
@@ -1113,10 +950,13 @@ func (m *Manager) AcquireReusableConnection(
// atomic from an acquire caller's perspective. On failure it rolls the pending
// request back; the caller discards the unusable connection while holding its
// pool-key acquisition lock.
-func (m *Manager) addPendingAndBeginReadLease(wc *WsConnection, sessionKey string) (*PendingRequest, error) {
+func (m *Manager) addPendingAndBeginReadLease(wc *WsConnection, sessionKey string, kind connectionCapacityKind) (*PendingRequest, error) {
if wc == nil || wc.session == nil {
return nil, fmt.Errorf("begin websocket read lease: connection has no session")
}
+ if err := m.ensureConnectionBudget(wc, kind); err != nil {
+ return nil, err
+ }
pr := wc.session.AddPendingRequest(sessionKey)
if err := wc.BeginReadLease(pr.RequestID); err != nil {
wc.session.RemovePendingRequest(pr.RequestID)
@@ -1206,77 +1046,15 @@ func logCompressionNegotiation(resp *http.Response, accountID int64) {
}
}
-// createConnection 创建新 WebSocket 连接
-func (m *Manager) createConnection(
- ctx context.Context,
- account *auth.Account,
- wsURL string,
- sessionKey string,
- headers http.Header,
- proxyOverride string,
-) (*WsConnection, error) {
- // 浅拷贝共享 dialer,继承全部调优字段(NetDialContext/KeepAlive、读写缓冲、压缩等),
- // 仅按需覆盖 Proxy;避免逐字段重建时漏抄字段(曾导致 NetDialContext/KeepAlive 失效)。
- dialerCopy := *m.dialer
- dialer := &dialerCopy
-
- // 拨号代理与 ExecuteRequestViaWebsocket 同一套判定:Resin 承担出站时不配代理
- // (传入的 wsURL 已是 Resin 反代地址);poolKey 仍按第 2 层代理分池,保持既有键不变。
- proxyURL := effectiveProxyURL(account, proxyOverride)
- dialProxy := proxy.CodexDialProxyURL(account, proxyURL)
- if dialProxy != "" {
- if err := configureWebsocketDialerProxy(dialer, dialProxy); err != nil {
- return nil, err
- }
- }
-
- // 创建会话(先关闭旧 session 避免泄漏)
- poolKey := m.poolKey(account.ID(), wsURL, sessionKey, proxyURL)
- if oldSessionVal, ok := m.sessions.Load(poolKey); ok {
- oldSession := oldSessionVal.(*Session)
- oldSession.Close()
- }
- session := NewSession(account.ID(), m)
- if trimmed := strings.TrimSpace(sessionKey); trimmed != "" {
- session.ID = trimmed
- }
- m.sessions.Store(poolKey, session)
-
- // 拨号连接
- conn, resp, err := dialer.DialContext(ctx, wsURL, headers)
- if err != nil {
- m.sessions.Delete(poolKey)
- session.Close()
- // bad handshake 时 resp 常非空:附带上游 HTTP 状态/ body,便于测试连接定位。
- return nil, formatDialHandshakeError(err, resp)
- }
-
- logCompressionNegotiation(resp, account.ID())
-
- // 创建连接包装
- wc := NewWsConnection(conn, session, wsURL)
- wc.account = account
- wc.PoolKey = poolKey
- wc.upstreamUserAgent = strings.TrimSpace(headers.Get("User-Agent"))
- wc.upstreamUserAgentKnown = true
- wc.upstreamClientIdentity = websocketClientIdentity(headers)
- wc.httpResp = resp
- wc.onDisconnected = m.getOnDisconnected()
- wc.onReadFailure = m.DiscardConnection
- session.SetConnected(true)
-
- // 控制帧处理器必须在唯一永久 reader 启动前安装。
- wc.installControlHandlers()
- wc.StartReadPump()
-
- return wc, nil
-}
-
// ReleaseConnection 释放连接(归还池)
func (m *Manager) ReleaseConnection(wc *WsConnection) {
if wc == nil {
return
}
+ if wc.session != nil {
+ m.retainReleasedConnection(wc)
+ return
+ }
wc.Touch()
}
@@ -1285,6 +1063,7 @@ func (m *Manager) RemoveConnection(accountID int64, wsURL string, sessionKey str
key := m.poolKey(accountID, wsURL, sessionKey, proxyURL)
if v, ok := m.connections.LoadAndDelete(key); ok {
wc := v.(*WsConnection)
+ wc.recordCloseCause(connectionCloseCause{source: "local", reason: closePoolRemoved, bound: m.hasLiveResponseBinding(wc)})
m.removeResponseConnBindings(wc)
wc.Close()
if wc.session != nil {
@@ -1303,6 +1082,7 @@ func (m *Manager) DiscardConnection(wc *WsConnection) {
if wc == nil {
return
}
+ wc.recordCloseCause(connectionCloseCause{source: "local", reason: closeUnusable, bound: m.hasLiveResponseBinding(wc)})
if wc.PoolKey != "" {
m.connections.CompareAndDelete(wc.PoolKey, wc)
if wc.session != nil {
@@ -1360,6 +1140,11 @@ func (m *Manager) evictResponseConnBindingsLocked(now time.Time) {
// BindResponseConn 记录 response_id 由哪条连接产出(续链亲和)。
func (m *Manager) BindResponseConn(responseID string, wc *WsConnection, sessionKey string, accountID int64, apiKey string) {
+ m.bindResponseConn(responseID, responseConnBinding{conn: wc, sessionKey: sessionKey, accountID: accountID, apiKey: apiKey})
+}
+
+func (m *Manager) bindResponseConn(responseID string, binding responseConnBinding) {
+ wc := binding.conn
responseID = strings.TrimSpace(responseID)
if m == nil || responseID == "" || wc == nil {
return
@@ -1375,21 +1160,25 @@ func (m *Manager) BindResponseConn(responseID string, wc *WsConnection, sessionK
if !m.responseConnBindingLiveLocked(wc) {
return
}
+ if wc.session != nil && connectionBudgetKind(wc) == capacityChat {
+ wc.session.markUserContext()
+ }
if m.respConnBindings == nil {
m.respConnBindings = make(map[string]responseConnBinding, 64)
}
+ // store:false 只承诺连接上的最新响应可续链,旧绑定必须失效。
+ for id, binding := range m.respConnBindings {
+ if binding.conn == wc {
+ delete(m.respConnBindings, id)
+ }
+ }
// 有界保护:先清一轮过期项,仍超限则拒绝新增(旧绑定比新绑定更可能被续链)。
if len(m.respConnBindings) >= responseConnBindingMaxEntries {
m.evictResponseConnBindingsLocked(now)
}
if len(m.respConnBindings) < responseConnBindingMaxEntries {
- m.respConnBindings[responseID] = responseConnBinding{
- conn: wc,
- sessionKey: sessionKey,
- accountID: accountID,
- apiKey: apiKey,
- expiresAt: now.Add(responseConnBindingTTL),
- }
+ binding.expiresAt = now.Add(responseConnBindingTTL)
+ m.respConnBindings[responseID] = binding
}
}
@@ -1420,10 +1209,14 @@ func (m *Manager) lookupResponseConn(responseID string, accountID int64, apiKey
// AcquirePreferredConnection 尝试独占 response_id 绑定的原连接(续链亲和)。
// 成功返回 (连接, pendingRequest, 池内 sessionKey);绑定失效或连接忙时返回 nil,
-// 调用方回退到常规 acquire 路径。忙时不等待:续链上下文虽在原连接,但排队会
-// 阻塞在前一个长响应后面,且该场景(同会话并发续链)极少,退化为缓存 miss 更稳。
+// 此单次尝试保留兼容入口;生产续链通过 acquireContinuation 有界等待。
func (m *Manager) AcquirePreferredConnection(responseID string, accountID int64, apiKey string) (*WsConnection, *PendingRequest, string) {
- wc, sessionKey := m.lookupResponseConn(responseID, accountID, apiKey)
+ return m.acquirePreferredConnection(websocketContinuation{responseID: responseID, accountID: accountID, apiKey: apiKey})
+}
+
+func (m *Manager) acquirePreferredConnection(input websocketContinuation) (*WsConnection, *PendingRequest, string) {
+ accountID := input.accountID
+ wc, sessionKey := m.lookupContinuationConn(input)
if wc == nil {
return nil, nil, ""
}
@@ -1451,17 +1244,18 @@ func (m *Manager) AcquirePreferredConnection(responseID string, accountID int64,
// probe 期间连接被其它 pool key 的容量裁剪安全回收。
accountLock.Lock()
defer accountLock.Unlock()
- if v, exists := m.connections.Load(wc.PoolKey); !exists || v != wc || !canReuseConnection(wc) {
+ current, _ := m.lookupContinuationConn(input)
+ if current != wc || !canReuseConnection(wc) {
return nil, nil, ""
}
- pr, err := m.addPendingAndBeginReadLease(wc, sessionKey)
+ pr, err := m.addPendingAndBeginReadLease(wc, sessionKey, capacityChat)
if err != nil {
- m.DiscardConnection(wc)
+ m.discardAcquisitionFailure(wc, err)
return nil, nil, ""
}
wc.Touch()
if wc.account != nil {
- m.trimIdleAccountConnections(accountID, accountConnectionLimit(wc.account), wc)
+ m.trimIdleAccountConnections(accountID, connectionLimit(connectionBudgetKind(wc)), wc)
}
return wc, pr, sessionKey
}
diff --git a/proxy/wsrelay/manager_test.go b/proxy/wsrelay/manager_test.go
index 37495538c..85b98111c 100644
--- a/proxy/wsrelay/manager_test.go
+++ b/proxy/wsrelay/manager_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/codex2api/auth"
+ "github.com/codex2api/proxy"
"github.com/gorilla/websocket"
)
@@ -272,6 +273,7 @@ func TestCanReuseConnection(t *testing.T) {
session.SetConnected(true)
conn := &WsConnection{session: session}
conn.SetState(StateConnected)
+ conn.Touch()
conn.lastUsed.Store(time.Now().Add(-IdleTimeout - time.Second).UnixNano())
if canReuseConnection(conn) {
@@ -314,7 +316,8 @@ func TestAcquireConnectionWaitsWhileSessionHasPendingRequest(t *testing.T) {
}
}
-func TestAcquireConnectionCapsIdleConnectionsAtAccountConcurrency(t *testing.T) {
+func TestAcquireConnectionCapsIdleConnectionsAtConfiguredSlots(t *testing.T) {
+ setBusyRuntimeSettings(t, func(s *proxy.RuntimeSettings) { s.CodexWSDownstreamKeepaliveSlots = 2 })
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
@@ -332,7 +335,7 @@ func TestAcquireConnectionCapsIdleConnectionsAtAccountConcurrency(t *testing.T)
manager := NewManager()
t.Cleanup(manager.Stop)
- account := &auth.Account{DBID: 42, DynamicConcurrencyLimit: 2}
+ account := &auth.Account{DBID: 42, DynamicConcurrencyLimit: 1}
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
connections := make([]*WsConnection, 0, 3)
@@ -355,7 +358,7 @@ func TestAcquireConnectionCapsIdleConnectionsAtAccountConcurrency(t *testing.T)
}
if got := manager.ConnectionCount(); got != 2 {
- t.Fatalf("ConnectionCount = %d, want account concurrency cap 2", got)
+ t.Fatalf("ConnectionCount = %d, want configured connection cap 2", got)
}
if connections[0].IsConnected() {
t.Fatal("oldest idle connection should be evicted when the account cap is reached")
@@ -423,7 +426,8 @@ func TestAcquireConnectionKeepsActualHandshakeUserAgentWhenReused(t *testing.T)
}
}
-func TestAcquireConnectionTrimsIdleConnectionsAfterDynamicLimitDecrease(t *testing.T) {
+func TestAcquireConnectionTrimsIdleConnectionsAfterConfiguredSlotDecrease(t *testing.T) {
+ setBusyRuntimeSettings(t, func(s *proxy.RuntimeSettings) { s.CodexWSDownstreamKeepaliveSlots = 3 })
manager := NewManager()
t.Cleanup(manager.Stop)
manager.probeFunc = func(*WsConnection) bool { return true }
@@ -439,9 +443,10 @@ func TestAcquireConnectionTrimsIdleConnectionsAfterDynamicLimitDecrease(t *testi
t.Fatalf("ConnectionCount before limit decrease = %d, want 3", got)
}
- account.Mu().Lock()
- account.DynamicConcurrencyLimit = 1
- account.Mu().Unlock()
+ proxy.UpdateRuntimeSettings(func(s proxy.RuntimeSettings) proxy.RuntimeSettings {
+ s.CodexWSDownstreamKeepaliveSlots = 1
+ return s
+ })
protected := connections[1]
got, pending, err := manager.AcquireConnection(
context.Background(), account, wsURL, "session-1", http.Header{}, "",
@@ -452,21 +457,25 @@ func TestAcquireConnectionTrimsIdleConnectionsAfterDynamicLimitDecrease(t *testi
if got != protected {
t.Fatal("existing session connection should be reused after the limit decrease")
}
- if count := manager.ConnectionCount(); count != 1 {
- t.Fatalf("ConnectionCount after limit decrease = %d, want 1", count)
+ if count := manager.ConnectionCount(); count != 2 {
+ t.Fatalf("ConnectionCount after limit decrease = %d, want active 1 + idle 1", count)
}
if !protected.IsConnected() {
t.Fatal("the connection selected for reuse must remain connected")
}
- for i, wc := range connections {
- if wc != protected && wc.IsConnected() {
- t.Fatalf("idle connection %d remained connected after the limit decreased", i)
- }
+ if connections[0].IsConnected() || !connections[2].IsConnected() {
+ t.Fatal("limit decrease should keep the newest idle connection beside the active one")
}
got.session.RemovePendingRequest(pending.RequestID)
+ manager.ReleaseConnection(got)
+ if manager.ConnectionCount() != 1 || !protected.IsConnected() || connections[2].IsConnected() {
+ t.Fatal("completed request should replace the older idle connection")
+ }
}
-func TestAcquireConnectionCountsPendingDialTowardAccountCap(t *testing.T) {
+func TestAcquireConnectionCountsPendingDialTowardBlankBudget(t *testing.T) {
+ setBusyRuntimeSettings(t, func(s *proxy.RuntimeSettings) { s.CodexWSStatelessSlots = 1 })
+ blankCtx := withConnectionCapacityKind(context.Background(), nil, []byte(`{"generate":false,"input":[]}`))
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
started := make(chan struct{})
release := make(chan struct{})
@@ -501,7 +510,7 @@ func TestAcquireConnectionCountsPendingDialTowardAccountCap(t *testing.T) {
firstResult := make(chan acquireResult, 1)
go func() {
wc, pending, err := manager.AcquireConnection(
- context.Background(), account, wsURL, "session-first", http.Header{}, "",
+ blankCtx, account, wsURL, "session-first", http.Header{}, "",
)
firstResult <- acquireResult{wc: wc, pending: pending, err: err}
}()
@@ -512,13 +521,13 @@ func TestAcquireConnectionCountsPendingDialTowardAccountCap(t *testing.T) {
t.Fatal("first websocket dial did not reach the server")
}
- secondCtx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
+ secondCtx, cancel := context.WithTimeout(blankCtx, 40*time.Millisecond)
defer cancel()
_, _, secondErr := manager.AcquireConnection(
secondCtx, account, wsURL, "session-second", http.Header{}, "",
)
if secondErr == nil {
- t.Fatal("second dial should wait for account connection capacity")
+ t.Fatal("second dial should be rejected at account connection capacity")
}
if got := attempts.Load(); got != 1 {
t.Fatalf("websocket dial attempts = %d, want 1 while first dial is pending", got)
diff --git a/proxy/wsrelay/oneshot_conn_test.go b/proxy/wsrelay/oneshot_conn_test.go
index 179d2bc21..ac4f8797e 100644
--- a/proxy/wsrelay/oneshot_conn_test.go
+++ b/proxy/wsrelay/oneshot_conn_test.go
@@ -13,6 +13,7 @@ func newPooledConn(t *testing.T, manager *Manager, accountID int64, sessionID, p
session := NewSession(accountID, manager)
session.ID = sessionID
session.SetConnected(true)
+ session.markUserContext()
conn := &WsConnection{session: session, URL: "wss://example.test/responses", PoolKey: poolKey}
conn.SetState(StateConnected)
conn.lastUsed.Store(lastUsed.UnixNano())
@@ -41,7 +42,7 @@ func TestSortIdleForEvictionPrefersOneShotConns(t *testing.T) {
}
}
-func TestTrimIdleAccountConnectionsEvictsOneShotFirst(t *testing.T) {
+func TestTrimIdleChatConnectionsEvictsOldestFirst(t *testing.T) {
manager := NewManager()
t.Cleanup(manager.Stop)
@@ -56,18 +57,18 @@ func TestTrimIdleAccountConnectionsEvictsOneShotFirst(t *testing.T) {
manager.trimIdleAccountConnections(1, 2, nil)
- if _, ok := manager.connections.Load(oneShotKey); ok {
- t.Fatal("expected one-shot connection to be evicted first under capacity pressure")
+ if _, ok := manager.connections.Load(oneShotKey); !ok {
+ t.Fatal("newest one-shot chat should remain retained")
}
- if _, ok := manager.connections.Load(slotOldKey); !ok {
- t.Fatal("expected LRU slot connection to survive while one-shot conn exists")
+ if _, ok := manager.connections.Load(slotOldKey); ok {
+ t.Fatal("oldest chat should be evicted regardless of its pool key")
}
if _, ok := manager.connections.Load(slotMidKey); !ok {
t.Fatal("expected newer slot connection to survive")
}
}
-func TestEnsureAccountConnectionCapacityEvictsOneShotFirst(t *testing.T) {
+func TestChatInferenceReservationDoesNotConsumeIdleRetention(t *testing.T) {
manager := NewManager()
t.Cleanup(manager.Stop)
@@ -78,12 +79,12 @@ func TestEnsureAccountConnectionCapacityEvictsOneShotFirst(t *testing.T) {
newPooledConn(t, manager, 1, "det-key#0", slotKey, now.Add(-2*time.Minute))
newPooledConn(t, manager, 1, "stateless-11111111-aaaa-bbbb-cccc-000000000003", oneShotKey, now)
- // count=2, pending=1, limit=3:需腾出 1 个槽位,应先逐出一次性连接。
- if !manager.ensureAccountConnectionCapacity(1, 3, "", 1) {
- t.Fatal("expected capacity reservation to succeed after evicting one-shot conn")
+ // 两条空闲连接未超保留上限,新增推理不要求它们让位。
+ if !manager.ensureAccountConnectionCapacity(connectionCapacityRequest{accountID: 1, limit: 3, extraPending: 1}) {
+ t.Fatal("inference should start without evicting retained idle connections")
}
- if _, ok := manager.connections.Load(oneShotKey); ok {
- t.Fatal("expected one-shot connection to be evicted first")
+ if _, ok := manager.connections.Load(oneShotKey); !ok {
+ t.Fatal("inference admission should not consume idle retention")
}
if _, ok := manager.connections.Load(slotKey); !ok {
t.Fatal("expected reusable slot connection to survive")
diff --git a/proxy/wsrelay/read_pump.go b/proxy/wsrelay/read_pump.go
index 02b48968d..489662148 100644
--- a/proxy/wsrelay/read_pump.go
+++ b/proxy/wsrelay/read_pump.go
@@ -139,10 +139,7 @@ func (wc *WsConnection) installControlHandlers() {
)
})
wc.conn.SetPongHandler(func(appData string) error {
- if wc.session != nil {
- wc.session.HandlePong()
- }
- wc.Touch()
+ // Pong 只证明传输层存活,不能延长用户聊天的业务空闲窗口。
wc.touchInbound()
wc.notifyProbePong(appData)
return nil
@@ -203,11 +200,10 @@ func (wc *WsConnection) runReadPump() {
}
if captured.idleSniff {
// 空闲期帧从不投递给任何后续租约(帧在无租约时刻开始,归属已定),
- // 只在"丢弃续命"与"销毁连接"之间裁决。
+ // 只在"丢弃元数据"与"销毁连接"之间裁决,不延长业务空闲窗口。
eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
if isIdleDroppableMetadataFrame(eventType) {
wc.endIdleSniff()
- wc.Touch()
wc.touchInbound()
continue
}
@@ -363,7 +359,7 @@ func (wc *WsConnection) enqueueBusinessFrameForCapturedLease(messageType int, pa
func isReadLeaseTerminal(payload []byte) bool {
switch gjson.GetBytes(payload, "type").String() {
- case "response.completed", "response.failed", "response.done", "error":
+ case "response.completed", "response.incomplete", "response.failed", "response.done", "error":
return true
default:
return false
@@ -448,6 +444,7 @@ func (wc *WsConnection) finalizeReadPumpFailure(state *wsReadState) {
state.mu.Lock()
readerErr := state.readerErr
state.mu.Unlock()
+ wc.recordReadClose(readerErr)
idle := time.Duration(0)
if ts := wc.lastUsed.Load(); ts > 0 {
idle = time.Since(time.Unix(0, ts)).Round(time.Millisecond)
diff --git a/proxy/wsrelay/session.go b/proxy/wsrelay/session.go
index 6036d982d..e25e0b8ea 100644
--- a/proxy/wsrelay/session.go
+++ b/proxy/wsrelay/session.go
@@ -36,8 +36,8 @@ const (
// 写超时:30 秒
WriteTimeout = 30 * time.Second
- // 空闲超时:5 分钟无活动则断开
- IdleTimeout = 5 * time.Minute
+ // 业务空闲超时:30 分钟未处理聊天请求或响应则断开,心跳不续期。
+ IdleTimeout = 30 * time.Minute
// 连接最大寿命:上游 chatgpt backend 对每条 Responses WS 连接强制 60 分钟
// 寿命上限,超限后该连接上的 response.create 一律返回
@@ -132,6 +132,9 @@ type Session struct {
// 最后活跃时间
LastActiveAt time.Time
+ // 曾用于用户聊天;续链绑定过期后仍保留,不能降回空白预热槽位。
+ userContext bool
+
// 连接状态
Connected bool
@@ -163,20 +166,6 @@ func NewSession(accountID int64, manager *Manager) *Session {
}
}
-// Touch 更新最后活跃时间
-func (s *Session) Touch() {
- s.mu.Lock()
- s.LastActiveAt = time.Now()
- s.mu.Unlock()
-}
-
-// IsExpired 检查会话是否过期(空闲超时)
-func (s *Session) IsExpired() bool {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return time.Since(s.LastActiveAt) > connectionIdleTimeout()
-}
-
// SetConnected 设置连接状态
func (s *Session) SetConnected(connected bool) {
s.mu.Lock()
@@ -343,11 +332,6 @@ func (s *Session) StopHeartbeat() {
s.mu.Unlock()
}
-// HandlePong 处理 Pong 响应并刷新会话活动时间。
-func (s *Session) HandlePong() {
- s.Touch()
-}
-
// Close 关闭会话
func (s *Session) Close() {
s.StopHeartbeat()
diff --git a/proxy/wsrelay/session_activity.go b/proxy/wsrelay/session_activity.go
new file mode 100644
index 000000000..0ce90ca84
--- /dev/null
+++ b/proxy/wsrelay/session_activity.go
@@ -0,0 +1,37 @@
+package wsrelay
+
+import "time"
+
+// Touch 更新最后业务活跃时间,并永久标记该会话曾承载用户聊天。
+func (s *Session) Touch() {
+ s.touchActivity(true)
+}
+
+func (s *Session) touchActivity(userContext bool) {
+ s.mu.Lock()
+ s.LastActiveAt = time.Now()
+ s.userContext = s.userContext || userContext
+ s.mu.Unlock()
+}
+
+func (s *Session) hasUserContext() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.userContext
+}
+
+func (s *Session) markUserContext() {
+ s.mu.Lock()
+ s.userContext = true
+ s.mu.Unlock()
+}
+
+// IsExpired 检查业务空闲超时;空白会话在普通模式下继续保持连接。
+func (s *Session) IsExpired() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if !s.userContext && !weakNetworkModeEnabled() {
+ return false
+ }
+ return time.Since(s.LastActiveAt) >= connectionIdleTimeout()
+}
diff --git a/proxy/wsrelay/session_test.go b/proxy/wsrelay/session_test.go
index de7f1057d..bf69f075f 100644
--- a/proxy/wsrelay/session_test.go
+++ b/proxy/wsrelay/session_test.go
@@ -103,13 +103,14 @@ func TestSessionExpiration(t *testing.T) {
}
// 手动设置 LastActiveAt 为很久以前
+ session.Touch()
session.mu.Lock()
- session.LastActiveAt = time.Now().Add(-10 * time.Minute)
+ session.LastActiveAt = time.Now().Add(-IdleTimeout - time.Minute)
session.mu.Unlock()
- // 现在应该过期(IdleTimeout = 5分钟)
+ // 超过当前业务空闲窗口后应过期。
if !session.IsExpired() {
- t.Error("session should be expired after 10 minutes of inactivity")
+ t.Error("session should be expired after IdleTimeout of inactivity")
}
}
diff --git a/proxy/wsrelay/subagent_transport_lane_test.go b/proxy/wsrelay/subagent_transport_lane_test.go
index 4e165a56e..2b7352d3c 100644
--- a/proxy/wsrelay/subagent_transport_lane_test.go
+++ b/proxy/wsrelay/subagent_transport_lane_test.go
@@ -185,7 +185,8 @@ func TestSubagentTransportLanesAvoidSharedSessionBusyWait(t *testing.T) {
}
})
- t.Run("account capacity remains authoritative", func(t *testing.T) {
+ t.Run("inference lanes do not consume idle retention", func(t *testing.T) {
+ setBusyRuntimeSettings(t, func(s *proxy.RuntimeSettings) { s.CodexWSDownstreamKeepaliveSlots = 1 })
manager := NewManager()
t.Cleanup(manager.Stop)
limitedAccount := &auth.Account{DBID: 43, DynamicConcurrencyLimit: 1}
@@ -209,8 +210,16 @@ func TestSubagentTransportLanesAvoidSharedSessionBusyWait(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
- if _, _, err := manager.AcquireConnection(ctx, limitedAccount, wsURL, childLane, childHeaders, ""); err == nil {
- t.Fatal("child bypassed the account connection capacity limit")
+ child, childPending, err := manager.AcquireConnection(ctx, limitedAccount, wsURL, childLane, childHeaders, "")
+ if err != nil {
+ t.Fatalf("idle retention rejected active child lane: %v", err)
+ }
+ t.Cleanup(func() {
+ child.session.RemovePendingRequest(childPending.RequestID)
+ manager.DiscardConnection(child)
+ })
+ if child == parent || !parent.IsConnected() || !child.IsConnected() {
+ t.Fatal("idle retention should preserve both active inference lanes")
}
})
}