diff --git a/.downstream/codex-quality-diagnostics.md b/.downstream/codex-quality-diagnostics.md index 00f5077f7b73..8ff048cfd9f5 100644 --- a/.downstream/codex-quality-diagnostics.md +++ b/.downstream/codex-quality-diagnostics.md @@ -2,6 +2,8 @@ 此入口用于管理员在真实 `/v1/responses` 链路上运行有界对照。路由材料、完整响应、上游模型声明和回答质量是四种不同证据;采集成功不代表回答质量恢复。 +本文描述协议与账号身份修复合入后的组合行为:诊断与普通业务共用最终出站身份、workspace、连接租约检查与模型声明守卫;诊断授权、预算、原始观测和路由材料仍独立于普通调度状态。 + ## 接口与范围 所有管理操作位于 `/api/v1/admin/accounts/:id/codex-quality-runs`,要求真实管理员身份。运行绑定该管理员已有的业务 Key、其分组、目标 Pro OAuth 账号主体、代理、`gpt-6-astra`、显式 `high` 和题面 SHA-256。 @@ -10,7 +12,7 @@ | --- | --- | --- | | `POST` 基础路径 | `run_id`、`api_key_id`、`prompt_sha256`、`max_sends`、`ttl_seconds` | 创建或恢复同一运行,换发短时 grant;最多 60 次发送、最长 7200 秒 | | `GET /:run_id` | 无正文 | 读取预算、原始上游观测和私有路由资格 | -| `POST /:run_id/renew-route` | `operation_id` | 显式采集与业务出口复验,均计入同一预算 | +| `POST /:run_id/renew-route` | `operation_id` | 用 `gpt-6-astra/high` 轻量探针显式采集与业务出口复验,均计入同一预算 | | `POST /:run_id/close` | 空对象 | 关闭 grant,释放该运行自身连接,擦除私有 Cookie 值,保留账本 | `run_id`、`operation_id` 与 trial 均使用规范化 UUID。同一 run 恢复时不能改变绑定范围或增加预算,换发 grant 不恢复已花费次数;closed 运行不能重开。 @@ -24,9 +26,14 @@ grant 与 trial header 在 handler 开始时移入私有执行上下文,不转 - 只在私有选号判断中跳过账号手动停调度开关;账号、Key、分组和代理配置不修改。当前 Key 状态、到期、额度、渠道模型限制、账号健康、并发及路由资格仍检查。 - acquire、verify、business 共用持久 CAS 预算,在本地准备与检查结束、上游 I/O 开始前预留。传输结果未知保留为 unknown,不能重放该 trial;预留次数是保守的发送上限,不能宣称每次预留均已得到上游响应。 - 诊断材料使用独立 bundle、clock 与连接,不能覆盖普通资格,不登记自动续获。GET 不抢占或恢复普通 half-open probe。 +- 私有资格和普通资格共用本进程租约检查:连接必须仍存在、未被已知关闭,主体、scope 与有效期须一致。检查不发送探针、不重拨或延长原租约;持久 bundle 本身不能证明进程重启后的连接仍可用。 - 普通健康、调度恢复、proxy circuit 和自动恢复通知与诊断隔离;实际用量账单及额度快照按真实请求记录。 -- 记录来源是网关改写前的原始响应。`created_model`、`terminal_model`、HTTP/事件模型头分别保留;模型头冲突保存在 `header_models`。缺失 token 数据为 null,不替换为零。 -- `completed` 仅代表成功终态观测,不能替代模型核验或数学判分。当前仍保留修前探针形态,探针未显式给出的 effort 如实记录为空。 +- 读取次序固定为网络 → 原始诊断观察 → 模型声明守卫 → 业务响应改写。`created_model`、`terminal_model`、HTTP/事件模型头分别保留,模型头名称不区分大小写;模型头冲突保存在 `header_models`。被守卫拦下的已读原始帧仍记入同一次预算,未读取的正文模型保持未知。缺失 token 数据为 null,不替换为零。 +- 诊断私有发送同样使用模型守卫;首输出前的模型错配不写入下游,已经输出的请求不会自动重放。资格要求终态模型与所有已提供的原始模型声明一致,早期声明或模型头冲突不能被最后一个正确标签覆盖。失败只撤销本诊断运行对应连接的资格,不修改普通账号健康状态。 +- `acquire`、`verify` 的模型发送显式使用 `high`,仍是轻量 `ping`/`pong` 协议探针,总读取预算保持 128 KiB。`business` 的 SSE 单事件上限复用 `Gateway.MaxLineSize`,JSON 复用 `Gateway.UpstreamResponseReadMaxBytes`;不会套用探针上限,也不使用无界响应缓冲。普通非诊断探针未指定 effort 时继续省略该字段。 +- `completed` 只代表原始上游成功终态观测;同一条记录仍可能因早期模型、模型头冲突或客户端取消而不能作为通过样本。它不能替代模型核验或数学判分。 + +普通 Codex 的 Responses-Lite 请求保留 `input` 中已有基础提示和工具,不再额外填入顶层默认 `instructions`。OAuth STATE 只在同一账号主体、会话和显式逻辑 turn 内保留首个已提交值;Messages 只有完整响应成功写入下游后才保存。缺少 turn 证据不复用旧值,API Key 会话续接契约保持。 ## 运维执行器 @@ -52,4 +59,4 @@ python scripts/invoke-codex-quality-acceptance.py status 诊断基础设施先独立发布并采集修前基线。协议与账号身份修复随后发布,在共同宿主基线、同一账号和同一预算下完成修后序列。不能先应用修复再把结果命名为修前基线。 -后续协议整合必须保持读取次序:网络 → 原始诊断观察 → 模型声明检查 → 业务响应改写。诊断业务流的读取限界需与普通业务一致,轻量采集探针保持自身预算。 +组合版本只用于修后序列。原始诊断观察始终位于模型守卫内层,避免守卫丢弃错误帧后账本反而看不到错误声明;上面的业务与探针读取限界同样适用于诊断私有传输。 diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 7b097256dd3f..e9fd4b3c5dcb 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -47,6 +47,7 @@ jobs: - name: Agent protocol contracts run: bash .github/scripts/test_agent_protocol.sh - name: First-party plugin contracts and signed process package + # Includes codex-runtime routing outcomes and legacy-state migration. run: bash .github/scripts/test_first_party_plugins.sh - name: Reasoning replay and recovery contracts run: bash .github/scripts/test_reasoning_replay.sh @@ -54,7 +55,7 @@ jobs: run: bash .github/scripts/test_model_context_capacity.sh - name: Service tests working-directory: backend - run: go test -p=1 -tags unit ./internal/service -run 'Test.*(Refusal|CyberPolicy|Continuation|InvalidEncryptedContent|PreviousResponseNotFound|CapacityShed|RequestScopedTransient|ServerOverloaded|BusinessSystemPrompt|RemoteSkill|Cindy|CompatibilityModes|AlphaSearch|CodexQuality)|Test(UpdateService|NormalizeAccountTaxonomyName|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1 + run: go test -p=1 -tags unit ./internal/service -run 'Test.*(Refusal|CyberPolicy|Continuation|InvalidEncryptedContent|PreviousResponseNotFound|CapacityShed|RequestScopedTransient|ServerOverloaded|BusinessSystemPrompt|RemoteSkill|Cindy|CompatibilityModes|AlphaSearch|CodexQuality|SelectedWorkspace)|Test(UpdateService|NormalizeAccountTaxonomyName|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1 - name: Upstream automation classifier tests run: | python3 .github/scripts/test_upstream_risk.py @@ -77,10 +78,10 @@ jobs: - name: Plugin policy cancellation race tests working-directory: backend run: go test -race -tags unit ./internal/service -run '^(TestPluginPolicyCancellationSurvivesUpstreamDetachWithoutCancelingBilling|TestPluginPolicyContextsStopOnConfigurationChangeAndDisable|TestSavingIdenticalPluginConfigurationDoesNotCancelHostWork|TestPluginOwnedHostJobHoldsUpdateLeaseUntilCleanup|TestAccountJobRetryPreservesOwnerAndAdmitsCurrentGeneration|TestLegacyImportJobUsesCurrentPluginLifetimeWithoutRewritingHistory|TestPluginJobCompletionDoesNotCancelButPolicyStopPersistsCancellation)$' -count=1 - - name: Cindy manual balance, split, image, and turn-state tests + - name: Account identity, routing leases, balance, image, and turn-state tests working-directory: backend run: | - go test -p=1 -tags unit ./internal/repository ./internal/service ./internal/handler ./internal/handler/admin -run 'Test.*(AccountCredentialIdentity|AccountJobCindyMutation|ProviderIdentityMismatch|CindyBalanceProbe|CindyGroupSplit|CindyCatalog|CindyCodexModels|ImageStudioEligibleKeys|CindyCompatibility|LegacyCindyRuntimeCompatibility|CodexImageGenerationBridgeOverridePrecedence|TurnStateCommits|SchedulerCache|SchedulerRefresh|KeepsCindyRoutingIdentity|StrictCindyGroup|MaterializesCindyIdentity|DisabledOrdinaryMember|SkipsCindyAggregate|GatewayModelCapabilities|GatewayModels|WriteOpenAIModelsList|AccountHandlerGetAvailableModels|ForwardResponsesOllamaClampPreservesContinuationPolicy|AccountAvailableModels|FetchOpenAIAccountModels|SetAnthropicAPIKeyAuthHeader|OllamaCloud.*(MaxTokens|MaxOutputTokens|Bearer)|Clamps.*OllamaBase)' -count=1 + go test -p=1 -tags unit ./internal/repository ./internal/service ./internal/handler ./internal/handler/admin -run 'Test.*(AccountCredentialIdentity|AccountJobCindyMutation|ProviderIdentityMismatch|CindyBalanceProbe|CindyGroupSplit|CindyCatalog|CindyCodexModels|ImageStudioEligibleKeys|CindyCompatibility|LegacyCindyRuntimeCompatibility|CodexImageGenerationBridgeOverridePrecedence|TurnStateCommits|LegacyCodexRoutingImport|CodexConnectionLease|SchedulerCache|SchedulerRefresh|KeepsCindyRoutingIdentity|StrictCindyGroup|MaterializesCindyIdentity|DisabledOrdinaryMember|SkipsCindyAggregate|GatewayModelCapabilities|GatewayModels|WriteOpenAIModelsList|AccountHandlerGetAvailableModels|ForwardResponsesOllamaClampPreservesContinuationPolicy|AccountAvailableModels|FetchOpenAIAccountModels|SetAnthropicAPIKeyAuthHeader|OllamaCloud.*(MaxTokens|MaxOutputTokens|Bearer)|Clamps.*OllamaBase)' -count=1 go test -p=1 -tags unit ./migrations -run 'Test.*(Migration222|Migration223|Migration224|Migration230|Migration231|Migration232|Migration235|Migration236|GroupModelAllowlistRepairMigration|ReleaseAcceptance)' -count=1 - name: Request body recovery tests working-directory: backend diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3bdb3d1c6415..2e8ae9290010 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1028,8 +1028,8 @@ type GatewayConfig struct { // OpenAICompactModel: /responses/compact 上游使用的模型。 // compact 端点支持模型滞后于普通 /responses 时,可用该配置降级规避上游错误。 OpenAICompactModel string `mapstructure:"openai_compact_model"` - // OpenAICodexTicket: ChatGPT OAuth 账号按 (账号, 模型) 捕获 292 长度 - // x-codex-turn-state,并在住宅 IP 业务请求中注入该头。默认关闭。 + // OpenAICodexTicket: Codex 路由插件的旧配置兼容入口。路由材料须按 + // 所选账号、模型与业务出口验证,不使用 STATE 长度判定资格。默认关闭。 OpenAICodexTicket OpenAICodexTicketConfig `mapstructure:"openai_codex_ticket"` // OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"` @@ -1248,14 +1248,13 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string { return "" } -// OpenAICodexTicketConfig 控制 ChatGPT OAuth 的 x-codex-turn-state 门票。 -// 打票走 harvest_proxy_url(SOCKS),业务出站仍用账号住宅 proxy_id,只替换该请求头。 -// 门票默认有效 3600 秒,临近过期前 refresh_before_seconds 重新打票。 +// OpenAICodexTicketConfig retains the legacy bootstrap configuration. Runtime +// route validation and expiry are owned by the Codex routing plugin. type OpenAICodexTicketConfig struct { Enabled bool `mapstructure:"enabled"` - TargetLength int `mapstructure:"target_length"` - TTLSeconds int `mapstructure:"ttl_seconds"` - RefreshBeforeSeconds int `mapstructure:"refresh_before_seconds"` + TargetLength int `mapstructure:"target_length"` // Deprecated: accepted for old files, ignored by routing. + TTLSeconds int `mapstructure:"ttl_seconds"` // Deprecated: not a routing-material lifetime. + RefreshBeforeSeconds int `mapstructure:"refresh_before_seconds"` // Deprecated: ignored by routing. HarvestProxyURL string `mapstructure:"harvest_proxy_url"` HarvestProbeIntervalSeconds int `mapstructure:"harvest_probe_interval_seconds"` HarvestAttemptTimeoutSeconds int `mapstructure:"harvest_attempt_timeout_seconds"` @@ -2436,9 +2435,6 @@ func setDefaults() { viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.5") viper.SetDefault("gateway.openai_codex_ticket.enabled", false) - viper.SetDefault("gateway.openai_codex_ticket.target_length", 292) - viper.SetDefault("gateway.openai_codex_ticket.ttl_seconds", 3600) - viper.SetDefault("gateway.openai_codex_ticket.refresh_before_seconds", 60) viper.SetDefault("gateway.openai_codex_ticket.harvest_proxy_url", "") viper.SetDefault("gateway.openai_codex_ticket.harvest_probe_interval_seconds", 6) viper.SetDefault("gateway.openai_codex_ticket.harvest_attempt_timeout_seconds", 25) @@ -2656,6 +2652,11 @@ func setEnvReachableDefaults() { viper.SetDefault("gateway.user_message_queue.mode", "") viper.SetDefault("update.proxy_url", "") + // Retired Codex bootstrap fields are accepted for compatibility and ignored by routing. + viper.SetDefault("gateway.openai_codex_ticket.target_length", 0) + viper.SetDefault("gateway.openai_codex_ticket.ttl_seconds", 0) + viper.SetDefault("gateway.openai_codex_ticket.refresh_before_seconds", 0) + // sticky_escape_enabled is the one exception to the zero-value rule: its // effective default is true, applied post-unmarshal via a viper.IsSet guard. // Registering false would make IsSet always report true and permanently diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1070b071906d..62f40f4e6fab 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -57,7 +57,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_5h_", "codex_7d_", "codex_reset_credit_", - // 292 门票是纯运行态凭据:它不在 filterSchedulerExtra 的投影白名单里, + // 历史 STATE 材料是运行态凭据:它不在 filterSchedulerExtra 的投影白名单里, // 因此 bucket 重建事件永远搬不动门票状态,续期时开事务+发 outbox 是白干。 // 归为观测型后仍会同步单账号快照(见 UpdateExtra),不丢任何新鲜度。 "codex_turn_ticket:", diff --git a/backend/internal/repository/account_repo_codex_lifecycle.go b/backend/internal/repository/account_repo_codex_lifecycle.go index e3b8f4dd5569..ee20a02e8379 100644 --- a/backend/internal/repository/account_repo_codex_lifecycle.go +++ b/backend/internal/repository/account_repo_codex_lifecycle.go @@ -34,18 +34,3 @@ func scanTicketState(row interface{ Scan(...any) error }) (*service.CodexTicketL } return s, nil } - -func ticketFromAccount(a *service.Account, model string) *service.CodexTicketRecord { - if a == nil { - return nil - } - raw, err := json.Marshal(a.Extra["codex_turn_ticket:"+model]) - if err != nil { - return nil - } - var t service.CodexTicketRecord - if json.Unmarshal(raw, &t) != nil || t.State == "" { - return nil - } - return &t -} diff --git a/backend/internal/repository/codex_connection_lease.go b/backend/internal/repository/codex_connection_lease.go index 01a61d6ddcad..e2393db186a7 100644 --- a/backend/internal/repository/codex_connection_lease.go +++ b/backend/internal/repository/codex_connection_lease.go @@ -26,6 +26,31 @@ type codexConnectionLease struct { expiresAt time.Time client *http.Client transport *http.Transport + alive *atomic.Bool +} + +type codexLeasedConn struct { + net.Conn + alive *atomic.Bool +} + +func (c *codexLeasedConn) Close() error { + c.alive.Store(false) + return c.Conn.Close() +} + +func (s *httpUpstreamService) CheckCodexConnectionLease(accountID int64, scope, leaseID string, expiresAt time.Time) error { + s.mu.RLock() + lease := s.codexConnectionLeases[leaseID] + s.mu.RUnlock() + now := time.Now() + if lease == nil || lease.alive == nil || !lease.alive.Load() || !now.Before(lease.expiresAt) || !now.Before(expiresAt) { + return service.ErrCodexConnectionLeaseExpired + } + if accountID <= 0 || lease.accountID != accountID || scope == "" || lease.scope != scope || expiresAt.After(lease.expiresAt) { + return service.ErrCodexConnectionLeaseScope + } + return nil } func (s *httpUpstreamService) DoWithCodexConnectionLease(req *http.Request, proxyURL string, accountID int64, scope string, leaseID string, expiresAt time.Time, profile *tlsfingerprint.Profile) (*http.Response, string, error) { @@ -57,6 +82,13 @@ func (s *httpUpstreamService) DoWithCodexConnectionLease(req *http.Request, prox if lease.accountID != accountID || lease.scope != scope || lease.origin != origin || lease.transportHash != transportHash { return nil, "", service.ErrCodexConnectionLeaseScope } + checkDeadline := expiresAt + if checkDeadline.IsZero() { + checkDeadline = lease.expiresAt + } + if err := s.CheckCodexConnectionLease(accountID, scope, leaseID, checkDeadline); err != nil { + return nil, "", err + } } else { if expiresAt.IsZero() || expiresAt.After(now.Add(120*time.Second)) { expiresAt = now.Add(120 * time.Second) @@ -80,12 +112,18 @@ func (s *httpUpstreamService) DoWithCodexConnectionLease(req *http.Request, prox // Exactly one physical dial is authorized. A disconnected/expired lease // must fail before any request can reach a different residential exit. var dialed atomic.Bool + alive := &atomic.Bool{} wrap := func(dial func(context.Context, string, string) (net.Conn, error)) func(context.Context, string, string) (net.Conn, error) { return func(ctx context.Context, network, address string) (net.Conn, error) { if !time.Now().Before(expiresAt) || !dialed.CompareAndSwap(false, true) { return nil, service.ErrCodexConnectionLeaseExpired } - return dial(ctx, network, address) + connection, err := dial(ctx, network, address) + if err != nil { + return nil, err + } + alive.Store(true) + return &codexLeasedConn{Conn: connection, alive: alive}, nil } } if transport.DialContext != nil { @@ -101,7 +139,7 @@ func (s *httpUpstreamService) DoWithCodexConnectionLease(req *http.Request, prox } leaseID = hex.EncodeToString(id[:]) lease = &codexConnectionLease{accountID: accountID, scope: scope, origin: origin, transportHash: transportHash, - expiresAt: expiresAt, transport: transport, + expiresAt: expiresAt, transport: transport, alive: alive, client: &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} s.mu.Lock() if s.codexConnectionLeases == nil { diff --git a/backend/internal/repository/codex_connection_lease_check_test.go b/backend/internal/repository/codex_connection_lease_check_test.go new file mode 100644 index 000000000000..5b1fa888ffc8 --- /dev/null +++ b/backend/internal/repository/codex_connection_lease_check_test.go @@ -0,0 +1,46 @@ +package repository + +import ( + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestCodexConnectionLeaseLocalCheckDoesNotDial(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + _, _ = io.WriteString(w, "complete") + })) + t.Cleanup(server.Close) + upstream, ok := NewHTTPUpstream(nil).(*httpUpstreamService) + require.True(t, ok) + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil) + require.NoError(t, err) + deadline := time.Now().Add(time.Minute) + response, id, err := upstream.DoWithCodexConnectionLease(request, "", 71, "fixture-scope", "", deadline, nil) + require.NoError(t, err) + _, err = io.Copy(io.Discard, response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.NoError(t, upstream.CheckCodexConnectionLease(71, "fixture-scope", id, deadline)) + require.ErrorIs(t, upstream.CheckCodexConnectionLease(72, "fixture-scope", id, deadline), service.ErrCodexConnectionLeaseScope) + require.ErrorIs(t, upstream.CheckCodexConnectionLease(71, "different-scope", id, deadline), service.ErrCodexConnectionLeaseScope) + require.ErrorIs(t, upstream.CheckCodexConnectionLease(71, "fixture-scope", id, deadline.Add(time.Second)), service.ErrCodexConnectionLeaseScope) + restarted, ok := NewHTTPUpstream(nil).(*httpUpstreamService) + require.True(t, ok) + require.ErrorIs(t, restarted.CheckCodexConnectionLease(71, "fixture-scope", id, deadline), service.ErrCodexConnectionLeaseExpired) + upstream.mu.RLock() + lease := upstream.codexConnectionLeases[id] + upstream.mu.RUnlock() + lease.transport.CloseIdleConnections() + require.ErrorIs(t, upstream.CheckCodexConnectionLease(71, "fixture-scope", id, deadline), service.ErrCodexConnectionLeaseExpired) + require.EqualValues(t, 1, calls.Load()) + upstream.removeCodexConnectionLease(id, lease) +} diff --git a/backend/internal/repository/codex_quality_lease.go b/backend/internal/repository/codex_quality_lease.go index 341bcc5f11e3..a2cab789a8b4 100644 --- a/backend/internal/repository/codex_quality_lease.go +++ b/backend/internal/repository/codex_quality_lease.go @@ -1,14 +1,5 @@ package repository -import "time" - -func (s *httpUpstreamService) HasCodexQualityConnection(id string, accountID int64, scope string) bool { - s.mu.RLock() - defer s.mu.RUnlock() - lease := s.codexConnectionLeases[id] - return lease != nil && lease.accountID == accountID && lease.scope == scope && time.Now().Before(lease.expiresAt) -} - // Close only the connection whose complete ownership tuple belongs to this // diagnostic run. Shared pools and other accounts' leases are never touched. func (s *httpUpstreamService) CloseCodexQualityConnection(id string, accountID int64, scope string) { diff --git a/backend/internal/repository/plugin_legacy_import.go b/backend/internal/repository/plugin_legacy_import.go index d2c4d1cf34d1..ff3275420564 100644 --- a/backend/internal/repository/plugin_legacy_import.go +++ b/backend/internal/repository/plugin_legacy_import.go @@ -84,17 +84,28 @@ func (r *pluginRepository) importLegacyTicket(ctx context.Context, plugin string if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } - ticket := ticketFromAccount(account, model) - if legacy == nil && ticket == nil { + _, hasLegacyMaterial := account.Extra["codex_turn_ticket:"+model] + if legacy == nil && !hasLegacyMaterial { return nil } + request, err := legacyCodexRoutingImport(account, model, legacy, time.Now().UTC()) + if err != nil { + return err + } + _, err = r.CompareSwapExtensionState(ctx, plugin, request) + return err +} + +// Retired STATE values never grant access, regardless of length or shape. +// Only the persisted lifecycle can carry renewal intent across the cutover. +func legacyCodexRoutingImport(account *service.Account, model string, legacy *service.CodexTicketLifecycle, now time.Time) (extensionv1.StateRequest, error) { identity := service.CodexTicketAccountIdentity(account) phase := "stopped" - var next, expiry, attempt *time.Time + var next, attempt *time.Time var lastCode, operation string - manualEnrolled := false + enrolled := false if legacy != nil { - phase, next, expiry, attempt, manualEnrolled = legacy.Phase, legacy.NextAt, legacy.ExpiresAt, legacy.LastAttemptAt, legacy.ManualWasEnrolled + phase, attempt = legacy.Phase, legacy.LastAttemptAt if legacy.LastResult != nil { lastCode = legacy.LastResult.Code } @@ -109,45 +120,20 @@ func (r *pluginRepository) importLegacyTicket(ctx context.Context, plugin string lastCode = "ticket_interrupted" } } - if ticket != nil && (ticket.AccountID != 0 && ticket.AccountID != account.ID || ticket.Model != "" && ticket.Model != model || len(ticket.State) != 292 || !strings.HasPrefix(ticket.State, "gAAAAA") || ticket.Identity != "" && ticket.Identity != identity) { - ticket = nil - phase = "stopped" - next = nil + if phase == "ready" || phase == "retry" { + phase, enrolled, next = "needs_cookie_verification", true, &now } - var rawTicket any - if ticket != nil { - captured := ticket.CapturedAt - if captured.IsZero() { - captured = ticket.ExpiresAt.Add(-time.Hour) - } - rawTicket = map[string]any{"state": ticket.State, "account_id": account.ID, "identity": identity, "model": model, "captured_at": captured, "expires_at": ticket.ExpiresAt} - if legacy == nil && time.Now().Before(ticket.ExpiresAt) { - phase = "ready" - expiry = &ticket.ExpiresAt - due := ticket.ExpiresAt.Add(-time.Minute) - next = &due - } - } - if ticket == nil || phase == "stopped" { - next = nil + if lastCode == "" || lastCode == "ticket_ready" || lastCode == "ticket_skipped" { + lastCode = "routing_legacy_retired" } - value, err := json.Marshal(map[string]any{"phase": phase, "identity": identity, "operation_id": operation, "next_attempt_at": next, "expires_at": expiry, "manual_was_enrolled": manualEnrolled, "last_code": lastCode, "last_attempt_at": attempt, "ticket": rawTicket}) + value, err := json.Marshal(map[string]any{"schema": extensionv1.CodexRoutingSchema, "phase": phase, "identity": identity, "enrolled": enrolled, "operation_id": operation, "next_attempt_at": next, "last_code": lastCode, "last_attempt_at": attempt}) if err != nil { - return err + return extensionv1.StateRequest{}, err } constraint := extensionv1.SchedulingConstraint{Model: model, Effect: "deny", Reason: "ticket_missing"} - if ticket != nil && time.Now().Before(ticket.ExpiresAt) { - constraint.Effect = "allow" - constraint.Until = &ticket.ExpiresAt - constraint.Reason = "ticket_ready" - } sum := sha256.Sum256([]byte(model)) - observation := extensionv1.AccountObservation{Key: model, Kind: "codex_ticket", State: phase, ExpiresAt: expiry, NextAt: next, CheckedAt: attempt, Code: lastCode} - if ticket != nil { - observation.Count = len(ticket.State) - } - _, err = r.CompareSwapExtensionState(ctx, plugin, extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(account.ID, 10) + "." + hex.EncodeToString(sum[:]), Value: value, NextAt: next, Projection: &extensionv1.AccountProjection{AccountID: account.ID, Identity: identity, Scheduling: []extensionv1.SchedulingConstraint{constraint}, Observations: []extensionv1.AccountObservation{observation}}}) - return err + observation := extensionv1.AccountObservation{Key: model, Kind: "codex_routing", State: phase, NextAt: next, CheckedAt: attempt, Code: lastCode} + return extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(account.ID, 10) + "." + hex.EncodeToString(sum[:]), Value: value, NextAt: next, Projection: &extensionv1.AccountProjection{AccountID: account.ID, Identity: identity, Scheduling: []extensionv1.SchedulingConstraint{constraint}, Observations: []extensionv1.AccountObservation{observation}}}, nil } func (r *pluginRepository) importLegacyProxyTrust(ctx context.Context, plugin, raw string) error { diff --git a/backend/internal/repository/plugin_legacy_ticket_state_test.go b/backend/internal/repository/plugin_legacy_ticket_state_test.go new file mode 100644 index 000000000000..c3d08adf5927 --- /dev/null +++ b/backend/internal/repository/plugin_legacy_ticket_state_test.go @@ -0,0 +1,46 @@ +package repository + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestLegacyCodexRoutingImportRetiresStateWithoutGrantingAccess(t *testing.T) { + now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + for _, length := range []int{292, 312, 332, 356, 780} { + account := &service.Account{ID: 7, Platform: "openai", Type: "oauth", Credentials: map[string]any{"chatgpt_account_id": "team", "plan_type": "business"}, Extra: map[string]any{"codex_turn_ticket:gpt-6-astra": map[string]any{"state": strings.Repeat("x", length), "expires_at": now.Add(time.Hour)}}} + for _, phase := range []string{"", "ready", "retry", "stopped", "manual_running"} { + var legacy *service.CodexTicketLifecycle + if phase != "" { + legacy = &service.CodexTicketLifecycle{Phase: phase, JobID: 17} + } + request, err := legacyCodexRoutingImport(account, "gpt-6-astra", legacy, now) + require.NoError(t, err) + var state map[string]any + require.NoError(t, json.Unmarshal(request.Value, &state)) + require.NotContains(t, state, "ticket") + require.NotContains(t, state, "qualification") + require.NotContains(t, state, "expires_at") + require.Equal(t, "deny", request.Projection.Scheduling[0].Effect) + require.Nil(t, request.Projection.Scheduling[0].Until) + require.Zero(t, request.Projection.Observations[0].Count) + if phase == "ready" || phase == "retry" { + require.Equal(t, "needs_cookie_verification", state["phase"]) + require.Equal(t, true, state["enrolled"]) + require.Equal(t, now, *request.NextAt) + } else { + require.Equal(t, "stopped", state["phase"]) + require.Equal(t, false, state["enrolled"]) + require.Nil(t, request.NextAt) + } + if legacy != nil { + require.Equal(t, "job-17", state["operation_id"]) + } + } + } +} diff --git a/backend/internal/service/codex_connection_lease.go b/backend/internal/service/codex_connection_lease.go index 397196e9bff1..766d447a14c4 100644 --- a/backend/internal/service/codex_connection_lease.go +++ b/backend/internal/service/codex_connection_lease.go @@ -21,3 +21,9 @@ var ( type CodexConnectionLeaseUpstream interface { DoWithCodexConnectionLease(req *http.Request, proxyURL string, accountID int64, scope string, leaseID string, expiresAt time.Time, profile *tlsfingerprint.Profile) (*http.Response, string, error) } + +// CodexConnectionLeaseInspector only examines a connection already owned by +// this process. It must not dial, send a probe, or extend the lease deadline. +type CodexConnectionLeaseInspector interface { + CheckCodexConnectionLease(accountID int64, scope, leaseID string, expiresAt time.Time) error +} diff --git a/backend/internal/service/codex_outbound_contract.go b/backend/internal/service/codex_outbound_contract.go new file mode 100644 index 000000000000..5d411310f4c3 --- /dev/null +++ b/backend/internal/service/codex_outbound_contract.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type codexRoutingDownstreamContextKey struct{} + +// Keep cancellation evidence without reconnecting it to the upstream context: +// existing callers intentionally detach that context to drain usage on cancel. +func withCodexRoutingDownstreamContext(request *http.Request, c *gin.Context) *http.Request { + if request == nil || c == nil || c.Request == nil { + return request + } + return request.WithContext(context.WithValue(request.Context(), codexRoutingDownstreamContextKey{}, c.Request.Context())) +} + +// finalizeCodexOutboundHeaders is shared by probes and business transports. +// Call it after model/tier policy and account header overrides. It only closes +// the identity/workspace/routing contract; it never supplies a reasoning effort +// or replaces a caller's prompt, tools, model, or response protocol. +func (s *OpenAIGatewayService) finalizeCodexOutboundHeaders(ctx context.Context, c *gin.Context, account *Account, headers http.Header, model, serviceTier string) error { + setOpenAICodexRoutingHint(headers, account, model, serviceTier) + if account == nil || !account.UsesOpenAICodexProtocol() { + return nil + } + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { + return err + } + return enforceCodexIdentityHeadersForAccountContext(ctx, headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) +} + +func codexRoutingProbeEffort(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + // Preserve the existing unspecified probe default. Diagnostics can + // explicitly request the business effort without changing other probes. + return "", nil + } + switch value { + case "none", "minimal", "low", "medium", "high", "xhigh", "max": + return value, nil + default: + return "", errCodexRoutingUnavailable + } +} diff --git a/backend/internal/service/codex_quality_composition_test.go b/backend/internal/service/codex_quality_composition_test.go new file mode 100644 index 000000000000..3db31c544cd2 --- /dev/null +++ b/backend/internal/service/codex_quality_composition_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type codexQualityPacketReader struct { + *strings.Reader + reads int +} + +func (r *codexQualityPacketReader) Read(p []byte) (int, error) { + r.reads++ + return r.Reader.Read(p) +} +func (*codexQualityPacketReader) Close() error { return nil } + +func TestCodexQualityComposedRawObservationPrecedesGuard(t *testing.T) { + for _, headerMismatch := range []bool{false, true} { + name := "created_and_event_header" + if headerMismatch { + name = "http_header_before_body" + } + t.Run(name, func(t *testing.T) { + store, run := qualityStateFixture(t, 3) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + rt := &codexQualityRuntime{s: svc, store: store, installation: &PluginInstallation{PluginKey: codexRuntimePluginKey}} + scope := run.Scope + scope.ConnectionLeaseID = "fixture-private-connection" + deadline := time.Now().Add(time.Minute) + q := &extensionv1.CodexRoutingQualification{Scope: scope, Model: codexQualityModel, + Bundle: extensionv1.CodexRoutingBundleRef{Key: "bundle.quality.fixture", Revision: 1, ConnectionLeaseID: scope.ConnectionLeaseID, ExpiresAt: deadline}, + VerifiedAt: time.Now().Add(-time.Second), ExpiresAt: deadline, + } + _, err := mutateCodexQualityRun(context.Background(), store, run.RunID, func(current *codexQualityRun) error { + current.Qualification = q + return nil + }) + require.NoError(t, err) + group := run.GroupID + key := &APIKey{ID: run.APIKeyID, UserID: run.ActorID, GroupID: &group, Status: StatusAPIKeyActive} + e := &codexQualityExecution{runtime: rt, runID: run.RunID, grantDigest: run.GrantDigest, trialID: uuid.NewString(), stage: "business", + accountID: run.AccountID, requestModel: codexQualityModel, effort: codexQualityEffort, qualification: q, + keyLookup: func(context.Context, int64) (*APIKey, error) { return key, nil }, + } + ctx := context.WithValue(context.Background(), codexQualityExecutionKey{}, e) + // The already-admitted synthetic send reserves once, exactly as the + // production private transport does before installing both readers. + require.NoError(t, reserveCodexQualityAttempt(rt.ctx(ctx), store, run.RunID, run.GrantDigest, e.attempt())) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) + require.NoError(t, err) + raw := "data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-6-luna\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\",\"headers\":{\"X-OpenAI-Model\":\"gpt-6-luna\"},\"output\":[{\"text\":\"private response text\"}]}}\n\n" + source := &codexQualityPacketReader{Reader: strings.NewReader(raw)} + headerModel := codexQualityModel + if headerMismatch { + headerModel = "gpt-6-luna" + } + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, "openai-model": []string{headerModel}, + }, Body: source} + svc.observeCodexQualityBusinessResponse(request, response, nil, q) + forwarded, err := io.ReadAll(response.Body) + require.ErrorIs(t, err, ErrCodexRoutingModelMismatch) + require.Empty(t, forwarded, "no mismatched frame may reach the downstream consumer") + require.NoError(t, response.Body.Close()) + saved, _, err := readCodexQualityRun(context.Background(), store, run.RunID) + require.NoError(t, err) + require.Equal(t, 1, saved.UsedSends) + require.Len(t, saved.Attempts, 1) + require.Nil(t, saved.Qualification, "a raw matching terminal must not preserve a route rejected for an earlier/header model") + attempt := saved.Attempts[0] + require.Contains(t, attempt.HeaderModels, headerModel) + if headerMismatch { + require.Zero(t, source.reads) + require.Nil(t, attempt.CreatedModel, "an unread body is unknown") + require.Nil(t, attempt.TerminalModel) + } else { + require.Equal(t, 1, source.reads) + require.Equal(t, "gpt-6-luna", *attempt.CreatedModel) + require.Equal(t, codexQualityModel, *attempt.TerminalModel) + require.Contains(t, attempt.HeaderModels, "gpt-6-luna") + require.True(t, attempt.Completed, "raw upstream completion is distinct from the guard outcome") + } + encoded, err := json.Marshal(saved) + require.NoError(t, err) + require.NotContains(t, string(encoded), "private response text") + require.ErrorIs(t, reserveCodexQualityAttempt(rt.ctx(ctx), store, run.RunID, run.GrantDigest, e.attempt()), ErrCodexQualitySpent) + }) + } +} + +func TestCodexQualityComposedHighProbeAndBusinessLimits(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}} + svc.cfg.Gateway.MaxLineSize = 256 << 10 + svc.cfg.Gateway.UpstreamResponseReadMaxBytes = 256 << 10 + run := codexQualityRun{RunID: uuid.NewString(), AccountID: 71} + query := codexQualityRenewQuery(run, uuid.NewString()) + ctx := withCodexTransportFixture(context.Background(), false) + request, err := svc.buildCodexRoutingProbe(ctx, codexQualityAccount(), query.Model, "fixture-token", "", query.ReasoningEffort) + require.NoError(t, err) + wire, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.Equal(t, codexQualityEffort, gjson.GetBytes(wire, "reasoning.effort").String()) + require.Equal(t, "ping", gjson.GetBytes(wire, "input.0.content.0.text").String(), "renewal remains a protocol probe") + require.NoError(t, request.Body.Close()) + + terminal := `{"type":"response.completed","response":{"status":"completed","model":"gpt-6-astra","output":[{"text":"` + strings.Repeat("x", 180<<10) + `"}]}}` + for _, test := range []struct{ stage, contentType string }{ + {"business", "text/event-stream"}, {"business", "application/json"}, + {"acquire", "text/event-stream"}, {"verify", "text/event-stream"}, + } { + t.Run(test.stage+"/"+test.contentType, func(t *testing.T) { + raw := terminal + if test.contentType == "text/event-stream" { + raw = "data: " + terminal + "\r\n\r\n" + } + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{test.contentType}}, Body: io.NopCloser(strings.NewReader(raw))} + body := svc.newCodexQualityObservedBody(response, test.stage, CodexQualityAttempt{Stage: test.stage, HTTPStatus: http.StatusOK}) + var observed CodexQualityAttempt + body.finish = func(value CodexQualityAttempt) { observed = value } + var reader io.ReadCloser = body + if test.stage == "business" { + response.Body = body + reader = svc.newCodexRoutingObservedBody(request, response, codexQualityModel) + } + forwarded, err := io.ReadAll(reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + require.Equal(t, raw, string(forwarded)) + require.Equal(t, test.stage == "business", observed.Completed) + }) + } +} diff --git a/backend/internal/service/codex_quality_contract_test.go b/backend/internal/service/codex_quality_contract_test.go new file mode 100644 index 000000000000..8d4fef6160ad --- /dev/null +++ b/backend/internal/service/codex_quality_contract_test.go @@ -0,0 +1,144 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func codexQualityAccount() *Account { + return &Account{ID: 71, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, Concurrency: 1, RateMultiplier: f64p(1), + Credentials: map[string]any{"access_token": "fixture-token", "chatgpt_account_id": "fixture-workspace", "chatgpt_user_id": "fixture-owner"}} +} + +func TestCodexQualityProbeMatchesBusinessHeadersAndPreservesEffort(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}} + account := codexQualityAccount() + for _, effort := range []string{"", "high"} { + t.Run("effort_"+effort, func(t *testing.T) { + ctx := withCodexTransportFixture(context.Background(), false) + probe, err := svc.buildCodexRoutingProbe(ctx, account, "gpt-6-astra", "fixture-token", "", effort) + require.NoError(t, err) + probeBody, err := io.ReadAll(probe.Body) + require.NoError(t, err) + require.Equal(t, effort, gjson.GetBytes(probeBody, "reasoning.effort").String()) + require.Equal(t, effort != "", gjson.GetBytes(probeBody, "reasoning.effort").Exists()) + require.Equal(t, "ping", gjson.GetBytes(probeBody, "input.0.content.0.text").String()) + require.Empty(t, probe.Header.Get(responsesLiteHeader), "a lightweight probe does not impersonate the Lite contract") + + body := []byte(`{"model":"gpt-6-astra","stream":true,"input":"business input","reasoning":{"effort":"high"}}`) + if effort == "" { + body = []byte(`{"model":"gpt-6-astra","stream":true,"input":"business input"}`) + } + c, _ := newTurnStateTestContext(t, 11, "fixture-session") + business, err := svc.buildUpstreamRequest(ctx, c, account, body, "fixture-token", true, "fixture-session", true) + require.NoError(t, err) + for _, name := range []string{"User-Agent", "originator", "version", "chatgpt-account-id", "chatgpt-user-id", openAICodexRoutingHintHeader} { + require.Equal(t, probe.Header.Get(name), business.Header.Get(name), name) + } + require.Equal(t, "model=gpt-6-astra", business.Header.Get(openAICodexRoutingHintHeader)) + businessBody, err := io.ReadAll(business.Body) + require.NoError(t, err) + require.Equal(t, effort, gjson.GetBytes(businessBody, "reasoning.effort").String()) + require.Equal(t, effort != "", gjson.GetBytes(businessBody, "reasoning.effort").Exists()) + }) + } + _, err := svc.buildCodexRoutingProbe(context.Background(), account, "gpt-6-astra", "fixture-token", "", "unrecognized") + require.Error(t, err) +} + +func TestCodexQualityLitePreservesInputAndOmittedInstructions(t *testing.T) { + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","stream":true,"reasoning":{"effort":"high","context":"all_turns"},"parallel_tool_calls":false,"tool_choice":"auto","input":[{"type":"additional_tools","role":"developer","tools":[]},{"type":"message","role":"developer","content":[{"type":"input_text","text":"preserve this unique base prompt"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.156.0") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"status\":\"completed\",\"model\":\"gpt-6-astra\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")), + }} + account := codexQualityAccount() + account.Extra = map[string]any{"openai_passthrough": passthrough} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + _, err := svc.Forward(withCodexTransportFixture(context.Background(), false), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists(), "Lite already carries its developer base prompt in input") + require.False(t, gjson.GetBytes(upstream.lastBody, "tools").Exists()) + require.JSONEq(t, gjson.GetBytes(body, "input").Raw, gjson.GetBytes(upstream.lastBody, "input").Raw) + require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + }) + } +} + +type codexQualityLeaseDirectory struct { + *routingHostDirectoryFixture + live bool + checks int +} + +func (d *codexQualityLeaseDirectory) CheckCodexRoutingLease(_ context.Context, scope extensionv1.CodexRoutingScope, deadline time.Time) error { + d.checks++ + if !d.live || scope.ConnectionLeaseID != "actual-connection" || !time.Now().Before(deadline) { + return ErrCodexConnectionLeaseExpired + } + return nil +} + +func TestCodexQualityHostCheckRejectsLostLeaseWithoutIO(t *testing.T) { + host, base, _ := routingHostFixture() + directory := &codexQualityLeaseDirectory{routingHostDirectoryFixture: base, live: true} + host.directory = directory + call := func(operation extensionv1.HostOperation, query extensionv1.CodexRoutingQuery) extensionv1.CodexRoutingProbeResult { + raw, err := json.Marshal(query) + require.NoError(t, err) + result, err := host.Call(context.Background(), extensionv1.HostInvocation{Operation: operation, Payload: raw}) + require.NoError(t, err) + var value extensionv1.CodexRoutingProbeResult + require.NoError(t, json.Unmarshal(result.Payload, &value)) + return value + } + query := extensionv1.CodexRoutingQuery{AccountID: 7, Model: "gpt-6-astra", Transport: "http", OperationID: "local-lease", Stage: "acquire"} + candidate := call(extensionv1.HostCodexRoutingProbe, query) + require.True(t, candidate.Valid) + query.Stage, query.Bundle = "verify", candidate.Bundle + verified := call(extensionv1.HostCodexRoutingProbe, query) + require.True(t, verified.Valid) + query.Bundle, query.Scope = verified.Bundle, &verified.Scope + require.True(t, call(extensionv1.HostCodexRoutingCheck, query).Valid) + directory.live = false // restart/lost physical connection with a still-live bundle + checked := call(extensionv1.HostCodexRoutingCheck, query) + require.False(t, checked.Valid) + require.Equal(t, "routing_connection_expired", checked.Observation.Code) + require.Equal(t, 2, directory.requests, "a local qualification check must never probe or redial") + directory.live = true + changed := *verified.Bundle + changed.ExpiresAt = changed.ExpiresAt.Add(time.Second) + query.Bundle = &changed + require.False(t, call(extensionv1.HostCodexRoutingCheck, query).Valid, "reference expiry must match the persisted bundle") + query.Bundle = verified.Bundle + changed = *verified.Bundle + changed.ConnectionLeaseID = "another-connection" + query.Bundle = &changed + require.False(t, call(extensionv1.HostCodexRoutingCheck, query).Valid) + require.Equal(t, 2, directory.requests) +} diff --git a/backend/internal/service/codex_quality_observation.go b/backend/internal/service/codex_quality_observation.go index 2208dbf43c6d..43e257304b2f 100644 --- a/backend/internal/service/codex_quality_observation.go +++ b/backend/internal/service/codex_quality_observation.go @@ -108,7 +108,7 @@ func reserveCodexQualitySend(req *http.Request, account *Account, q *extensionv1 return err } model, effort, err := codexQualityWireFields(req) - if err != nil || model != codexQualityModel || (e.stage == "business" && effort != codexQualityEffort) { + if err != nil || model != codexQualityModel || effort != codexQualityEffort { return ErrCodexQualityUnavailable } if e.stage == "business" { @@ -134,6 +134,51 @@ func (e *codexQualityExecution) attempt() CodexQualityAttempt { return a } +// Read the original response into the diagnostic observer before a protocol +// guard can reject a frame. The guard's outcome never replaces raw evidence. +func (s *OpenAIGatewayService) observeCodexQualityBusinessResponse(request *http.Request, response *http.Response, err error, q *extensionv1.CodexRoutingQualification) { + observeCodexQualityResponse(request.Context(), response, err, q) + if err != nil || response == nil || response.Body == nil { + return + } + guard := s.newCodexRoutingObservedBody(request, response, codexQualityModel) + guard.finish = func(completion codexRoutingCompletion) { + if completion.observationCode(codexQualityModel, response.StatusCode) == "routing_verified" { + return + } + e := codexQualityExecutionFromContext(request.Context()) + if e == nil || q == nil { + return + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(request.Context()), 3*time.Second) + defer cancel() + _, _ = mutateCodexQualityRun(e.runtime.ctx(ctx), e.runtime.store, e.runID, func(run *codexQualityRun) error { + // Raw observation can have refreshed the same connection's cookie + // revision before Close finishes. A failed guard revokes that lease, + // but never a separately renewed connection or ordinary eligibility. + if current := run.Qualification; current != nil && current.Bundle.Key == q.Bundle.Key && current.Scope.ConnectionLeaseID == q.Scope.ConnectionLeaseID && current.Scope.SameOwner(q.Scope) { + run.Qualification = nil + } + return nil + }) + } + response.Body = guard +} + +func codexQualityModelsMatch(attempt CodexQualityAttempt) bool { + if !attempt.Completed || attempt.TerminalModel == nil || *attempt.TerminalModel != codexQualityModel { + return false + } + for _, models := range [][]string{attempt.ResponseModels, attempt.HeaderModels} { + for _, model := range models { + if model != codexQualityModel { + return false + } + } + } + return true +} + func observeCodexQualityResponse(ctx context.Context, response *http.Response, err error, q *extensionv1.CodexRoutingQualification) { e := codexQualityExecutionFromContext(ctx) if e == nil { @@ -154,19 +199,21 @@ func observeCodexQualityResponse(ctx context.Context, response *http.Response, e if e.stage == "business" && q != nil { deleted = e.runtime.s.recordCodexRoutingDeletions(ctx, e.runtime.installation, q, response.Header, observedAt) } - for _, name := range []string{"openai-model", "x-openai-model"} { - for _, value := range response.Header.Values(name) { - recordCodexQualityHeaderModel(&a, value) + for name, values := range response.Header { + if strings.EqualFold(name, "openai-model") || strings.EqualFold(name, "x-openai-model") { + for _, value := range values { + recordCodexQualityHeaderModel(&a, value) + } } } - observer := &codexQualityObservedBody{ReadCloser: response.Body, sse: strings.Contains(strings.ToLower(response.Header.Get("Content-Type")), "text/event-stream"), sniff: response.Header.Get("Content-Type") == "", attempt: a} + observer := e.runtime.s.newCodexQualityObservedBody(response, e.stage, a) observer.finish = func(a CodexQualityAttempt) { finishCodexQualityAttempt(e.runtime.ctx(ctx), e.runtime.store, e.runID, a) if e.stage == "business" { if _, err := e.current(ctx); err != nil { return } - matched := a.Completed && a.TerminalModel != nil && *a.TerminalModel == codexQualityModel && !deleted + matched := codexQualityModelsMatch(a) && !deleted var replacement *extensionv1.CodexRoutingQualification if matched { replacement = e.runtime.s.refreshObservedCodexCookies(ctx, e.runtime.installation, q, response.Header, extensionv1.CodexRoutingObservation{Stage: "business", Code: "routing_verified", RequestedModel: codexQualityModel, ResponseModel: codexQualityModel, Completed: true, ModelMatched: true, ObservedAt: observedAt}) @@ -223,20 +270,39 @@ func recordCodexQualityHeaderModel(attempt *CodexQualityAttempt, value string) { // A bounded single-frame observer; it never buffers the whole response and // always forwards the original bytes without editing model fields or content. +func (s *OpenAIGatewayService) newCodexQualityObservedBody(response *http.Response, stage string, attempt CodexQualityAttempt) *codexQualityObservedBody { + limit, jsonLimit, totalLimit := codexRoutingProbeReadLimit, int64(codexRoutingProbeReadLimit), int64(codexRoutingProbeReadLimit) + if stage == "business" { + limit, jsonLimit, totalLimit = s.codexRoutingBusinessEventLimit(), resolveUpstreamResponseReadLimit(s.cfg), 0 + } + return &codexQualityObservedBody{ + ReadCloser: response.Body, + sse: strings.Contains(strings.ToLower(response.Header.Get("Content-Type")), "text/event-stream"), + sniff: strings.TrimSpace(response.Header.Get("Content-Type")) == "", + attempt: attempt, maxEventBytes: limit, maxJSONBytes: jsonLimit, maxReadBytes: totalLimit, + } +} + type codexQualityObservedBody struct { io.ReadCloser - sse, sniff, failed, oversized bool - pending []byte - attempt CodexQualityAttempt - once sync.Once - finish func(CodexQualityAttempt) + sse, sniff, failed, oversized bool + pending []byte + scanFrom, maxEventBytes int + maxJSONBytes, maxReadBytes, readBytes int64 + attempt CodexQualityAttempt + once sync.Once + mu sync.Mutex + done bool + finish func(CodexQualityAttempt) } func (b *codexQualityObservedBody) Read(p []byte) (int, error) { n, err := b.ReadCloser.Read(p) - if n > 0 { + b.mu.Lock() + if n > 0 && !b.done { b.feed(p[:n]) } + b.mu.Unlock() if err != nil { b.complete(err) } @@ -247,28 +313,53 @@ func (b *codexQualityObservedBody) feed(raw []byte) { if b.oversized { return } + if b.maxReadBytes > 0 { + b.readBytes += int64(len(raw)) + if b.readBytes > b.maxReadBytes { + b.oversized, b.failed, b.pending = true, true, nil + return + } + } b.pending = append(b.pending, raw...) - if b.sniff && (bytes.HasPrefix(bytes.TrimSpace(b.pending), []byte("data:")) || bytes.HasPrefix(bytes.TrimSpace(b.pending), []byte("event:"))) { + if b.sniff && bodyHasSSEFraming(b.pending) { b.sse, b.sniff = true, false } + limit := int64(b.maxEventBytes) + if !b.sse { + limit = b.maxJSONBytes + } + if limit <= 0 { + limit = codexRoutingProbeReadLimit + } if b.sse { - b.pending = bytes.ReplaceAll(b.pending, []byte("\r\n"), []byte("\n")) - for { - end := bytes.Index(b.pending, []byte("\n\n")) - if end < 0 { - break + for i := b.scanFrom; i < len(b.pending); i++ { + if b.pending[i] != '\n' { + continue + } + next := i + 1 + if next < len(b.pending) && b.pending[next] == '\r' { + next++ + } + if next >= len(b.pending) || b.pending[next] != '\n' { + continue } - var data []string - for _, line := range strings.Split(string(b.pending[:end]), "\n") { - if strings.HasPrefix(line, "data:") { - data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + if int64(i) > limit { + b.oversized, b.failed, b.pending = true, true, nil + return + } + var data [][]byte + for _, line := range bytes.Split(b.pending[:i], []byte{'\n'}) { + if bytes.HasPrefix(line, []byte("data:")) { + data = append(data, bytes.TrimSpace(line[len("data:"):])) } } - b.event([]byte(strings.Join(data, "\n"))) - b.pending = b.pending[end+2:] + b.event(bytes.Join(data, []byte{'\n'})) + b.pending = b.pending[next+1:] + i = -1 } + b.scanFrom = max(0, len(b.pending)-2) } - if len(b.pending) > codexRoutingProbeReadLimit { + if int64(len(b.pending)) > limit { b.oversized, b.failed, b.pending = true, true, nil } } @@ -279,17 +370,22 @@ func (b *codexQualityObservedBody) event(raw []byte) { } kind := gjson.GetBytes(raw, "type").String() root := gjson.ParseBytes(raw) - for _, path := range []string{"response.headers.openai-model", "response.headers.x-openai-model", "response.metadata.headers.openai-model", "response.metadata.headers.x-openai-model", "headers.openai-model", "headers.x-openai-model", "metadata.headers.openai-model", "metadata.headers.x-openai-model"} { - value := root.Get(path) - if value.Type == gjson.String { - recordCodexQualityHeaderModel(&b.attempt, value.String()) - } else if value.IsArray() { - for _, item := range value.Array() { - if item.Type == gjson.String { - recordCodexQualityHeaderModel(&b.attempt, item.String()) + for _, path := range []string{"response.headers", "response.metadata.headers", "headers", "metadata.headers"} { + root.Get(path).ForEach(func(name, value gjson.Result) bool { + if !strings.EqualFold(name.String(), "openai-model") && !strings.EqualFold(name.String(), "x-openai-model") { + return true + } + if value.Type == gjson.String { + recordCodexQualityHeaderModel(&b.attempt, value.String()) + } else if value.IsArray() { + for _, item := range value.Array() { + if item.Type == gjson.String { + recordCodexQualityHeaderModel(&b.attempt, item.String()) + } } } - } + return true + }) } r := root.Get("response") if !r.Exists() { @@ -326,6 +422,7 @@ func (b *codexQualityObservedBody) event(raw []byte) { func (b *codexQualityObservedBody) complete(err error) { b.once.Do(func() { + b.mu.Lock() if !b.sse && !b.oversized { b.event(b.pending) } @@ -340,8 +437,17 @@ func (b *codexQualityObservedBody) complete(err error) { } else { b.attempt.ErrorCode = "incomplete" } - b.finish(b.attempt) + b.done, b.pending = true, nil + attempt := b.attempt + b.mu.Unlock() + if b.finish != nil { + b.finish(attempt) + } }) } -func (b *codexQualityObservedBody) Close() error { b.complete(nil); return b.ReadCloser.Close() } +func (b *codexQualityObservedBody) Close() error { + err := b.ReadCloser.Close() + b.complete(nil) + return err +} diff --git a/backend/internal/service/codex_quality_observation_test.go b/backend/internal/service/codex_quality_observation_test.go new file mode 100644 index 000000000000..06ed44dcac38 --- /dev/null +++ b/backend/internal/service/codex_quality_observation_test.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCodexQualityObservationClassifiesStreamFailures(t *testing.T) { + for _, test := range []struct{ name, payload, code string }{ + {"capacity", `{"type":"response.failed","response":{"error":{"code":"server_is_overloaded","message":"private upstream text"}}}`, "routing_capacity"}, + {"policy", `{"type":"response.failed","response":{"error":{"code":"cyber_policy","message":"private upstream text"}}}`, "routing_policy"}, + {"incomplete", `{"type":"response.incomplete","response":{"model":"gpt-6-astra","status":"incomplete"}}`, "routing_incomplete"}, + {"cancelled", `{"type":"response.cancelled","response":{"status":"cancelled"}}`, "routing_cancelled"}, + {"mismatch", `{"type":"response.completed","response":{"model":"gpt-6-luna","status":"completed"}}`, "routing_model_mismatch"}, + {"no_prose_inference", `{"type":"error","error":{"code":"other","message":"server_is_overloaded private upstream text"}}`, "routing_upstream"}, + } { + t.Run(test.name, func(t *testing.T) { + var observation extensionv1.CodexRoutingObservation + err := readCodexRoutingCompletion(strings.NewReader("data: "+test.payload+"\n\n"), "text/event-stream", "gpt-6-astra", &observation) + require.Error(t, err) + require.Equal(t, test.code, observation.Code) + encoded, err := json.Marshal(observation) + require.NoError(t, err) + require.NotContains(t, string(encoded), "private upstream text") + }) + } + var cancelled extensionv1.CodexRoutingObservation + require.Error(t, readCodexRoutingCompletion(passthroughErrReadCloser{err: context.Canceled}, "text/event-stream", "gpt-6-astra", &cancelled)) + require.Equal(t, "routing_cancelled", cancelled.Code) +} + +func TestCodexQualityBusinessObservationUsesExistingLimits(t *testing.T) { + frame := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\",\"output\":[{\"text\":\"" + strings.Repeat("x", 180<<10) + "\"}]}}\r\n\r\n" + for _, limit := range []int{256 << 10, 64 << 10} { + cfg := &config.Config{} + cfg.Gateway.MaxLineSize = limit + svc := &OpenAIGatewayService{cfg: cfg} + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(frame))} + body := svc.newCodexRoutingObservedBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") + var completed codexRoutingCompletion + body.finish = func(value codexRoutingCompletion) { completed = value } + _, err := io.Copy(io.Discard, body) + require.NoError(t, err) + require.NoError(t, body.Close()) + require.Equal(t, limit > len(frame), completed.Completed && !completed.Failed) + } + var probe extensionv1.CodexRoutingObservation + require.Error(t, readCodexRoutingCompletion(strings.NewReader(frame), "text/event-stream", "gpt-6-astra", &probe), "lightweight probes retain their independent total budget") + require.Equal(t, "routing_incomplete", probe.Code) +} + +func TestCodexQualityCancellationObservesClientWithoutCancellingDrain(t *testing.T) { + client, cancel := context.WithCancel(t.Context()) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(client) + wire := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(context.WithoutCancel(client)) + wire = withCodexRoutingDownstreamContext(wire, c) + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\"}}\n\n"))} + body := (&OpenAIGatewayService{cfg: &config.Config{}}).newCodexRoutingObservedBody(wire, response, "gpt-6-astra") + var completed codexRoutingCompletion + body.finish = func(value codexRoutingCompletion) { completed = value } + cancel() + require.NoError(t, wire.Context().Err(), "cancellation evidence must not stop the existing usage drain") + _, err := io.Copy(io.Discard, body) + require.NoError(t, err) + require.NoError(t, body.Close()) + require.Equal(t, "routing_cancelled", completed.observationCode("gpt-6-astra", http.StatusOK)) +} + +func TestCodexQualityMismatchStopsBeforeFirstOutput(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}} + for _, contentType := range []string{"text/event-stream", "application/json"} { + payload := `{"type":"response.created","response":{"model":"gpt-6-luna","status":"in_progress"}}` + if contentType == "text/event-stream" { + payload = "data: " + payload + "\n\n" + } else { + payload = `{"object":"response","model":"gpt-6-luna","status":"completed"}` + } + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(payload))} + body := svc.newCodexRoutingObservedBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") + var completed codexRoutingCompletion + body.finish = func(value codexRoutingCompletion) { completed = value } + data, err := io.ReadAll(body) + require.ErrorIs(t, err, ErrCodexRoutingModelMismatch) + if contentType == "text/event-stream" { + require.Empty(t, data) + } + require.Equal(t, "routing_model_mismatch", completed.observationCode("gpt-6-astra", http.StatusOK)) + require.NoError(t, body.Close()) + } + // The official header is independently authoritative, even when a later + // body would have echoed the requested name. No response bytes escape. + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Openai-Model": []string{"gpt-6-luna"}}, Body: io.NopCloser(strings.NewReader("unread"))} + body := svc.newCodexRoutingObservedBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") + data, err := io.ReadAll(body) + require.ErrorIs(t, err, ErrCodexRoutingModelMismatch) + require.Empty(t, data) + require.NoError(t, body.Close()) +} + +type codexQualitySignalWriter struct { + gin.ResponseWriter + written chan struct{} + once sync.Once +} + +func (w *codexQualitySignalWriter) Write(data []byte) (int, error) { + n, err := w.ResponseWriter.Write(data) + if err == nil && n > 0 { + w.once.Do(func() { close(w.written) }) + } + return n, err +} + +type codexQualityLaterMismatchReader struct { + first *strings.Reader + later *strings.Reader + gate <-chan struct{} + ctx context.Context +} + +func (r *codexQualityLaterMismatchReader) Read(data []byte) (int, error) { + if r.first.Len() > 0 { + return r.first.Read(data) + } + select { + case <-r.gate: + return r.later.Read(data) + case <-r.ctx.Done(): + return 0, r.ctx.Err() + } +} +func (r *codexQualityLaterMismatchReader) Close() error { return nil } + +func TestCodexQualityMismatchAfterOutputDoesNotReplay(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx) + gate := make(chan struct{}) + c.Writer = &codexQualitySignalWriter{ResponseWriter: c.Writer, written: gate} + reader := &codexQualityLaterMismatchReader{ + first: strings.NewReader("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_quality\",\"model\":\"gpt-6-astra\",\"status\":\"in_progress\"}}\n\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"first-visible\"}\n\n"), + later: strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-6-luna\",\"status\":\"completed\"}}\n\n"), + gate: gate, ctx: ctx, + } + svc := &OpenAIGatewayService{cfg: &config.Config{}, toolCorrector: NewCodexToolCorrector()} + response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: reader} + response.Body = svc.newCodexRoutingObservedBody(c.Request, response, "gpt-6-astra") + _, err := svc.handleStreamingResponse(ctx, response, c, codexQualityAccount(), time.Now(), "gpt-6-astra", "gpt-6-astra") + require.ErrorIs(t, err, ErrCodexRoutingModelMismatch) + var failover *UpstreamFailoverError + require.False(t, errors.As(err, &failover)) + require.Contains(t, recorder.Body.String(), "first-visible") + require.NotContains(t, recorder.Body.String(), "gpt-6-luna") +} diff --git a/backend/internal/service/codex_quality_route.go b/backend/internal/service/codex_quality_route.go index 7e715642c516..24f9ff43379c 100644 --- a/backend/internal/service/codex_quality_route.go +++ b/backend/internal/service/codex_quality_route.go @@ -63,7 +63,7 @@ func (s *OpenAIGatewayService) RenewCodexQualityRoute(ctx context.Context, actor defer stop() _, _ = rt.store.ReleaseExtensionLease(release, codexRuntimePluginKey, lease) }() - query := extensionv1.CodexRoutingQuery{AccountID: accountID, Model: codexQualityModel, Transport: "http", Stage: "acquire", OperationID: "quality-" + id + "-" + operation, Scope: &run.Scope} + query := codexQualityRenewQuery(run, operation) call := func() (extensionv1.CodexRoutingProbeResult, error) { e := &codexQualityExecution{runtime: rt, runID: id, operationID: operation, stage: query.Stage, accountID: accountID, keyLookup: lookup} stageCtx := context.WithValue(ctx, codexQualityExecutionKey{}, e) @@ -103,3 +103,10 @@ func (s *OpenAIGatewayService) RenewCodexQualityRoute(ctx context.Context, actor } return codexQualityView(saved, rt.installation.RuntimeGeneration), nil } + +func codexQualityRenewQuery(run codexQualityRun, operation string) extensionv1.CodexRoutingQuery { + return extensionv1.CodexRoutingQuery{ + AccountID: run.AccountID, Model: codexQualityModel, ReasoningEffort: codexQualityEffort, + Transport: "http", Stage: "acquire", OperationID: "quality-" + run.RunID + "-" + operation, Scope: &run.Scope, + } +} diff --git a/backend/internal/service/codex_quality_runtime.go b/backend/internal/service/codex_quality_runtime.go index 49653447e627..490794d09d0d 100644 --- a/backend/internal/service/codex_quality_runtime.go +++ b/backend/internal/service/codex_quality_runtime.go @@ -364,12 +364,10 @@ func (rt *codexQualityRuntime) qualification(ctx context.Context, run codexQuali return nil, ErrCodexQualityUnavailable } bundle, err := readCodexRoutingBundle(rt.ctx(ctx), rt.store, codexRuntimePluginKey, q.Bundle, q.Scope, true) - if err != nil || bundle.Model != codexQualityModel || bundle.Scope.ConnectionLeaseID != q.Scope.ConnectionLeaseID { + if err != nil || bundle.Model != codexQualityModel || bundle.Scope.ConnectionLeaseID != q.Scope.ConnectionLeaseID || bundle.Scope.Transport != q.Scope.Transport || q.ExpiresAt.After(bundle.ExpiresAt) { return nil, ErrCodexQualityUnavailable } - if leases, ok := rt.s.httpUpstream.(interface { - HasCodexQualityConnection(string, int64, string) bool - }); !ok || !leases.HasCodexQualityConnection(q.Scope.ConnectionLeaseID, run.AccountID, codexRoutingScopeKey(q.Scope)) { + if rt.s.CheckCodexRoutingLease(ctx, q.Scope, q.ExpiresAt) != nil { return nil, ErrCodexQualityUnavailable } return q, nil diff --git a/backend/internal/service/codex_quality_state_test.go b/backend/internal/service/codex_quality_state_test.go new file mode 100644 index 000000000000..7da7214b3703 --- /dev/null +++ b/backend/internal/service/codex_quality_state_test.go @@ -0,0 +1,129 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestCodexQualityOAuthStateOwnsSubjectSessionAndTurn(t *testing.T) { + svc := &OpenAIGatewayService{} + account := codexQualityAccount() + c, _ := newTurnStateTestContext(t, 11, "fixture-session") + stageCodexRoutingTurn(c, []byte(`{"client_metadata":{"turn_id":"one"}}`)) + ctx := context.Background() + svc.bindOpenAICompatSessionTurnState(ctx, c, account, "cache", "first") + svc.bindOpenAICompatSessionTurnState(ctx, c, account, "cache", "later") + require.Equal(t, "first", svc.getOpenAICompatSessionTurnState(ctx, c, account, "cache")) + require.Empty(t, svc.getOpenAICompatSessionTurnState(ctx, c, account, "other-cache")) + stageCodexRoutingTurn(c, []byte(`{"client_metadata":{"turn_id":"two"}}`)) + require.Empty(t, svc.getOpenAICompatSessionTurnState(ctx, c, account, "cache")) + stageCodexRoutingTurn(c, []byte(`{"client_metadata":{"turn_id":"one"}}`)) + oldWSScope := openAIWSTurnStateScope(c, account, "ws-session") + account.Credentials["chatgpt_user_id"] = "new-owner" + require.Empty(t, svc.getOpenAICompatSessionTurnState(ctx, c, account, "cache"), "same numeric account id is not proof of the credential owner") + require.NotEqual(t, oldWSScope, openAIWSTurnStateScope(c, account, "ws-session")) + c.Request.Header.Del(openAIWSTurnMetadataHeader) + stageCodexRoutingTurn(c, nil) + svc.bindOpenAICompatSessionTurnState(ctx, c, account, "cache", "unproven") + require.Empty(t, svc.getOpenAICompatSessionTurnState(ctx, c, account, "cache")) + + apiKey := &Account{ID: 72, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + svc.bindOpenAICompatSessionTurnState(ctx, c, apiKey, "cache", "api-state") + svc.bindOpenAICompatSessionResponseID(ctx, c, apiKey, "cache", "resp_api") + stageCodexRoutingTurn(c, []byte(`{"client_metadata":{"turn_id":"another-turn"}}`)) + require.Equal(t, "api-state", svc.getOpenAICompatSessionTurnState(ctx, c, apiKey, "cache")) + require.Equal(t, "resp_api", svc.getOpenAICompatSessionResponseID(ctx, c, apiKey, "cache")) +} + +func TestCodexQualityCommittedStateRetainsFirstAndRejectsChangedSubject(t *testing.T) { + svc := &OpenAIGatewayService{} + account := codexQualityAccount() + c, _ := newTurnStateTestContext(t, 11, "fixture-session") + first := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"first"}} + later := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"later"}} + svc.noteStagedOpenAICodexTurnStateCommitted(c, account, first) + prepared := svc.codexTurnStateResponseHeaders(c, account, later) + require.Equal(t, "first", prepared.Get(openAICodexTurnStateHeader)) + require.Equal(t, "later", later.Get(openAICodexTurnStateHeader), "retain the untouched upstream evidence") + svc.noteStagedOpenAICodexTurnStateCommitted(c, account, later) + svc.noteStagedOpenAICodexTurnStateCommitted(c, account, nil) + keep := first.Clone() + svc.guardOpenAICodexTurnStateEcho(c, account, keep) + require.Equal(t, "first", keep.Get(openAICodexTurnStateHeader)) + svc.guardOpenAICodexTurnStateEcho(c, account, later) + require.Empty(t, later.Get(openAICodexTurnStateHeader)) + account.Credentials["chatgpt_account_id"] = "another-workspace" + svc.guardOpenAICodexTurnStateEcho(c, account, keep) + require.Empty(t, keep.Get(openAICodexTurnStateHeader)) +} + +func TestCodexQualityWSTurnCannotFallBackToHandshake(t *testing.T) { + svc := &OpenAIGatewayService{} + account := codexQualityAccount() + c, _ := newTurnStateTestContext(t, 11, "fixture-session") + c.Request.Header.Set(openAIWSTurnMetadataHeader, `{"turn_id":"old-handshake"}`) + stageCodexRoutingWSTurn(c, []byte(`{"client_metadata":{"turn_id":"tool-turn"}}`)) + svc.noteStagedOpenAICodexTurnStateCommitted(c, account, http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"tool-state"}}) + stageCodexRoutingWSTurn(c, []byte(`{"client_metadata":{"turn_id":"tool-turn"},"input":[{"type":"function_call_output","call_id":"call-one","output":"ok"}]}`)) + state := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"tool-state"}} + svc.guardOpenAICodexTurnStateEcho(c, account, state) + require.Equal(t, "tool-state", state.Get(openAICodexTurnStateHeader)) + stageCodexRoutingWSTurn(c, []byte(`{"input":[{"type":"message","role":"user","content":"new user turn"}]}`)) + require.Empty(t, codexRoutingTurnID(c)) + require.Empty(t, openAIWSTurnStateScope(c, account, "fixture-session")) + svc.guardOpenAICodexTurnStateEcho(c, account, state) + require.Empty(t, state.Get(openAICodexTurnStateHeader)) +} + +func TestCodexQualityMessagesStateCommitsOnlyCompletedDownstream(t *testing.T) { + for _, test := range []struct { + name, status string + stream, writeFails, wantState bool + }{ + {"buffered_success", "completed", false, false, true}, + {"stream_success", "completed", true, false, true}, + {"buffered_failed", "failed", false, false, false}, + {"stream_failed", "failed", true, false, false}, + {"buffered_incomplete", "incomplete", false, false, false}, + {"stream_incomplete", "incomplete", true, false, false}, + {"buffered_write_failure", "completed", false, true, false}, + {"stream_write_failure", "completed", true, true, false}, + } { + t.Run(test.name, func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 11, "fixture-session") + c.Request.URL.Path = "/v1/messages" + if test.writeFails { + c.Writer = &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0} + } + terminal := `{"type":"response.` + test.status + `","response":{"id":"resp_quality","object":"response","model":"gpt-6-astra","status":"` + test.status + `","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"pong"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}` + if test.status == "failed" { + terminal = `{"type":"response.failed","response":{"id":"resp_quality","status":"failed","model":"gpt-6-astra","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"fixture failure"}}}` + } + payload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_quality\",\"model\":\"gpt-6-astra\",\"status\":\"in_progress\"}}\n\n" + if test.status != "failed" { + payload += "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"pong\"}\n\n" + } + payload += "data: " + terminal + "\n\n" + headers := http.Header{"Content-Type": []string{"text/event-stream"}} + headers.Set(openAICodexTurnStateHeader, "header-is-not-a-commit") + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: headers, Body: io.NopCloser(strings.NewReader(payload))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := codexQualityAccount() + stream := "false" + if test.stream { + stream = "true" + } + body := []byte(`{"model":"gpt-6-astra","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":` + stream + `}`) + _, _ = svc.ForwardAsAnthropic(withCodexTransportFixture(context.Background(), false), c, account, body, "fixed-cache", "") + require.Len(t, upstream.requests, 1) + stored := svc.getOpenAICompatSessionTurnState(context.Background(), c, account, "fixed-cache") + require.Equal(t, test.wantState, stored != "", "only a complete response successfully written downstream may save STATE") + }) + } +} diff --git a/backend/internal/service/codex_quality_wire_test.go b/backend/internal/service/codex_quality_wire_test.go index 2aac82e0e199..31aaf6668fbc 100644 --- a/backend/internal/service/codex_quality_wire_test.go +++ b/backend/internal/service/codex_quality_wire_test.go @@ -28,6 +28,7 @@ type qualityWireUpstream struct { HTTPUpstream accountID int64 scope extensionv1.CodexRoutingScope + expiresAt time.Time calls int request *http.Request body []byte @@ -37,6 +38,17 @@ func (u *qualityWireUpstream) HasCodexQualityConnection(id string, accountID int return id == u.scope.ConnectionLeaseID && accountID == u.accountID && scope == codexRoutingScopeKey(u.scope) } +func (u *qualityWireUpstream) CheckCodexConnectionLease(accountID int64, scope, id string, expiresAt time.Time) error { + now := time.Now() + if id != u.scope.ConnectionLeaseID || !now.Before(u.expiresAt) || !now.Before(expiresAt) { + return ErrCodexConnectionLeaseExpired + } + if accountID <= 0 || accountID != u.accountID || scope == "" || scope != codexRoutingScopeKey(u.scope) || expiresAt.After(u.expiresAt) { + return ErrCodexConnectionLeaseScope + } + return nil +} + func (u *qualityWireUpstream) DoWithCodexConnectionLease(req *http.Request, _ string, accountID int64, scope, id string, _ time.Time, _ *tlsfingerprint.Profile) (*http.Response, string, error) { if !u.HasCodexQualityConnection(id, accountID, scope) { return nil, "", ErrCodexConnectionLeaseExpired @@ -106,6 +118,7 @@ func qualityWireServiceFixture(t *testing.T, compressed, recovery bool) (*OpenAI leaseScope := scope leaseScope.ConnectionLeaseID, leaseScope.RouteEvidence = "quality-wire-connection", "connection" upstream.scope = leaseScope + upstream.expiresAt = expiry cookie := codexRoutingCookie{Name: "__cflb", Value: "quality-wire-route", Domain: "chatgpt.com", Path: "/", FirstSeen: now, ExpiresAt: expiry} clock := codexRoutingCookieClock{Scope: scope} clock.apply([]codexRoutingCookieChange{{Cookie: cookie}}, now) diff --git a/backend/internal/service/codex_routing_completion.go b/backend/internal/service/codex_routing_completion.go index ef02949ee346..d9fad7d2c5d9 100644 --- a/backend/internal/service/codex_routing_completion.go +++ b/backend/internal/service/codex_routing_completion.go @@ -2,8 +2,11 @@ package service import ( "bytes" + "context" "encoding/json" + "errors" "io" + "net/http" "strings" extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" @@ -11,90 +14,232 @@ import ( const codexRoutingProbeReadLimit = 128 << 10 -// Only a delimited successful terminal and the original upstream model can -// certify a route. It does not retain prompts, reasoning, or response text. +var ErrCodexRoutingModelMismatch = errors.New("upstream Codex response model does not match the requested model") + +// Only bounded metadata is retained. Probe totals remain limited to 128 KiB; +// business readers explicitly select their existing stream/JSON read limits. type codexRoutingCompletion struct { - Model string - Completed bool - Failed bool - pending []byte + Model string + RequestedModel string + Completed bool + Failed bool + Mismatch bool + FailureCode string + maxEventBytes int + pending []byte + scanFrom int +} + +func (o *codexRoutingCompletion) fail(code string) { + o.Failed = true + if o.FailureCode == "" { + o.FailureCode = code + } +} + +func (o *codexRoutingCompletion) observeModel(model string) { + model = strings.TrimSpace(model) + if model == "" { + return + } + if len(model) > 256 { + o.fail("routing_incomplete") + return + } + if (o.Model != "" && o.Model != model) || (o.RequestedModel != "" && o.RequestedModel != model) { + o.Mismatch = true + } + o.Model = model +} + +func (o *codexRoutingCompletion) headers(headers http.Header) { + for name, values := range headers { + if strings.EqualFold(name, "openai-model") || strings.EqualFold(name, "x-openai-model") { + for _, value := range values { + o.observeModel(value) + } + } + } +} + +// Classification uses explicit upstream codes, never error prose, account plan, +// STATE length, or the official client's hard-coded safety-reroute warning. +func codexRoutingErrorCode(raw json.RawMessage) string { + var value struct { + Code string `json:"code"` + Type string `json:"type"` + } + if json.Unmarshal(raw, &value) == nil { + for _, code := range []string{value.Code, value.Type} { + switch code { + case "server_is_overloaded", "rate_limit_exceeded", "slow_down", "insufficient_quota", "usage_limit_reached": + return "routing_capacity" + case "cyber_policy", "bio_policy", "misalignment_policy_violation", "content_policy_violation": + return "routing_policy" + } + } + } + return "routing_upstream" } -func (o *codexRoutingCompletion) json(raw []byte) { +func (o *codexRoutingCompletion) json(raw []byte) { o.jsonEvent(raw, "") } + +func (o *codexRoutingCompletion) jsonEvent(raw []byte, eventType string) { var event struct { - Type string `json:"type"` - Object string `json:"object"` - Status string `json:"status"` - Model string `json:"model"` - Error json.RawMessage `json:"error"` + Type string `json:"type"` + Object string `json:"object"` + Status string `json:"status"` + Model string `json:"model"` + Headers map[string]json.RawMessage `json:"headers"` + Error json.RawMessage `json:"error"` Response *struct { - Status string `json:"status"` - Model string `json:"model"` - Error json.RawMessage `json:"error"` + Status string `json:"status"` + Model string `json:"model"` + Headers map[string]json.RawMessage `json:"headers"` + Error json.RawMessage `json:"error"` } `json:"response"` } if json.Unmarshal(raw, &event) != nil { return } + if event.Type == "" { + event.Type = eventType + } + observeHeaders := func(headers map[string]json.RawMessage) { + for name, value := range headers { + if strings.EqualFold(name, "openai-model") || strings.EqualFold(name, "x-openai-model") { + var model string + if json.Unmarshal(value, &model) == nil { + o.observeModel(model) + } + } + } + } + observeHeaders(event.Headers) + if event.Response != nil { + observeHeaders(event.Response.Headers) + o.observeModel(event.Response.Model) + } else if event.Object == "response" { + o.observeModel(event.Model) + } badError := func(raw json.RawMessage) bool { return len(raw) > 0 && string(raw) != "null" } - if badError(event.Error) || event.Type == "error" || event.Type == "response.failed" || event.Type == "response.incomplete" { - o.Failed = true + if badError(event.Error) { + o.fail(codexRoutingErrorCode(event.Error)) return } - model := "" - if event.Type == "response.completed" && event.Response != nil { - if event.Response.Status != "completed" || badError(event.Response.Error) { - o.Failed = true + if event.Response != nil && badError(event.Response.Error) { + o.fail(codexRoutingErrorCode(event.Response.Error)) + return + } + switch event.Type { + case "response.cancelled", "response.canceled": + o.fail("routing_cancelled") + return + case "response.incomplete": + o.fail("routing_incomplete") + return + case "error", "response.failed": + o.fail("routing_upstream") + return + } + terminal := event.Type == "response.completed" && event.Response != nil + if terminal { + if event.Response.Status != "completed" { + o.fail("routing_incomplete") return } - model = strings.TrimSpace(event.Response.Model) } else if event.Object == "response" && event.Status == "completed" { - model = strings.TrimSpace(event.Model) + terminal = true } else { return } - if model == "" || len(model) > 256 || (o.Model != "" && o.Model != model) { - o.Failed = true - return + if terminal && o.Model != "" { + o.Completed = true + } else { + o.fail("routing_incomplete") } - o.Model, o.Completed = model, true } func (o *codexRoutingCompletion) feed(data []byte) { if o.Failed { return } + limit := o.maxEventBytes + if limit <= 0 { + limit = codexRoutingProbeReadLimit + } o.pending = append(o.pending, data...) - o.pending = bytes.ReplaceAll(o.pending, []byte("\r\n"), []byte("\n")) - for { - end := bytes.Index(o.pending, []byte("\n\n")) - if end < 0 { + for i := o.scanFrom; i < len(o.pending); i++ { + if o.pending[i] != '\n' { + continue + } + next := i + 1 + if next < len(o.pending) && o.pending[next] == '\r' { + next++ + } + if next >= len(o.pending) || o.pending[next] != '\n' { + continue + } + if i > limit { + o.fail("routing_incomplete") break } - var lines []string - for _, line := range strings.Split(string(o.pending[:end]), "\n") { - if strings.HasPrefix(line, "data:") { - lines = append(lines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + var lines [][]byte + eventType := "" + for _, line := range bytes.Split(o.pending[:i], []byte{'\n'}) { + line = bytes.TrimSuffix(line, []byte{'\r'}) + if bytes.HasPrefix(line, []byte("data:")) { + lines = append(lines, bytes.TrimSpace(line[len("data:"):])) + } else if bytes.HasPrefix(line, []byte("event:")) { + eventType = string(bytes.TrimSpace(line[len("event:"):])) } } - o.json([]byte(strings.Join(lines, "\n"))) - o.pending = o.pending[end+2:] + o.jsonEvent(bytes.Join(lines, []byte{'\n'}), eventType) + o.pending = o.pending[next+1:] + i = -1 + if o.Failed { + break + } } - if len(o.pending) > codexRoutingProbeReadLimit { - o.Failed = true + o.scanFrom = max(0, len(o.pending)-2) + if len(o.pending) > limit { + o.fail("routing_incomplete") + } + if o.Failed { o.pending = nil } } -func readCodexRoutingCompletion(body io.Reader, contentType, requested string, observation *extensionv1.CodexRoutingObservation) error { +func (o *codexRoutingCompletion) observationCode(requested string, status int) string { + if o.FailureCode != "" { + return o.FailureCode + } + if status != http.StatusOK && status != http.StatusSwitchingProtocols { + return "routing_upstream" + } + if o.Mismatch || (o.Model != "" && o.Model != requested) { + return "routing_model_mismatch" + } + if !o.Completed || o.Failed || o.Model == "" { + return "routing_incomplete" + } + return "routing_verified" +} + +func readCodexRoutingCompletion(body io.Reader, contentType, requested string, observation *extensionv1.CodexRoutingObservation, headers ...http.Header) error { data, err := io.ReadAll(io.LimitReader(body, codexRoutingProbeReadLimit+1)) if err != nil || len(data) > codexRoutingProbeReadLimit { observation.Code = "routing_incomplete" + if errors.Is(err, context.Canceled) { + observation.Code = "routing_cancelled" + } return errCodexRoutingUnavailable } - var completed codexRoutingCompletion - // The ChatGPT edge omits Content-Type on streamed replies (2026-09-23); - // an untyped body is still recognised by its SSE framing. + completed := codexRoutingCompletion{RequestedModel: requested} + for _, header := range headers { + completed.headers(header) + } + // The ChatGPT edge may omit Content-Type on otherwise valid SSE. if strings.Contains(strings.ToLower(contentType), "text/event-stream") || (strings.TrimSpace(contentType) == "" && bodyHasSSEFraming(data)) { completed.feed(data) } else { @@ -102,15 +247,10 @@ func readCodexRoutingCompletion(body io.Reader, contentType, requested string, o } observation.Completed = completed.Completed && !completed.Failed observation.ResponseModel = completed.Model - observation.ModelMatched = observation.Completed && completed.Model == requested - if !observation.Completed { - observation.Code = "routing_incomplete" - return errCodexRoutingUnavailable - } - if !observation.ModelMatched { - observation.Code = "routing_model_mismatch" + observation.ModelMatched = observation.Completed && !completed.Mismatch && completed.Model == requested + observation.Code = completed.observationCode(requested, http.StatusOK) + if observation.Code != "routing_verified" { return errCodexRoutingUnavailable } - observation.Code = "routing_verified" return nil } diff --git a/backend/internal/service/codex_routing_fingerprint.go b/backend/internal/service/codex_routing_fingerprint.go index 99906ffb62e3..5dac3ea9b916 100644 --- a/backend/internal/service/codex_routing_fingerprint.go +++ b/backend/internal/service/codex_routing_fingerprint.go @@ -25,6 +25,16 @@ var codexRoutingReference json.RawMessage // A turn identifier is independent of the conversation/session. Opaque state // cannot cross a turn simply because the same downstream session continues. func stageCodexRoutingTurn(c *gin.Context, body []byte) { + stageCodexRoutingTurnWithHeader(c, body, true) +} + +// A persistent WS handshake header describes the initial request only. Each +// frame needs its own explicit turn identity before opaque state may be reused. +func stageCodexRoutingWSTurn(c *gin.Context, body []byte) { + stageCodexRoutingTurnWithHeader(c, body, false) +} + +func stageCodexRoutingTurnWithHeader(c *gin.Context, body []byte, allowHeader bool) { if c == nil { return } @@ -33,7 +43,7 @@ func stageCodexRoutingTurn(c *gin.Context, body []byte) { metadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String() turn = strings.TrimSpace(gjson.Get(metadata, "turn_id").String()) } - if turn == "" && c.Request != nil { + if turn == "" && allowHeader && c.Request != nil { turn = strings.TrimSpace(gjson.Get(c.Request.Header.Get(openAIWSTurnMetadataHeader), "turn_id").String()) } if len(turn) > 128 { diff --git a/backend/internal/service/codex_routing_host.go b/backend/internal/service/codex_routing_host.go index 606c52b6cef7..587450ec4507 100644 --- a/backend/internal/service/codex_routing_host.go +++ b/backend/internal/service/codex_routing_host.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -20,6 +21,10 @@ type codexRoutingHostDirectory interface { ExecuteCodexRoutingProbe(context.Context, extensionv1.CodexRoutingQuery, string, time.Time) (*http.Response, extensionv1.CodexRoutingScope, error) } +type codexRoutingLeaseDirectory interface { + CheckCodexRoutingLease(context.Context, extensionv1.CodexRoutingScope, time.Time) error +} + func isCodexRoutingHostOperation(operation extensionv1.HostOperation) bool { return operation == extensionv1.HostCodexRoutingScope || operation == extensionv1.HostCodexRoutingProbe || operation == extensionv1.HostCodexRoutingCheck || operation == extensionv1.HostCodexRoutingCleanup } @@ -60,7 +65,11 @@ func (h *pluginExtensionHost) callCodexRouting(ctx context.Context, in extension if query.Model == "" || len(query.Model) > 256 { return extensionv1.Result{}, errCodexRoutingUnavailable } - result := extensionv1.CodexRoutingProbeResult{Scope: scope, Observation: extensionv1.CodexRoutingObservation{Stage: query.Stage, Code: "routing_unavailable", RequestedModel: query.Model, Transport: query.Transport, ObservedAt: time.Now().UTC()}} + query.ReasoningEffort, err = codexRoutingProbeEffort(query.ReasoningEffort) + if err != nil { + return extensionv1.Result{}, err + } + result := extensionv1.CodexRoutingProbeResult{Scope: scope, Observation: extensionv1.CodexRoutingObservation{Stage: query.Stage, Code: "routing_unavailable", RequestedModel: query.Model, ReasoningEffort: query.ReasoningEffort, Transport: query.Transport, ObservedAt: time.Now().UTC()}} var bundle codexRoutingPrivateBundle var bundleRef extensionv1.CodexRoutingBundleRef cookieHeader := "" @@ -79,8 +88,17 @@ func (h *pluginExtensionHost) callCodexRouting(ctx context.Context, in extension } } if in.Operation == extensionv1.HostCodexRoutingCheck { - result.Valid = query.Bundle != nil && bundle.Status == "qualified" && bundle.Model == query.Model && bundle.Scope.ConnectionLeaseID != "" result.Scope, result.Bundle = bundle.Scope, query.Bundle + checker, ok := h.directory.(codexRoutingLeaseDirectory) + if !ok || query.Bundle == nil || bundle.Status != "qualified" || bundle.Model != query.Model || + bundle.Scope.Transport != query.Transport || query.Scope == nil || !query.Scope.SameOwner(bundle.Scope) || + query.Scope.Transport != bundle.Scope.Transport || query.Scope.ConnectionLeaseID != bundle.Scope.ConnectionLeaseID || + checker.CheckCodexRoutingLease(ctx, bundle.Scope, bundle.ExpiresAt) != nil { + result.Observation.Code = "routing_connection_expired" + return marshal(result) + } + result.Valid = true + result.Observation.Code = "routing_verified" return marshal(result) } if query.OperationID == "" || len(query.OperationID) > 256 || (query.Stage != "acquire" && query.Stage != "verify") || (query.Stage == "verify" && query.Bundle == nil) { @@ -137,6 +155,9 @@ func (h *pluginExtensionHost) callCodexRouting(ctx context.Context, in extension result.Observation.DurationMS = time.Since(start).Milliseconds() if probeErr != nil || response == nil { result.Observation.Code = "routing_transport" + if errors.Is(probeErr, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) { + result.Observation.Code = "routing_cancelled" + } return marshal(result) } defer func() { _ = response.Body.Close() }() @@ -161,7 +182,7 @@ func (h *pluginExtensionHost) callCodexRouting(ctx context.Context, in extension result.Observation.Code = "routing_upstream" return marshal(result) } - if err := readCodexRoutingCompletion(response.Body, response.Header.Get("Content-Type"), query.Model, &result.Observation); err != nil { + if err := readCodexRoutingCompletion(response.Body, response.Header.Get("Content-Type"), query.Model, &result.Observation, response.Header); err != nil { // A completed cold acquisition can issue a useful routing Cookie even // when that cold turn selected another model. It is only a candidate; // business-exit verification remains the sole model qualification gate. @@ -226,7 +247,7 @@ func readCodexRoutingBundle(ctx context.Context, store PluginExtensionStateStore return bundle, errCodexRoutingUnavailable } record, err := store.ReadExtensionState(ctx, key, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: ref.Key}) - if err != nil || !record.Found || record.Revision != ref.Revision || json.Unmarshal(record.Value, &bundle) != nil || bundle.Schema != extensionv1.CodexRoutingSchema || !scope.SameOwner(bundle.Scope) || !time.Now().Before(bundle.ExpiresAt) || (qualified && bundle.Status != "qualified") { + if err != nil || !record.Found || record.Revision != ref.Revision || json.Unmarshal(record.Value, &bundle) != nil || bundle.Schema != extensionv1.CodexRoutingSchema || !scope.SameOwner(bundle.Scope) || !time.Now().Before(bundle.ExpiresAt) || !ref.ExpiresAt.Equal(bundle.ExpiresAt) || ref.ConnectionLeaseID != bundle.Scope.ConnectionLeaseID || (qualified && bundle.Status != "qualified") { return bundle, errCodexRoutingUnavailable } clockRecord, err := store.ReadExtensionState(ctx, key, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: bundle.ClockKey}) @@ -261,7 +282,7 @@ func (s *OpenAIGatewayService) PrepareCodexRoutingScope(ctx context.Context, id if err != nil || !isOpenAICodexTicketAccount(a) { return extensionv1.CodexRoutingScope{}, errCodexRoutingUnavailable } - identity, err := resolveCodexOutboundIdentityForAccountContext(ctx, a, codexAccountIdentityOverrideUA(a)) + identity, err := resolveCodexOutboundIdentityForAccountContext(ctx, a, s.codexIdentityOverrideUA(a)) if err != nil { return extensionv1.CodexRoutingScope{}, err } @@ -271,6 +292,17 @@ func (s *OpenAIGatewayService) PrepareCodexRoutingScope(ctx context.Context, id return extensionv1.CodexRoutingScope{AccountID: id, Identity: CodexTicketAccountIdentity(a), ProfileHash: profile, RouteHash: codexRoutingDigest(resolveAccountProxyURL(a), config.HarvestProxyURL), RouteEvidence: "connection_required", Transport: transport}, nil } +func (s *OpenAIGatewayService) CheckCodexRoutingLease(ctx context.Context, scope extensionv1.CodexRoutingScope, expiresAt time.Time) error { + if ctx.Err() != nil || scope.Transport != "http" || scope.ConnectionLeaseID == "" { + return ErrCodexConnectionLeaseExpired + } + inspector, ok := s.httpUpstream.(CodexConnectionLeaseInspector) + if !ok { + return ErrCodexConnectionLeaseExpired + } + return inspector.CheckCodexConnectionLease(scope.AccountID, codexRoutingScopeKey(scope), scope.ConnectionLeaseID, expiresAt) +} + func (s *OpenAIGatewayService) ExecuteCodexRoutingProbe(ctx context.Context, query extensionv1.CodexRoutingQuery, cookies string, deadline time.Time) (*http.Response, extensionv1.CodexRoutingScope, error) { scope, err := s.PrepareCodexRoutingScope(ctx, query.AccountID, query.Transport) if err != nil { @@ -292,7 +324,7 @@ func (s *OpenAIGatewayService) ExecuteCodexRoutingProbe(ctx context.Context, que if err != nil { return nil, scope, errCodexRoutingUnavailable } - request, err := s.buildCodexRoutingProbe(ctx, a, query.Model, token, cookies) + request, err := s.buildCodexRoutingProbe(ctx, a, query.Model, token, cookies, query.ReasoningEffort) if err != nil { return nil, scope, err } @@ -320,11 +352,22 @@ func (s *OpenAIGatewayService) ExecuteCodexRoutingProbe(ctx context.Context, que return response, scope, err } -func (s *OpenAIGatewayService) buildCodexRoutingProbe(ctx context.Context, account *Account, model, token, cookies string) (*http.Request, error) { +func (s *OpenAIGatewayService) buildCodexRoutingProbe(ctx context.Context, account *Account, model, token, cookies string, explicitEffort ...string) (*http.Request, error) { + effort := "" + if len(explicitEffort) > 0 { + effort = explicitEffort[0] + } + effort, err := codexRoutingProbeEffort(effort) + if err != nil { + return nil, err + } session, turn := uuid.Must(uuid.NewV7()).String(), uuid.Must(uuid.NewV7()).String() metadata := map[string]any{"session_id": session, "thread_id": session, "turn_id": turn, "x-codex-window-id": session + ":0"} body := map[string]any{"model": model, "store": false, "stream": true, "instructions": "Reply with exactly: pong", "input": []any{map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": "ping"}}}}, "client_metadata": metadata, "prompt_cache_key": session} - identity, err := resolveCodexOutboundIdentityForAccountContext(ctx, account, codexAccountIdentityOverrideUA(account)) + if effort != "" { + body["reasoning"] = map[string]any{"effort": effort} + } + identity, err := resolveCodexOutboundIdentityForAccountContext(ctx, account, s.codexIdentityOverrideUA(account)) if err != nil { return nil, err } @@ -343,12 +386,10 @@ func (s *OpenAIGatewayService) buildCodexRoutingProbe(ctx context.Context, accou ids := resolveCodexFingerprintIDsFromRequest(account, headers) applyCodexFingerprintClientMetadata(body, ids) applyCodexFingerprintHeaders(headers, ids) - if err := enforceCodexIdentityHeadersForAccountContext(ctx, headers, account, codexAccountIdentityOverrideUA(account)); err != nil { - return nil, err - } - if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { + if err := s.finalizeCodexOutboundHeaders(ctx, nil, account, headers, model, ""); err != nil { return nil, err } + applyOpenAICodexBetaFeatures(nil, account, headers) headers.Set("Authorization", "Bearer "+token) headers.Set("Accept", "text/event-stream") headers.Set("Content-Type", "application/json") diff --git a/backend/internal/service/codex_routing_test.go b/backend/internal/service/codex_routing_test.go index 6e7646f75ee5..b22e7da6469f 100644 --- a/backend/internal/service/codex_routing_test.go +++ b/backend/internal/service/codex_routing_test.go @@ -400,7 +400,17 @@ func TestCodexRoutingAnotherModelClockDoesNotBlockOwnCookieRefresh(t *testing.T) raw, _ = json.Marshal(clock) _, err = store.CompareSwapExtensionState(context.Background(), codexRuntimePluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: clockKey, ExpectedRevision: 1, Value: raw}) require.NoError(t, err) - q := &extensionv1.CodexRoutingQualification{Scope: scope, Model: "gpt-6-astra", VerifiedAt: now, ExpiresAt: expiry, Bundle: extensionv1.CodexRoutingBundleRef{Key: "bundle.astra", Revision: 1, ExpiresAt: expiry}} + q := &extensionv1.CodexRoutingQualification{Scope: scope, Model: "gpt-6-astra", VerifiedAt: now, ExpiresAt: expiry, Bundle: extensionv1.CodexRoutingBundleRef{Key: "bundle.astra", Revision: 1, ExpiresAt: expiry, ConnectionLeaseID: scope.ConnectionLeaseID}} + // Match the host-issued reference contract: another model may update the + // shared clock, but a missing or different connection cannot own this bundle. + for _, leaseID := range []string{"", "connection-sol"} { + wrongConnection := q.Bundle + wrongConnection.ConnectionLeaseID = leaseID + _, err = readCodexRoutingBundle(context.Background(), store, codexRuntimePluginKey, wrongConnection, scope, true) + require.Error(t, err) + } + _, err = readCodexRoutingBundle(context.Background(), store, codexRuntimePluginKey, q.Bundle, scope, true) + require.NoError(t, err, "refresh must reach the Cookie clock logic with a valid connection-bound reference") next := service.refreshObservedCodexCookies(context.Background(), installation, q, http.Header{"Set-Cookie": []string{"__oailb=astra-refreshed; Path=/; Secure; Max-Age=100"}}, extensionv1.CodexRoutingObservation{ObservedAt: time.Now().UTC(), Completed: true, ModelMatched: true, ResponseModel: "gpt-6-astra"}) require.NotNil(t, next) require.Equal(t, q.ExpiresAt, next.ExpiresAt) @@ -467,7 +477,9 @@ func TestCodexRoutingUnchangedCookiesDoNotGrowAndExpiryRedactsValues(t *testing. raw, _ = json.Marshal(bundle) _, err = store.CompareSwapExtensionState(context.Background(), codexRuntimePluginKey, extensionv1.StateRequest{Namespace: codexRoutingPrivateNamespace, Key: bundleKey, Value: raw, NextAt: &expires}) require.NoError(t, err) - q := &extensionv1.CodexRoutingQualification{Scope: scope, Model: "gpt-6-astra", VerifiedAt: now, ExpiresAt: expires, Bundle: extensionv1.CodexRoutingBundleRef{Key: bundleKey, Revision: 1, ExpiresAt: expires}} + q := &extensionv1.CodexRoutingQualification{Scope: scope, Model: "gpt-6-astra", VerifiedAt: now, ExpiresAt: expires, Bundle: extensionv1.CodexRoutingBundleRef{Key: bundleKey, Revision: 1, ExpiresAt: expires, ConnectionLeaseID: scope.ConnectionLeaseID}} + _, err = readCodexRoutingBundle(context.Background(), store, codexRuntimePluginKey, q.Bundle, scope, true) + require.NoError(t, err, "nil refresh must mean unchanged Cookies, not an invalid fixture reference") service := &OpenAIGatewayService{pluginManager: &PluginManager{repo: store}} before := len(store.values) updated := service.refreshObservedCodexCookies(context.Background(), &PluginInstallation{PluginKey: codexRuntimePluginKey}, q, http.Header{"Set-Cookie": []string{"__cflb=temporary-private-value; Path=/; Secure; Max-Age=100"}}, extensionv1.CodexRoutingObservation{ObservedAt: now}) diff --git a/backend/internal/service/codex_routing_transport.go b/backend/internal/service/codex_routing_transport.go index a4a84441f543..f0f44a7249d9 100644 --- a/backend/internal/service/codex_routing_transport.go +++ b/backend/internal/service/codex_routing_transport.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "io" "net/http" "slices" @@ -100,7 +101,7 @@ func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, a if response != nil { observeCodexInfrastructureCookies(q.Scope, response.Header, q.ExpiresAt) } - observeCodexQualityResponse(request.Context(), response, err, q) + s.observeCodexQualityBusinessResponse(request, response, err, q) return response, true, err } if err != nil { @@ -128,17 +129,13 @@ func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, a observation.CookieNames = append(observation.CookieNames, cookie.Name) } } - body := &codexRoutingObservedBody{ReadCloser: response.Body, sse: strings.Contains(strings.ToLower(response.Header.Get("Content-Type")), "text/event-stream")} + body := s.newCodexRoutingObservedBody(request, response, model) body.finish = func(completion codexRoutingCompletion) { observation.Completed = completion.Completed && !completion.Failed && response.StatusCode == http.StatusOK observation.ResponseModel = completion.Model - observation.ModelMatched = observation.Completed && completion.Model == model + observation.ModelMatched = observation.Completed && !completion.Mismatch && completion.Model == model observation.DurationMS = time.Since(start).Milliseconds() - if observation.ModelMatched { - observation.Code = "routing_verified" - } else if observation.Completed { - observation.Code = "routing_model_mismatch" - } + observation.Code = completion.observationCode(model, response.StatusCode) if deleted { observation.Code = "routing_cookie_deleted" } @@ -152,42 +149,134 @@ func (s *OpenAIGatewayService) doQualifiedCodexUpstream(request *http.Request, a return response, true, nil } +func (s *OpenAIGatewayService) codexRoutingBusinessEventLimit() int { + maxEventBytes := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxEventBytes = s.cfg.Gateway.MaxLineSize + } + return maxEventBytes +} + +func (s *OpenAIGatewayService) newCodexRoutingObservedBody(request *http.Request, response *http.Response, model string) *codexRoutingObservedBody { + completionContext := request.Context() + if downstream, ok := request.Context().Value(codexRoutingDownstreamContextKey{}).(context.Context); ok { + completionContext = downstream + } + body := &codexRoutingObservedBody{ + ReadCloser: response.Body, + ctx: completionContext, + sse: strings.Contains(strings.ToLower(response.Header.Get("Content-Type")), "text/event-stream"), + detectSSE: strings.TrimSpace(response.Header.Get("Content-Type")) == "", + maxJSONBytes: resolveUpstreamResponseReadLimit(s.cfg), + rejectMismatch: response.StatusCode == http.StatusOK, + completion: codexRoutingCompletion{RequestedModel: model, maxEventBytes: s.codexRoutingBusinessEventLimit()}, + } + body.completion.headers(response.Header) + return body +} + +// Ungated OAuth requests have the same model contract, but observing them +// cannot mint or refresh a Cookie qualification. API-key aliases are untouched. +func (s *OpenAIGatewayService) observeUnqualifiedCodexResponse(request *http.Request, account *Account, response *http.Response) { + if account == nil || !account.IsOpenAIOAuthLike() || request == nil || request.URL == nil || + request.URL.Hostname() != "chatgpt.com" || request.URL.Path != "/backend-api/codex/responses" || response == nil || response.Body == nil { + return + } + model, _ := request.Context().Value(codexRoutingModelKey{}).(string) + if model == "" { + return + } + response.Body = s.newCodexRoutingObservedBody(request, response, model) +} + type codexRoutingObservedBody struct { io.ReadCloser - sse bool - completion codexRoutingCompletion - json []byte - once sync.Once - finish func(codexRoutingCompletion) + sse bool + detectSSE bool + ctx context.Context + maxJSONBytes int64 + rejectMismatch bool + completion codexRoutingCompletion + json []byte + once sync.Once + mu sync.Mutex + done bool + finish func(codexRoutingCompletion) } func (body *codexRoutingObservedBody) Read(p []byte) (int, error) { + body.mu.Lock() + rejected := body.rejectMismatch && body.completion.Mismatch + body.mu.Unlock() + if rejected { + body.complete() + return 0, ErrCodexRoutingModelMismatch + } n, err := body.ReadCloser.Read(p) - if n > 0 { + body.mu.Lock() + if n > 0 && !body.done { if body.sse { body.completion.feed(p[:n]) - } else if len(body.json)+n <= codexRoutingProbeReadLimit { - body.json = append(body.json, p[:n]...) } else { - body.completion.Failed = true + limit := body.maxJSONBytes + if limit <= 0 { + limit = defaultUpstreamResponseReadMaxBytes + } + if int64(len(body.json))+int64(n) <= limit { + body.json = append(body.json, p[:n]...) + if body.detectSSE && bodyHasSSEFraming(body.json) { + body.sse, body.detectSSE = true, false + body.completion.feed(body.json) + body.json = nil + } + } else { + body.completion.fail("routing_incomplete") + body.json = nil + } } } + if errors.Is(err, context.Canceled) { + body.completion.fail("routing_cancelled") + } + rejected = body.rejectMismatch && body.completion.Mismatch + body.mu.Unlock() if err != nil { body.complete() + body.mu.Lock() + rejected = body.rejectMismatch && body.completion.Mismatch + body.mu.Unlock() + } + if rejected { + body.complete() + return 0, ErrCodexRoutingModelMismatch } return n, err } func (body *codexRoutingObservedBody) complete() { body.once.Do(func() { + body.mu.Lock() if !body.sse && !body.completion.Failed { body.completion.json(body.json) } - body.finish(body.completion) + if body.ctx != nil && errors.Is(body.ctx.Err(), context.Canceled) { + body.completion.fail("routing_cancelled") + } + body.done = true + body.json, body.completion.pending = nil, nil + completed := body.completion + body.mu.Unlock() + if body.finish != nil { + body.finish(completed) + } }) } -func (body *codexRoutingObservedBody) Close() error { body.complete(); return body.ReadCloser.Close() } +func (body *codexRoutingObservedBody) Close() error { + err := body.ReadCloser.Close() + body.complete() + return err +} func (s *OpenAIGatewayService) publishCodexRoutingObservation(ctx context.Context, account *Account, installation *PluginInstallation, q *extensionv1.CodexRoutingQualification, observation extensionv1.CodexRoutingObservation, replacement *extensionv1.CodexRoutingQualification) { if s.pluginManager == nil || installation == nil || q == nil { diff --git a/backend/internal/service/codex_routing_ws_probe.go b/backend/internal/service/codex_routing_ws_probe.go index 1a0117b3a4f8..4a86e3dee823 100644 --- a/backend/internal/service/codex_routing_ws_probe.go +++ b/backend/internal/service/codex_routing_ws_probe.go @@ -28,7 +28,7 @@ func (s *OpenAIGatewayService) executeCodexRoutingWSProbe(ctx context.Context, q if err != nil { return nil, scope, errCodexRoutingUnavailable } - request, err := s.buildCodexRoutingProbe(ctx, account, query.Model, token, cookies) + request, err := s.buildCodexRoutingProbe(ctx, account, query.Model, token, cookies, query.ReasoningEffort) if err != nil { return nil, scope, err } @@ -73,7 +73,8 @@ func (s *OpenAIGatewayService) executeCodexRoutingWSProbe(ctx context.Context, q if err := connection.WriteJSON(ctx, payload); err != nil { return nil, scope, errCodexRoutingUnavailable } - var completion codexRoutingCompletion + completion := codexRoutingCompletion{RequestedModel: query.Model} + completion.headers(handshake) var terminal []byte readBytes := 0 for readBytes < codexRoutingProbeReadLimit { @@ -86,10 +87,7 @@ func (s *OpenAIGatewayService) executeCodexRoutingWSProbe(ctx context.Context, q return nil, scope, errCodexRoutingUnavailable } completion.json(frame) - if completion.Failed { - return nil, scope, errCodexRoutingUnavailable - } - if completion.Completed { + if completion.Failed || completion.Completed { terminal = frame break } diff --git a/backend/internal/service/codex_ticket_lifecycle.go b/backend/internal/service/codex_ticket_lifecycle.go index 58e05775b191..a109cbbfe93f 100644 --- a/backend/internal/service/codex_ticket_lifecycle.go +++ b/backend/internal/service/codex_ticket_lifecycle.go @@ -82,8 +82,12 @@ func CodexTicketAccountEligible(a *Account) bool { func CodexTicketFailure(code string) CodexTicketResult { messages := map[string]string{ "routing_candidate": "已取得候选 Cookie,尚未通过业务出口验证", - "routing_model_mismatch": "业务出口返回模型不匹配,未发布资格", - "routing_incomplete": "响应未完整结束或缺少模型字段", + "routing_model_mismatch": "业务出口的上游模型声明不匹配,未发布路由资格", + "routing_incomplete": "响应未完整结束或缺少模型声明", + "routing_capacity": "上游容量不足或流内限流,未完成路由验证", + "routing_policy": "上游策略检查阻止请求,未完成路由验证", + "routing_cancelled": "客户端取消,本次响应未完成验证", + "routing_legacy_retired": "旧 STATE 材料已丢弃,尚未获得路由资格", "routing_cookie_missing": "未取得可用路由 Cookie", "routing_cookie_expired": "路由 Cookie 已到期", "routing_cookie_deleted": "上游已撤销路由 Cookie", @@ -91,21 +95,21 @@ func CodexTicketFailure(code string) CodexTicketResult { "routing_stale": "账号、设备、出口或 Cookie 代次已变化", "routing_connection_expired": "已验证连接不可复用,需要重新验证", "routing_upstream": "上游拒绝路由验证请求", - "ticket_plugin_unavailable": "票据插件未启用或暂不可用", - "ticket_disabled": "票据总开关未开启", - "ticket_proxy_missing": "未配置打票代理", - "ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号可打票", - "ticket_model_invalid": "模型不在票据配置范围内", - "ticket_busy": "该账号和模型已有打票任务", + "ticket_plugin_unavailable": "Codex 路由插件未启用或暂不可用", + "ticket_disabled": "Codex 路由采集与验证未开启", + "ticket_proxy_missing": "未配置路由采集代理", + "ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号支持路由采集", + "ticket_model_invalid": "模型不在路由配置范围内", + "ticket_busy": "该账号和模型已有路由采集任务", "ticket_interrupted": "上次请求已开始,结果未确认;不会自动重复发送", "ticket_token": "无法取得账号访问令牌", "ticket_timeout": "请求超时", - "ticket_transport": "打票代理传输失败", - "ticket_upstream": "上游拒绝打票请求", - "ticket_length": "响应票据长度不符合292规则", - "ticket_prefix": "响应票据格式不符合规则", - "ticket_missing_header": "响应未包含票据头", - "ticket_persist": "票据持久化失败,未加入自动续期", + "ticket_transport": "路由采集代理传输失败", + "ticket_upstream": "上游拒绝路由采集请求", + "ticket_length": "历史 STATE 长度检查失败(该规则已停用)", + "ticket_prefix": "历史 STATE 格式检查失败(该规则已停用)", + "ticket_missing_header": "历史采集响应未包含 STATE 头", + "ticket_persist": "路由验证记录持久化失败,未加入自动续期", "ticket_stale": "账号身份、任务或续期状态已经变化", "ticket_canceled": "任务已取消", "ticket_stopped": "自动续期已停止", @@ -128,6 +132,12 @@ func CodexTicketFailure(code string) CodexTicketResult { stage = "configuration" case "ticket_busy", "ticket_interrupted", "ticket_stale", "ticket_stopped", "ticket_canceled": stage = "lifecycle" + case "routing_cancelled", "routing_legacy_retired": + stage = "lifecycle" + case "routing_capacity": + stage = "upstream_stream" + case "routing_policy": + stage = "upstream_policy" case "ticket_token": stage = "account_auth" case "ticket_proxy_auth": diff --git a/backend/internal/service/codex_ticket_plugin.go b/backend/internal/service/codex_ticket_plugin.go index 696ad726575d..a82145b94870 100644 --- a/backend/internal/service/codex_ticket_plugin.go +++ b/backend/internal/service/codex_ticket_plugin.go @@ -20,7 +20,7 @@ func (s *OpenAIGatewayService) CodexTicketStatuses(account *Account) []OpenAICod } func (s *OpenAIGatewayService) openAICodexTicketConfig() config.OpenAICodexTicketConfig { - cfg := config.OpenAICodexTicketConfig{FailClosed: true, TargetLength: 292, TTLSeconds: 3600, RefreshBeforeSeconds: 60, Models: []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel}} + cfg := config.OpenAICodexTicketConfig{FailClosed: true, Models: []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel}} if s == nil || s.pluginManager == nil { return cfg } @@ -159,7 +159,7 @@ func OpenAICodexTicketStatuses(account *Account, cfg config.OpenAICodexTicketCon if observation.Code != "" { result := CodexTicketFailure(observation.Code) if observation.Code == "routing_verified" || observation.Code == "ticket_skipped" { - result = CodexTicketResult{Code: observation.Code, Success: true, Message: "业务出口 Cookie 路由已验证"} + result = CodexTicketResult{Code: observation.Code, Success: true, Message: "路由验证记录:业务出口响应完整、模型声明一致;本项未验证质量"} } entry.LastResult = &result } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 37fc9c96ddba..5027dff8c43e 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -719,11 +719,11 @@ const ( SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced" // SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。 SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled" - // SettingKeyOpenAICodexTicketEnabled Codex 292 打票总开关(后台可改、热更新)。 + // SettingKeyOpenAICodexTicketEnabled Codex 路由采集与验证总开关(旧配置兼容键)。 // 关闭:不打票、不注入 x-codex-turn-state,按原链路转发。 // 开启:后台打票并在业务请求中覆盖该头。 SettingKeyOpenAICodexTicketEnabled = "openai_codex_ticket_enabled" - // SettingKeyOpenAICodexTicketHarvestProxyURL Codex 292 打票出口(socks5h/http),后台可改、热更新。 + // SettingKeyOpenAICodexTicketHarvestProxyURL Codex 路由采集出口(旧配置兼容键)。 SettingKeyOpenAICodexTicketHarvestProxyURL = "openai_codex_ticket_harvest_proxy_url" // SettingKeyClaudeCodeClientVersion 网关对 Anthropic 上游声明的 Claude Code CLI 客户端版本号(管理员覆写)。 // 空值表示跟随自动同步值;自动同步也没有结果时回退到 claude.CLIVersion()(环境变量覆盖 + 内置基线)。 diff --git a/backend/internal/service/openai_codex_request_compression.go b/backend/internal/service/openai_codex_request_compression.go index 4c788b4e2647..eb0d49fbb8ad 100644 --- a/backend/internal/service/openai_codex_request_compression.go +++ b/backend/internal/service/openai_codex_request_compression.go @@ -147,6 +147,7 @@ func (s *OpenAIGatewayService) doOpenAICodexUpstream(req *http.Request, account response, err := s.httpUpstream.Do(wire, proxyURL, account.ID, account.Concurrency) if err == nil && isOpenAICodexTicketAccount(account) { s.observeCodexWire(wire.Context(), account, wire, response, nil) + s.observeUnqualifiedCodexResponse(wire, account, response) } return response, err } diff --git a/backend/internal/service/openai_codex_ticket.go b/backend/internal/service/openai_codex_ticket.go index ba2493341e85..44dbf7be403d 100644 --- a/backend/internal/service/openai_codex_ticket.go +++ b/backend/internal/service/openai_codex_ticket.go @@ -18,9 +18,9 @@ const ( openAICodexTicketDefaultSolModel = "gpt-5.6-sol" ) -// ErrOpenAICodexTicketUnavailable 表示该号该模型没有可用的 292 门票, -// 且 fail_closed 禁止裸打业务请求。 -var ErrOpenAICodexTicketUnavailable = errors.New("codex turn-state ticket unavailable") +// ErrOpenAICodexTicketUnavailable 表示该账号和模型没有有效的路由资格, +// 且 fail_closed 禁止未验证路由的业务请求。 +var ErrOpenAICodexTicketUnavailable = errors.New("codex route qualification unavailable") func openAICodexTicketExtraKey(model string) string { return openAICodexTicketExtraKeyPrefix + strings.TrimSpace(model) diff --git a/backend/internal/service/openai_codex_turn_state.go b/backend/internal/service/openai_codex_turn_state.go index 54a4c6576db3..ebe98f66cf12 100644 --- a/backend/internal/service/openai_codex_turn_state.go +++ b/backend/internal/service/openai_codex_turn_state.go @@ -22,6 +22,8 @@ const openAICodexTurnStateHeader = "x-codex-turn-state" // 账号,出站守卫据此剥离已知异账号的回带值。 type openAICodexTurnStateOrigin struct { accountID int64 + identity string + state string expiresAt time.Time } @@ -57,7 +59,11 @@ func openAIWSTurnStateScope(c *gin.Context, account *Account, sessionHash string return sessionHash } if turn := codexRoutingTurnID(c); turn != "" { - return sessionHash + "\x00" + turn + scope := sessionHash + "\x00" + turn + if account != nil && account.IsOpenAIOAuthLike() { + scope += "\x00" + CodexTicketAccountIdentity(account) + } + return scope } return "" } @@ -72,14 +78,14 @@ func (s *OpenAIGatewayService) relayOpenAICodexTurnState(c *gin.Context, account return } canonical := http.CanonicalHeaderKey(openAICodexTurnStateHeader) - state := extractOpenAICodexTurnState(upstream) + state := s.firstCommittedCodexTurnState(c, account, extractOpenAICodexTurnState(upstream)) if state == "" { c.Writer.Header().Del(canonical) s.clearOpenAICodexTurnStateProvenance(c, account) return } c.Writer.Header().Set(canonical, state) - s.noteOpenAICodexTurnStateProvenance(c, account) + s.noteOpenAICodexTurnStateProvenance(c, account, state) } // stageOpenAICodexTurnState 将上游 turn-state 暂存到延迟提交的响应头集合 @@ -108,13 +114,14 @@ func stageOpenAICodexTurnState(dst *http.Header, upstream http.Header) { // noteStagedOpenAICodexTurnStateCommitted 在暂存响应头真正写入下游时记录 // 铸造账号——只有此刻客户端才确定收到了该 blob,溯源表才与客户端持有的 // 值一致(否则被 failover 丢弃的 attempt 会污染溯源,导致后续误剥离)。 -// 成功提交但无 state 表示上一来源已失效,必须清除旧 provenance。 +// OAuth 同主体同 turn 的首个已提交值保持不变;API Key 保留缺失即清除的契约。 func (s *OpenAIGatewayService) noteStagedOpenAICodexTurnStateCommitted(c *gin.Context, account *Account, staged http.Header) { - if staged == nil || strings.TrimSpace(staged.Get(openAICodexTurnStateHeader)) == "" { + state := s.firstCommittedCodexTurnState(c, account, extractOpenAICodexTurnState(staged)) + if state == "" { s.clearOpenAICodexTurnStateProvenance(c, account) return } - s.noteOpenAICodexTurnStateProvenance(c, account) + s.noteOpenAICodexTurnStateProvenance(c, account, state) } func extractOpenAICodexTurnState(upstream http.Header) string { @@ -125,7 +132,7 @@ func extractOpenAICodexTurnState(upstream http.Header) string { } // noteOpenAICodexTurnStateProvenance 记录(下游会话 → 铸造账号)。 -func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context, account *Account) { +func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context, account *Account, states ...string) { if s == nil || account == nil || account.ID <= 0 { return } @@ -133,13 +140,70 @@ func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context if seed == "" { return } - s.openaiCodexTurnStateOrigins.Store(seed, openAICodexTurnStateOrigin{ + state := "" + if len(states) > 0 { + state = strings.TrimSpace(states[0]) + } + strict := account.IsOpenAIOAuthLike() + if strict && state == "" { + return + } + origin := openAICodexTurnStateOrigin{ accountID: account.ID, + identity: CodexTicketAccountIdentity(account), + state: state, expiresAt: time.Now().Add(s.openAIWSSessionStickyTTL()), - }) + } + if strict { + // The first committed value wins atomically for this owner and turn. + for { + old, loaded := s.openaiCodexTurnStateOrigins.LoadOrStore(seed, origin) + if !loaded { + break + } + if previous, ok := old.(openAICodexTurnStateOrigin); ok && previous.accountID == origin.accountID && + previous.identity == origin.identity && previous.state != "" && time.Now().Before(previous.expiresAt) { + return + } + if s.openaiCodexTurnStateOrigins.CompareAndSwap(seed, old, origin) { + break + } + } + } else { + s.openaiCodexTurnStateOrigins.Store(seed, origin) + } s.sweepOpenAICodexTurnStateOrigins() } +func (s *OpenAIGatewayService) firstCommittedCodexTurnState(c *gin.Context, account *Account, candidate string) string { + if s == nil || account == nil || !account.IsOpenAIOAuthLike() { + return candidate + } + if raw, ok := s.openaiCodexTurnStateOrigins.Load(openAICodexTurnStateSeed(c, account)); ok { + if origin, ok := raw.(openAICodexTurnStateOrigin); ok && origin.accountID == account.ID && + origin.identity == CodexTicketAccountIdentity(account) && origin.state != "" && time.Now().Before(origin.expiresAt) { + return origin.state + } + } + return candidate +} + +// Keep the client-facing header consistent with the first committed value, +// without rewriting the original headers used for upstream observations. +func (s *OpenAIGatewayService) codexTurnStateResponseHeaders(c *gin.Context, account *Account, upstream http.Header) http.Header { + state := extractOpenAICodexTurnState(upstream) + first := s.firstCommittedCodexTurnState(c, account, state) + if first == state { + return upstream + } + prepared := upstream.Clone() + if prepared == nil { + prepared = make(http.Header) + } + prepared.Set(openAICodexTurnStateHeader, first) + return prepared +} + func (s *OpenAIGatewayService) clearOpenAICodexTurnStateProvenance(c *gin.Context, accounts ...*Account) { if s == nil { return @@ -162,7 +226,7 @@ func (s *OpenAIGatewayService) commitOpenAIWSSessionTurnState( sessionHash string, turnState string, ) { - turnState = strings.TrimSpace(turnState) + turnState = s.firstCommittedCodexTurnState(c, account, strings.TrimSpace(turnState)) stateScope := openAIWSTurnStateScope(c, account, sessionHash) if stateStore != nil && stateScope != "" { if !openAICodexTurnStateUsesSessionContract(account) && account != nil { @@ -180,7 +244,7 @@ func (s *OpenAIGatewayService) commitOpenAIWSSessionTurnState( s.clearOpenAICodexTurnStateProvenance(c, account) return } - s.noteOpenAICodexTurnStateProvenance(c, account) + s.noteOpenAICodexTurnStateProvenance(c, account, turnState) } // guardOpenAICodexTurnStateEcho 出站守卫:客户端回带的 turn-state 只有在 @@ -221,6 +285,11 @@ func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, acc } if origin.accountID != account.ID { h.Del(openAICodexTurnStateHeader) + return + } + if account.IsOpenAIOAuthLike() && (origin.identity != CodexTicketAccountIdentity(account) || + origin.state == "" || strings.TrimSpace(h.Get(openAICodexTurnStateHeader)) != origin.state) { + h.Del(openAICodexTurnStateHeader) } } diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 8efa0c5d3ad3..d597e2118d46 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1110,7 +1110,7 @@ func TestForwardAsAnthropic_KeepsInboundHyphenSessionHeaderOverCacheKeyFallback( require.Empty(t, upstream.requests[0].Header.Get("conversation_id")) } -func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { +func TestForwardAsAnthropic_ReusesOAuthCodexTurnStateOnlyWithinExplicitTurn(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -1119,6 +1119,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ firstResp, openAICompatSSECompletedResponse("resp_oauth_second", "gpt-5.4"), + openAICompatSSECompletedResponse("resp_oauth_next_turn", "gpt-5.4"), }} svc := &OpenAIGatewayService{ httpUpstream: upstream, @@ -1141,6 +1142,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { firstCtx, _ := gin.CreateTestContext(firstRec) firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) firstCtx.Request.Header.Set("Content-Type", "application/json") + firstCtx.Request.Header.Set(openAIWSTurnMetadataHeader, `{"turn_id":"first-turn"}`) firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4") require.NoError(t, err) @@ -1148,11 +1150,13 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) - secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + // A subsequent request for the same explicit turn may carry its first STATE. + secondBody := firstBody secondRec := httptest.NewRecorder() secondCtx, _ := gin.CreateTestContext(secondRec) secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) secondCtx.Request.Header.Set("Content-Type", "application/json") + secondCtx.Request.Header.Set(openAIWSTurnMetadataHeader, `{"turn_id":"first-turn"}`) secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4") require.NoError(t, err) @@ -1163,6 +1167,19 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) + + nextBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + nextRec := httptest.NewRecorder() + nextCtx, _ := gin.CreateTestContext(nextRec) + nextCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(nextBody)) + nextCtx.Request.Header.Set("Content-Type", "application/json") + nextCtx.Request.Header.Set(openAIWSTurnMetadataHeader, `{"turn_id":"next-turn"}`) + nextResult, err := svc.ForwardAsAnthropic(context.Background(), nextCtx, account, nextBody, "stable-cache-key", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, nextResult) + require.Len(t, upstream.requests, 3) + require.Equal(t, upstream.requests[1].Header.Get("session-id"), upstream.requests[2].Header.Get("session-id")) + require.Empty(t, upstream.requests[2].Header.Get("x-codex-turn-state"), "a new explicit turn must not inherit the prior turn's STATE") } func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { @@ -1216,7 +1233,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { } } -func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey(t *testing.T) { +func TestForwardAsAnthropic_OAuthDigestFallbackKeepsSessionWithoutUnprovenTurnState(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -1267,7 +1284,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) - require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("x-codex-turn-state"), "the digest preserves a session, not proof of a shared logical turn") require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) @@ -1325,7 +1342,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesDigestPrefixRewrite(t *t require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) - require.Equal(t, "turn_state_metadata_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("x-codex-turn-state"), "metadata preserves session identity but does not establish a shared logical turn") require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) @@ -1382,7 +1399,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnch require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) - require.Equal(t, "turn_state_cache_anchor_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("x-codex-turn-state"), "a stable metadata session does not carry STATE into a new user turn") require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index c5688abf3670..521f04c7422f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -514,7 +514,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { + if instructionsEmpty && account.UsesOpenAICodexProtocol() && !responsesLite && !compatMessagesBridge && !nativeCNResponses { markPatchSet("instructions", defaultCodexSynthInstructions(upstreamModel)) } if billingModel != requestedModel { @@ -648,6 +648,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{ IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, + SkipDefaultInstructions: responsesLite, OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages, }) } @@ -1653,9 +1654,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if account.UsesOpenAICodexProtocol() { // Required: set Host for ChatGPT API (must use req.Host, not Header.Set) req.Host = "chatgpt.com" - if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { - return nil, fmt.Errorf("resolve chatgpt account headers: %w", err) - } } // Whitelist passthrough headers @@ -1728,14 +1726,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. ensureCodexSessionIdentityHeaders(req.Header, gjson.GetBytes(body, "prompt_cache_key").String()) } - // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽), - // 身份取自该 OAuth 凭据的账号级 Codex TUI 身份;客户端自报身份不参与构造。 - if account.UsesOpenAICodexProtocol() { - if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil { - return nil, err - } - } - // Ensure required headers exist if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -1751,13 +1741,15 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) - setOpenAICodexRoutingHintFromBody(req.Header, account, body) + if err := s.finalizeCodexOutboundHeaders(req.Context(), c, account, req.Header, gjson.GetBytes(body, "model").String(), gjson.GetBytes(body, "service_tier").String()); err != nil { + return nil, err + } logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable") if err := applyMappedGPT55LiteCompatibility(req, account, body); err != nil { return nil, err } - return req, nil + return withCodexRoutingDownstreamContext(req, c), nil } // codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。 diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 8f01b40b4449..09beaa6acd2f 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -34,6 +34,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( defaultMappedModel string, ) (*OpenAIForwardResult, error) { rememberPromptRequestedModel(c, body) + stageCodexRoutingTurn(c, body) + c.Set(openAICompatTurnStateCommittedContextKey, false) pricingContext, pricingErr := CaptureCindyPricingContext(ctx, c, account) if pricingErr != nil { return nil, pricingErr @@ -151,9 +153,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( compatContinuationDisabled := compatContinuationEnabled && s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, compatSessionSeed) compatTurnState := "" - // OAuth/Plus relies on session_id + x-codex-turn-state; trimming to a - // sliding 12-message window makes the cached prefix stall at system/tools. - // Keep full replay there so upstream prompt caching can grow turn by turn. + // Keep OAuth history for prompt caching. Opaque turn state is independently + // scoped to an explicit logical turn; a cache/session key cannot extend it. if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() && previousResponseID == "" && !compatContinuationDisabled { compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq) } @@ -421,7 +422,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 清除身份头。真正发送前恢复完整 Codex 身份,避免 ChatGPT Codex 上游因缺失 // originator/OpenAI-Beta 返回 404(issue #3901)。 ensureCodexIdentityHeaders(upstreamReq.Header) - if err := enforceCodexIdentityHeadersForAccountContext(upstreamReq.Context(), upstreamReq.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil { + if err := s.finalizeCodexOutboundHeaders(upstreamReq.Context(), c, account, upstreamReq.Header, upstreamModel, gjson.GetBytes(responsesBody, "service_tier").String()); err != nil { return nil, err } logger.L().Debug("openai messages: upstream identity restored", @@ -545,12 +546,6 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.Header, resp.StatusCode) } - if account.UsesOpenAICodexProtocol() && compatSessionSeed != "" { - if turnState := strings.TrimSpace(resp.Header.Get("x-codex-turn-state")); turnState != "" { - s.bindOpenAICompatSessionTurnState(ctx, c, account, compatSessionSeed, turnState) - } - } - // 9. Handle normal response // Upstream is always streaming; choose response format based on client preference. var result *OpenAIForwardResult @@ -573,6 +568,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // Propagate ServiceTier and ReasoningEffort to result for billing if handleErr == nil && result != nil { + if account.UsesOpenAICodexProtocol() && compatSessionSeed != "" && !result.ClientDisconnect && + c.GetBool(openAICompatTurnStateCommittedContextKey) && c.Request.Context().Err() == nil { + s.bindOpenAICompatSessionTurnState(ctx, c, account, compatSessionSeed, extractOpenAICodexTurnState(resp.Header)) + } if compatContinuationEnabled && compatSessionSeed != "" && result.ResponseID != "" { s.bindOpenAICompatSessionResponseID(ctx, c, account, compatSessionSeed, result.ResponseID) } @@ -716,7 +715,20 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) } c.Header("Content-Type", "application/json; charset=utf-8") - c.JSON(http.StatusOK, anthropicResp) + if account != nil && account.IsOpenAIOAuthLike() { + responseBody, err := json.Marshal(anthropicResp) + if err != nil { + return nil, err + } + if err := writeOpenAIHTTPResponseData(c, http.StatusOK, "application/json; charset=utf-8", responseBody); err != nil { + return nil, err + } + if strings.TrimSpace(finalResponse.Status) == "completed" { + c.Set(openAICompatTurnStateCommittedContextKey, true) + } + } else { + c.JSON(http.StatusOK, anthropicResp) + } result := &OpenAIForwardResult{ RequestID: requestID, @@ -1013,6 +1025,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( var usage OpenAIUsage responseID := "" terminalEventType := "" + completedTurn := false var firstTokenMs *int firstChunk := true clientDisconnected := false @@ -1107,6 +1120,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( isTerminalEvent := isOpenAICompatResponsesTerminalEvent(eventType) || isBareErrorEvent if isTerminalEvent { terminalEventType = eventType + completedTurn = eventType == "response.completed" && event.Response != nil && event.Response.Status == "completed" if event.Response != nil { if id := strings.TrimSpace(event.Response.ID); id != "" { responseID = id @@ -1242,6 +1256,9 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( c.Writer.Flush() } } + if completedTurn && clientOutputStarted && !clientDisconnected { + c.Set(openAICompatTurnStateCommittedContextKey, true) + } logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected) return resultWithUsage(), nil } diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index d3ddd7457d9f..f6e6347e4c1d 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -202,7 +202,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } if account != nil && account.UsesOpenAICodexProtocol() { - if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" { + lite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) + if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" && !lite { rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field" MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body) @@ -214,7 +215,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( }) return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason) } - if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() { + if !lite && isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() { nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel)) if setErr != nil { return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr) @@ -850,9 +851,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( stripOpenAILegacyResponsesBeta(req.Header) promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) req.Host = "chatgpt.com" - if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { - return nil, fmt.Errorf("resolve chatgpt account headers: %w", err) - } apiKeyID := getAPIKeyIDFromContext(c) // 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。 clientSessionID := strings.TrimSpace(req.Header.Get("session_id")) @@ -914,14 +912,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if account.UsesOpenAICodexProtocol() && !isOpenAIResponsesCompactPath(c) { ensureCodexSessionIdentityHeaders(req.Header, gjson.GetBytes(body, "prompt_cache_key").String()) } - // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一为该账号的 Codex TUI 身份 - // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 - if account.UsesOpenAICodexProtocol() { - if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil { - return nil, err - } - } - if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") } @@ -936,13 +926,15 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) - setOpenAICodexRoutingHintFromBody(req.Header, account, body) + if err := s.finalizeCodexOutboundHeaders(req.Context(), c, account, req.Header, gjson.GetBytes(body, "model").String(), gjson.GetBytes(body, "service_tier").String()); err != nil { + return nil, err + } logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable") if err := applyMappedGPT55LiteCompatibility(req, account, body); err != nil { return nil, err } - return req, nil + return withCodexRoutingDownstreamContext(req, c), nil } func stripOpenAILegacyResponsesBeta(headers http.Header) { @@ -2177,7 +2169,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) var stagedTurnState http.Header - stageOpenAICodexTurnState(&stagedTurnState, resp.Header) + stageOpenAICodexTurnState(&stagedTurnState, s.codexTurnStateResponseHeaders(c, account, resp.Header)) // Keep turn-state private until an actual downstream write. Merely receiving // upstream headers must not move provenance to an attempt the client never saw. c.Writer.Header().Del(openAICodexTurnStateHeader) @@ -2877,7 +2869,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, s.codexTurnStateResponseHeaders(c, account, resp.Header)) contentType := resp.Header.Get("Content-Type") if contentType == "" { @@ -3028,7 +3020,7 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c if terminalErr == nil { logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false) } - stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, s.codexTurnStateResponseHeaders(c, account, resp.Header)) contentType := "application/json; charset=utf-8" if !ok { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index ae8a97d38ecc..8241e461c898 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -134,7 +134,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // 客户端会在同回合的后续请求中回带(openai_codex_turn_state.go)。 // OpenAI 首个语义输出前只暂存,溯源在 applyAttemptResponseHeaders 真正提交时记录。 if stageFirstOutput { - stageOpenAICodexTurnState(&attemptResponseHeaders, resp.Header) + stageOpenAICodexTurnState(&attemptResponseHeaders, s.codexTurnStateResponseHeaders(c, account, resp.Header)) } else { s.relayOpenAICodexTurnState(c, account, resp.Header) } @@ -562,6 +562,17 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if scanErr == nil { return nil, nil, false } + if errors.Is(scanErr, ErrCodexRoutingModelMismatch) { + // The route observer already revoked the mismatched qualification. + // Never turn this into an automatic replay, especially after output. + if account != nil && account.IsOpenAIOAuthLike() { + s.clearOpenAICodexTurnStateProvenance(c, account) + } + if openAIStreamClientOutputStarted(c, clientOutputStarted) { + sendErrorEvent("upstream_model_mismatch", "upstream_model_mismatch") + } + return resultWithUsage(), ErrCodexRoutingModelMismatch, true + } if errors.Is(scanErr, errOpenAIFirstOutputScannerLimit) && !firstOutputProgressObserved { logger.LegacyPrintf("service.openai_gateway", "SSE token exceeded guarded first-output limit: account=%d limit=%d error=%v", account.ID, openAIFirstOutputStageMaxBytes+openAIFirstOutputScannerFramingAllowance, scanErr) failoverErr := s.newOpenAIStreamFailoverError( @@ -1876,7 +1887,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r // Codex 协议要求 /responses/compact JSON 响应携带 x-codex-turn-state // (codex-api/src/endpoint/compact.rs 从响应头捕获)。先暂存,只有 body // 真正写成功后才更新该会话的铸造账号。 - stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, s.codexTurnStateResponseHeaders(c, account, resp.Header)) contentType := "application/json" if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { @@ -2178,7 +2189,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte if terminalErr == nil { logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false) } - stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, s.codexTurnStateResponseHeaders(c, account, resp.Header)) contentType := "application/json; charset=utf-8" if !ok { diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go index 65bafbd5448c..845ba1165615 100644 --- a/backend/internal/service/openai_messages_continuation.go +++ b/backend/internal/service/openai_messages_continuation.go @@ -20,6 +20,8 @@ type openAICompatSessionResponseBinding struct { ExpiresAt time.Time } +const openAICompatTurnStateCommittedContextKey = "openai_compat_turn_state_committed" + func openAICompatContinuationEnabled(account *Account, model string) bool { if account == nil || account.Type != AccountTypeAPIKey { return false @@ -292,7 +294,7 @@ func (s *OpenAIGatewayService) getOpenAICompatSessionTurnState(_ context.Context if s == nil { return "" } - key := openAICompatSessionResponseKey(c, account, promptCacheKey) + key := openAICompatTurnStateKey(c, account, promptCacheKey) if key == "" { return "" } @@ -315,7 +317,7 @@ func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Contex if s == nil { return } - key := openAICompatSessionResponseKey(c, account, promptCacheKey) + key := openAICompatTurnStateKey(c, account, promptCacheKey) state := strings.TrimSpace(turnState) if key == "" || state == "" { return @@ -324,6 +326,20 @@ func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Contex TurnState: state, ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()), } + if account.IsOpenAIOAuthLike() { + for { + raw, loaded := s.openaiCompatSessionResponses.LoadOrStore(key, binding) + if !loaded { + return + } + if existing, ok := raw.(openAICompatSessionResponseBinding); ok && existing.TurnState != "" && time.Now().Before(existing.ExpiresAt) { + return + } + if s.openaiCompatSessionResponses.CompareAndSwap(key, raw, binding) { + return + } + } + } if raw, ok := s.openaiCompatSessionResponses.Load(key); ok { if existing, ok := raw.(openAICompatSessionResponseBinding); ok { binding.ResponseID = existing.ResponseID @@ -332,3 +348,18 @@ func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Contex } s.openaiCompatSessionResponses.Store(key, binding) } + +func openAICompatTurnStateKey(c *gin.Context, account *Account, promptCacheKey string) string { + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + if key == "" || openAICodexTurnStateUsesSessionContract(account) { + return key + } + turn := codexRoutingTurnID(c) + if turn == "" { + // A conversation/cache key alone does not prove a logical turn. In + // particular, a Messages history followed by another user prompt must + // never inherit the preceding turn's opaque OAuth state. + return "" + } + return key + "\x00" + CodexTicketAccountIdentity(account) + "\x00" + turn +} diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index f01d69057ccd..221414665856 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -112,21 +112,22 @@ type OpenAIExchangeCodeInput struct { // OpenAITokenInfo represents the token information for OpenAI type OpenAITokenInfo struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token,omitempty"` - ExpiresIn int64 `json:"expires_in"` - ExpiresAt int64 `json:"expires_at"` - ClientID string `json:"client_id,omitempty"` - AuthMode string `json:"auth_mode,omitempty"` - Email string `json:"email,omitempty"` - ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` - ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` - ChatGPTAccountFedRAMP bool `json:"chatgpt_account_is_fedramp,omitempty"` - OrganizationID string `json:"organization_id,omitempty"` - PlanType string `json:"plan_type,omitempty"` - SubscriptionExpiresAt string `json:"subscription_expires_at,omitempty"` - PrivacyMode string `json:"privacy_mode,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt int64 `json:"expires_at"` + ClientID string `json:"client_id,omitempty"` + AuthMode string `json:"auth_mode,omitempty"` + Email string `json:"email,omitempty"` + ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` + ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` + ChatGPTAccountFedRAMP bool `json:"chatgpt_account_is_fedramp,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + PlanType string `json:"plan_type,omitempty"` + SubscriptionExpiresAt string `json:"subscription_expires_at,omitempty"` + PrivacyMode string `json:"privacy_mode,omitempty"` + planTypeFromOtherWorkspace bool } // ExchangeCode exchanges authorization code for tokens @@ -214,13 +215,13 @@ func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken stri // RefreshTokenWithClientID refreshes an OpenAI OAuth token with optional client_id. func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refreshToken string, proxyURL string, clientID string) (*OpenAITokenInfo, error) { - return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, codexOutboundIdentity{}) + return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, codexOutboundIdentity{}, nil) } // refreshTokenWithIdentity 用给定的 Codex 出站身份刷新 token:已有账号刷新时使用与推理 // 一致的账号级身份(真实 Codex 客户端以自己的 UA/originator 刷新);身份为空或客户端 // 不支持时退回全局规范身份。 -func (s *OpenAIOAuthService) refreshTokenWithIdentity(ctx context.Context, refreshToken, proxyURL, clientID string, identity codexOutboundIdentity) (*OpenAITokenInfo, error) { +func (s *OpenAIOAuthService) refreshTokenWithIdentity(ctx context.Context, refreshToken, proxyURL, clientID string, identity codexOutboundIdentity, selectedAccount *Account) (*OpenAITokenInfo, error) { var tokenResp *openai.TokenResponse var err error refresher, ok := s.oauthClient.(OpenAIOAuthIdentityRefresher) @@ -263,6 +264,7 @@ func (s *OpenAIOAuthService) refreshTokenWithIdentity(ctx context.Context, refre tokenInfo.PlanType = userInfo.PlanType } + preserveOpenAISelectedWorkspace(tokenInfo, selectedAccount) s.enrichTokenInfo(ctx, tokenInfo, proxyURL) return tokenInfo, nil @@ -286,12 +288,12 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope // accounts/check 命中的记录不属于个人账号时,必须改用个人订阅端点拿到期时间, // 否则会把 workspace 权益的 expires_at 当成个人订阅到期日展示。 forcePersonalSubscriptionLookup := false - if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { - // chatgpt_plan_type from the ID token is the canonical personal-plan value. - // accounts/check is a multi-account/workspace endpoint; inactive team or - // business workspaces can otherwise overwrite Pro/Free with internal - // workspace billing plan names such as self_serve_business_usage_based. - appliedAccountInfoPlanType := shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) + if info := fetchChatGPTAccountInfoForWorkspace(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID, tokenInfo.ChatGPTAccountID); info != nil { + // A token claim is authoritative only for its own selected workspace. + // Preserve that claim; when refresh returned a different default workspace, + // use only the exact selected account's authenticated account information. + appliedAccountInfoPlanType := shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) || + (tokenInfo.planTypeFromOtherWorkspace && strings.TrimSpace(info.PlanType) != "" && chatGPTAccountInfoBelongsToTokenAccount(tokenInfo, info)) if appliedAccountInfoPlanType { tokenInfo.PlanType = info.PlanType } @@ -404,7 +406,7 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A if err != nil { return nil, err } - return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, identity) + return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, identity, account) } // BuildAccountCredentials builds credentials map from token info diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index b2fe4e73b79c..36cf36be4063 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -116,6 +116,10 @@ var ( // orgID is used to match the correct account when multiple accounts exist (e.g., personal + team). // Returns nil on any failure (best-effort, non-blocking). func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, orgID string) *ChatGPTAccountInfo { + return fetchChatGPTAccountInfoForWorkspace(ctx, clientFactory, accessToken, proxyURL, orgID, "") +} + +func fetchChatGPTAccountInfoForWorkspace(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, orgID, selectedAccountID string) *ChatGPTAccountInfo { if accessToken == "" || clientFactory == nil { return nil } @@ -156,6 +160,11 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac slog.Debug("chatgpt_account_check_no_accounts", "body", truncate(resp.String(), 300)) return nil } + // An existing account selects one workspace. Never fall back to the token's + // default or another paid workspace when that exact identity is absent. + if strings.TrimSpace(selectedAccountID) != "" { + return chatGPTAccountInfoForSelectedWorkspace(accounts, selectedAccountID, time.Now()) + } // 优先匹配 orgID 对应的账号(access_token JWT 中的 poid) if orgID != "" { diff --git a/backend/internal/service/openai_routing_hint.go b/backend/internal/service/openai_routing_hint.go index c66b94114918..95e8ae1b5e2e 100644 --- a/backend/internal/service/openai_routing_hint.go +++ b/backend/internal/service/openai_routing_hint.go @@ -71,11 +71,6 @@ func deleteOpenAIHeaderEqualFold(headers http.Header, name string) { } } -func setOpenAICodexRoutingHintFromBody(headers http.Header, account *Account, body []byte) { - fields := gjson.GetManyBytes(body, "model", "service_tier") - setOpenAICodexRoutingHint(headers, account, fields[0].String(), fields[1].String()) -} - // logOpenAIRoutingDiagnostics records only gateway-derived routing state. In // particular, it deliberately does not include any header values, tokens, or // credentials because these diagnostics run on authentication-bearing paths. diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 7aa52223b3e0..4823e4b71b10 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -174,7 +174,7 @@ func TestOpenAISetupTokenChatCompletionsUsesCodexTransform(t *testing.T) { require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) } -func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { +func TestOpenAISetupTokenMessagesUsesCodexBridgeWithoutUnprovenTurnState(t *testing.T) { gin.SetMode(gin.TestMode) firstResp := openAICompatSSECompletedResponse("resp_setup_first", "gpt-5.4") @@ -226,7 +226,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, secondResult) require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx)) - require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("x-codex-turn-state"), "a shared cache key does not prove that the next user prompt belongs to the prior turn") require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session-id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, "codex-tui") diff --git a/backend/internal/service/openai_subscription_test.go b/backend/internal/service/openai_subscription_test.go index 17c6f789df9a..5f38645590ab 100644 --- a/backend/internal/service/openai_subscription_test.go +++ b/backend/internal/service/openai_subscription_test.go @@ -260,8 +260,8 @@ func TestEnrichTokenInfo_KeepsEntitlementWhenAccountMatches(t *testing.T) { require.Zero(t, subscriptionCalls, "账号一致时不应额外请求订阅端点") } -// 反向不变式:套餐本身就取自 accounts/check(JWT 没有 plan_type)时,到期时间必须 -// 跟着取同一条记录,否则会变成「workspace 套餐 + 个人到期时间」的另一种错配。 +// 反向不变式:所选 workspace 的套餐取自 accounts/check(JWT 没有 plan_type)时, +// 到期时间必须跟着取同一条记录,不能混用其他 workspace 的订阅。 func TestEnrichTokenInfo_WorkspacePlanTypeKeepsItsOwnExpiry(t *testing.T) { const workspaceAccountID = "workspace-b" workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) @@ -289,7 +289,7 @@ func TestEnrichTokenInfo_WorkspacePlanTypeKeepsItsOwnExpiry(t *testing.T) { tokenInfo := &OpenAITokenInfo{ AccessToken: "access-token", - ChatGPTAccountID: "personal-account-a", + ChatGPTAccountID: workspaceAccountID, OrganizationID: workspaceAccountID, // id_token 没带 chatgpt_plan_type } diff --git a/backend/internal/service/openai_workspace_identity.go b/backend/internal/service/openai_workspace_identity.go new file mode 100644 index 000000000000..16bfb0b81d41 --- /dev/null +++ b/backend/internal/service/openai_workspace_identity.go @@ -0,0 +1,47 @@ +package service + +import ( + "strings" + "time" +) + +// A refresh rotates credentials for the selected workspace; it must not switch +// the account mapping to whichever workspace the new ID token defaults to. +func preserveOpenAISelectedWorkspace(info *OpenAITokenInfo, account *Account) { + if info == nil || account == nil { + return + } + selected := strings.TrimSpace(account.GetCredential("chatgpt_account_id")) + if selected == "" { + return + } + if !strings.EqualFold(selected, strings.TrimSpace(info.ChatGPTAccountID)) { + // Keep the existing selected-workspace metadata until accounts/check can + // corroborate it. The other workspace's claim is not evidence about it. + info.PlanType = account.GetCredential("plan_type") + info.SubscriptionExpiresAt = account.GetCredential("subscription_expires_at") + info.OrganizationID = account.GetCredential("organization_id") + info.planTypeFromOtherWorkspace = true + } + info.ChatGPTAccountID = selected + info.OrganizationID = account.GetCredential("organization_id") +} + +func chatGPTAccountInfoForSelectedWorkspace(accounts map[string]any, selected string, now time.Time) *ChatGPTAccountInfo { + selected = strings.TrimSpace(selected) + if selected == "" { + return nil + } + for key, raw := range accounts { + account, ok := raw.(map[string]any) + if !ok || !strings.EqualFold(chatGPTAccountObjectID(account, key), selected) || !isUsableChatGPTAccountCandidate(account, now) { + continue + } + info := &ChatGPTAccountInfo{} + fillAccountInfo(info, account, key) + if strings.TrimSpace(info.PlanType) != "" { + return info + } + } + return nil +} diff --git a/backend/internal/service/openai_workspace_identity_test.go b/backend/internal/service/openai_workspace_identity_test.go new file mode 100644 index 000000000000..55cfea703383 --- /dev/null +++ b/backend/internal/service/openai_workspace_identity_test.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPreserveOpenAISelectedWorkspace(t *testing.T) { + for _, plan := range []string{"team", "business", "self_serve_business_prolite", "pro"} { + t.Run(plan, func(t *testing.T) { + account := &Account{Credentials: map[string]any{"chatgpt_account_id": "selected", "organization_id": "selected-org", "plan_type": plan, "subscription_expires_at": "selected-expiry"}} + info := &OpenAITokenInfo{ChatGPTAccountID: "other-default", OrganizationID: "default-org", PlanType: "free", SubscriptionExpiresAt: "other-expiry"} + preserveOpenAISelectedWorkspace(info, account) + require.Equal(t, "selected", info.ChatGPTAccountID) + require.Equal(t, "selected-org", info.OrganizationID) + require.Equal(t, plan, info.PlanType) + require.Equal(t, "selected-expiry", info.SubscriptionExpiresAt) + require.True(t, info.planTypeFromOtherWorkspace) + require.Equal(t, plan, account.GetCredential("plan_type"), "parsing does not rewrite account data") + }) + } + info := &OpenAITokenInfo{ChatGPTAccountID: "personal", OrganizationID: "unrelated-default-org", PlanType: "pro"} + preserveOpenAISelectedWorkspace(info, &Account{Credentials: map[string]any{"chatgpt_account_id": "personal", "plan_type": "plus"}}) + require.Equal(t, "pro", info.PlanType, "a fresh claim for the selected personal account remains authoritative") + require.Empty(t, info.OrganizationID, "refresh must not invent an organization mapping") + require.False(t, info.planTypeFromOtherWorkspace) +} + +func TestEnrichTokenInfo_SelectedWorkspaceReplacesForeignPlanClaim(t *testing.T) { + expiresAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{"accounts": map[string]any{ + "default": map[string]any{"account": map[string]any{"account_id": "personal", "plan_type": "pro", "is_default": true}}, + "team": map[string]any{"account": map[string]any{"account_id": "selected-team", "plan_type": "business"}, "entitlement": map[string]any{"expires_at": expiresAt}}, + }}, + }) + defer server.Close() + info := &OpenAITokenInfo{AccessToken: "test-token", ChatGPTAccountID: "personal", PlanType: "pro"} + account := &Account{Credentials: map[string]any{"chatgpt_account_id": "selected-team", "plan_type": "team"}} + preserveOpenAISelectedWorkspace(info, account) + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), info, "") + require.Equal(t, "selected-team", info.ChatGPTAccountID) + require.Equal(t, "business", info.PlanType) + require.Equal(t, expiresAt, info.SubscriptionExpiresAt) +} + +func TestEnrichTokenInfo_MissingSelectedWorkspaceDoesNotAdoptDefaultPlan(t *testing.T) { + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{"accounts": map[string]any{ + "default": map[string]any{"account": map[string]any{"account_id": "personal", "plan_type": "pro", "is_default": true}}, + }}, + onSubscription: func(accountID string) map[string]any { + require.Equal(t, "selected-team", accountID) + return map[string]any{} + }, + }) + defer server.Close() + info := &OpenAITokenInfo{AccessToken: "test-token", ChatGPTAccountID: "personal", PlanType: "pro"} + preserveOpenAISelectedWorkspace(info, &Account{Credentials: map[string]any{"chatgpt_account_id": "selected-team"}}) + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), info, "") + require.Equal(t, "selected-team", info.ChatGPTAccountID) + require.Empty(t, info.PlanType) + require.Empty(t, info.SubscriptionExpiresAt) +} + +func TestChatGPTAccountInfoForSelectedWorkspace(t *testing.T) { + now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + accounts := map[string]any{ + "default": map[string]any{"account": map[string]any{"account_id": "personal", "plan_type": "pro", "is_default": true}}, + "team-alias": map[string]any{"account": map[string]any{"account_id": "selected-team", "plan_type": "business"}}, + "expired": map[string]any{"account": map[string]any{"plan_type": "team"}, "entitlement": map[string]any{"expires_at": now.Add(-time.Hour).Format(time.RFC3339)}}, + "disabled": map[string]any{"account": map[string]any{"plan_type": "team", "is_deactivated": true}}, + "wrong-key": map[string]any{"account": map[string]any{"account_id": "different-owner", "plan_type": "team"}}, + } + team := chatGPTAccountInfoForSelectedWorkspace(accounts, "selected-team", now) + require.NotNil(t, team) + require.Equal(t, "selected-team", team.AccountID) + require.Equal(t, "business", team.PlanType) + personal := chatGPTAccountInfoForSelectedWorkspace(accounts, "personal", now) + require.NotNil(t, personal) + require.Equal(t, "pro", personal.PlanType) + for _, selected := range []string{"missing-team", "expired", "disabled", "wrong-key"} { + require.Nil(t, chatGPTAccountInfoForSelectedWorkspace(accounts, selected, now), "must not fall back to a different workspace: %s", selected) + } +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index fc8188b6688d..b7f8366522d5 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -101,7 +101,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( errors.New("invalid json"), ) } - stageCodexRoutingTurn(c, trimmedFirstMessage) + stageCodexRoutingWSTurn(c, trimmedFirstMessage) // 预取一次 OpenAI Fast Policy settings,绑定到 ctx,让该 WS session // 内所有帧的 evaluateOpenAIFastPolicy 调用复用同一份快照,避免每帧 @@ -607,7 +607,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, integrityErr.Error(), integrityErr) } ingressSessionOriginalModel = originalModel - stageCodexRoutingTurn(c, trimmed) + stageCodexRoutingWSTurn(c, trimmed) if nativeRoutingConnection { if routingErr := s.guardCodexRoutingNativeModel(account, upstreamModel); routingErr != nil { return openAIWSClientPayload{}, routingErr @@ -776,15 +776,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeReplayInputExists := false bridgeReplayVerified := false bridgeBaselineResponseID := "" - bridgeOwnedTurnState := openAIWSHTTPBridgeTurnState{accountID: account.ID} + bridgeOwnedTurnState := openAIWSHTTPBridgeTurnState{accountID: account.ID, identity: CodexTicketAccountIdentity(account)} bridgeLogicalTurn := "" for turn := 1; ; turn++ { - stageCodexRoutingTurn(c, currentBridgePayload.rawForHash) + stageCodexRoutingWSTurn(c, currentBridgePayload.rawForHash) currentLogicalTurn := codexRoutingTurnID(c) - if bridgeOwnedTurnState.accountID != account.ID || (!openAICodexTurnStateUsesSessionContract(account) && (currentLogicalTurn == "" || currentLogicalTurn != bridgeLogicalTurn)) { + if bridgeOwnedTurnState.accountID != account.ID || (account.IsOpenAIOAuthLike() && bridgeOwnedTurnState.identity != CodexTicketAccountIdentity(account)) || (!openAICodexTurnStateUsesSessionContract(account) && (currentLogicalTurn == "" || currentLogicalTurn != bridgeLogicalTurn)) { turnState, bridgeOwnedTurnState.value = "", "" bridgeOwnedTurnState.accountID = account.ID + bridgeOwnedTurnState.identity = CodexTicketAccountIdentity(account) } + bridgeOwnedTurnState.turnID = currentLogicalTurn bridgeLogicalTurn = currentLogicalTurn if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { @@ -926,7 +928,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( // tool continuations with an omitted/different response header. A new // turn clears it above. API destinations keep their prior contract. bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)) - if openAICodexTurnStateUsesSessionContract(account) || bridgeOwnedTurnState.value == "" { + if openAICodexTurnStateUsesSessionContract(account) || (bridgeOwnedTurnState.value == "" && !result.ClientDisconnect && isOpenAIWSSuccessTerminalEvent(result.UpstreamTerminalEvent)) { turnState = bridgeTurnState bridgeOwnedTurnState.value = bridgeTurnState } diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index a61d373f1042..56c55ffd49f9 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -153,9 +153,6 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } if account != nil && account.UsesOpenAICodexProtocol() { - if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { - return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err) - } headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } @@ -179,21 +176,15 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { headers.Set("user-agent", CodexCanonicalUserAgent()) } - // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 - // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 - if account != nil && account.UsesOpenAICodexProtocol() { - if err := enforceCodexIdentityHeadersForAccountContext(ctx, headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil { - return nil, sessionResolution, err - } - } - // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。 // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。 account.ApplyHeaderOverrides(headers) // turn-state is minted under the outbound account identity. A value known // to come from another account must not survive an account failover. s.guardOpenAICodexTurnStateEcho(c, account, headers) - setOpenAICodexRoutingHint(headers, account, routingModel, routingServiceTier) + if err := s.finalizeCodexOutboundHeaders(ctx, c, account, headers, routingModel, routingServiceTier); err != nil { + return nil, sessionResolution, err + } logOpenAIRoutingDiagnostics( ctx, account, diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 0acacd540629..7c681020a6fd 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -64,7 +64,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( } if c != nil { if _, staged := c.Get(codexRoutingTurnContextKey); !staged { - stageCodexRoutingTurn(c, codexWSMetadataBody(reqBody)) + stageCodexRoutingWSTurn(c, codexWSMetadataBody(reqBody)) } } refusalRuntime := s.openAIRefusalRecoveryRuntime(ctx) diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 1d3be673cc81..3328223977d9 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -35,6 +35,8 @@ type openAIWSHTTPBridgeToolState struct { // upstream response. It never enters the shared native-WS session state store. type openAIWSHTTPBridgeTurnState struct { accountID int64 + identity string + turnID string value string } @@ -566,7 +568,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( applyBridgeOwnedTurnState := func(req *http.Request) { if account.Platform != PlatformGrok && len(bridgeStates) > 0 { state := bridgeStates[0] - if state.accountID > 0 && state.accountID == account.ID && strings.TrimSpace(state.value) != "" { + ownerMatches := state.accountID > 0 && state.accountID == account.ID + if account.IsOpenAIOAuthLike() { + ownerMatches = ownerMatches && state.identity == CodexTicketAccountIdentity(account) && + state.turnID != "" && state.turnID == codexRoutingTurnID(c) + } + if ownerMatches && strings.TrimSpace(state.value) != "" { // The general HTTP builder strips unproven client-supplied state. // This value is independently proven by the owning bridge, so carry // it without publishing shared provenance for other connections. @@ -735,6 +742,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( UpstreamTerminalEvent: upstreamTerminalEvent, UpstreamTerminalStatus: upstreamTerminalStatus, ResponseHeaders: cloneHeader(resp.Header), + ClientDisconnect: clientDisconnected, Duration: time.Since(turnStart), FirstTokenMs: firstTokenMs, } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index da6659206a0f..caee9b179a1a 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -511,7 +511,7 @@ func TestOpenAIWSHTTPBridgeFullCustomToolHistoryWithoutPreviousResponseIDDoesNot gin.SetMode(gin.TestMode) completed := func(responseID string, output string) string { - return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.1\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" } upstream := &httpUpstreamRecorder{responses: []*http.Response{ {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_1", `[{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}]`)))}, @@ -594,6 +594,9 @@ func TestOpenAIWSHTTPBridgeFullCustomToolHistoryWithoutPreviousResponseIDDoesNot } require.Len(t, upstream.bodies, 4) + for _, body := range upstream.bodies { + require.Equal(t, "gpt-5.4", gjson.GetBytes(body, "model").String(), "the upstream fixture must match the resolved model") + } orphanInput := gjson.GetBytes(upstream.bodies[1], "input").Array() require.Len(t, orphanInput, 3, "an unfinished call is retained until the upstream validates it") require.Equal(t, "run pwd", orphanInput[0].String()) @@ -614,7 +617,7 @@ func TestOpenAIWSHTTPBridgeObjectToolOutputWithoutPreviousResponseIDReplaysMatch gin.SetMode(gin.TestMode) completed := func(responseID string, output string) string { - return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.1\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" } upstream := &httpUpstreamRecorder{responses: []*http.Response{ {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_1", `[{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}]`)))}, @@ -692,6 +695,9 @@ func TestOpenAIWSHTTPBridgeObjectToolOutputWithoutPreviousResponseIDReplaysMatch } require.Len(t, upstream.bodies, 2) + for _, body := range upstream.bodies { + require.Equal(t, "gpt-5.4", gjson.GetBytes(body, "model").String(), "the upstream fixture must match the resolved model") + } secondInput := gjson.GetBytes(upstream.bodies[1], "input").Array() require.Len(t, secondInput, 3) require.Equal(t, "custom_tool_call", secondInput[1].Get("type").String()) diff --git a/backend/pkg/extensionapi/v1/codex_routing.go b/backend/pkg/extensionapi/v1/codex_routing.go index 60a87b3e7e16..d41861eb7754 100644 --- a/backend/pkg/extensionapi/v1/codex_routing.go +++ b/backend/pkg/extensionapi/v1/codex_routing.go @@ -39,31 +39,33 @@ type CodexRoutingBundleRef struct { } type CodexRoutingQuery struct { - AccountID int64 `json:"account_id"` - Model string `json:"model,omitempty"` - Transport string `json:"transport,omitempty"` - Stage string `json:"stage,omitempty"` // acquire or verify - OperationID string `json:"operation_id,omitempty"` - Bundle *CodexRoutingBundleRef `json:"bundle,omitempty"` - Scope *CodexRoutingScope `json:"scope,omitempty"` + AccountID int64 `json:"account_id"` + Model string `json:"model,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Transport string `json:"transport,omitempty"` + Stage string `json:"stage,omitempty"` // acquire or verify + OperationID string `json:"operation_id,omitempty"` + Bundle *CodexRoutingBundleRef `json:"bundle,omitempty"` + Scope *CodexRoutingScope `json:"scope,omitempty"` } // All observation fields are safe for an authenticated read-only account view. // Cookie and STATE values, tokens, proxy URLs and response text are excluded. type CodexRoutingObservation struct { - Stage string `json:"stage"` - Code string `json:"code"` - HTTPStatus int `json:"http_status,omitempty"` - RequestedModel string `json:"requested_model,omitempty"` - ResponseModel string `json:"response_model,omitempty"` - Completed bool `json:"completed"` - ModelMatched bool `json:"model_matched"` - StateLength int `json:"state_length,omitempty"` - CookieNames []string `json:"cookie_names,omitempty"` - ObservedAt time.Time `json:"observed_at"` - DurationMS int64 `json:"duration_ms,omitempty"` - Transport string `json:"transport,omitempty"` - CookieSent bool `json:"cookie_sent"` + Stage string `json:"stage"` + Code string `json:"code"` + HTTPStatus int `json:"http_status,omitempty"` + RequestedModel string `json:"requested_model,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ResponseModel string `json:"response_model,omitempty"` + Completed bool `json:"completed"` + ModelMatched bool `json:"model_matched"` + StateLength int `json:"state_length,omitempty"` + CookieNames []string `json:"cookie_names,omitempty"` + ObservedAt time.Time `json:"observed_at"` + DurationMS int64 `json:"duration_ms,omitempty"` + Transport string `json:"transport,omitempty"` + CookieSent bool `json:"cookie_sent"` } type CodexRoutingProbeResult struct { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 7fde3adb0072..8f207931c1dc 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -335,22 +335,17 @@ gateway: # Use this to avoid compact failures when newer models are not yet supported by the compact endpoint. # 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。 openai_compact_model: "gpt-5.5" - # ChatGPT OAuth x-codex-turn-state ticket: harvest a 292-length header via a - # dedicated HTTP(S)/SOCKS proxy, store it for ttl_seconds (default 3600), then inject - # it on residential-IP production requests. Off by default. - # ChatGPT OAuth 门票:用独立 HTTP(S)/SOCKS 代理打到 292 长度的 x-codex-turn-state,默认缓存 1 小时, - # 再在住宅 IP 业务请求中覆盖该头。默认关闭。 + # Legacy bootstrap settings for the Codex routing plugin. Off by default. + # Runtime configuration lives in the plugin. A route requires same-account, + # same-model business-egress verification; STATE length is not qualification + # or quality evidence. Legacy target_length/ttl_seconds/refresh_before_seconds + # are accepted for old configuration files but ignored by current routing. # Env: GATEWAY_OPENAI_CODEX_TICKET_ENABLED=true # GATEWAY_OPENAI_CODEX_TICKET_HARVEST_PROXY_URL=socks5h://user:pass@host:port - # Admin UI 开关(系统设置 → 网关 → Codex 设置 → 292 打票)可覆盖 enabled,无需重启。 - # Off: do not harvest or inject. On: harvest in the background and inject on production requests. - # The harvest proxy should rotate egress IPs itself (fresh CONNECT per request). - # Credential shadow accounts retain their existing forwarding policy and are not ticket-gated. + # Admin UI: Codex 路由采集与验证. Existing routing records renew on demand. + # Credential shadow accounts retain their existing forwarding policy. openai_codex_ticket: enabled: false - target_length: 292 - ttl_seconds: 3600 - refresh_before_seconds: 600 # 后台「系统设置 → 网关 → Codex 设置」可覆盖此值,无需重启。 # 后台值的读取缓存为 5 秒,下个探测周期使用新代理;代理服务商自行负责轮换 IP。 harvest_proxy_url: "" diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 74b0f87e77e7..7dcb8905e934 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2499,7 +2499,7 @@ - +
{ }) describe('plan_type helpers', () => { + it('preserves Business workspace identifiers when presenting plan options', () => { + for (const [plan, label] of [['business', 'Business'], ['self_serve_business_usage_based', 'Business (usage-based)']]) { + expect(planTypeDisplayLabel(plan)).toBe(label) + expect(buildPlanTypeOptions(plan, 'Clear').find(option => option.value === plan)).toEqual({ value: plan, label }) + expect(applyPlanType({ chatgpt_account_id: 'selected-team' }, plan)).toEqual({ chatgpt_account_id: 'selected-team', plan_type: plan }) + } + }) + describe('planTypeDisplayLabel', () => { it('maps canonical + alias values to friendly labels', () => { expect(planTypeDisplayLabel('plus')).toBe('Plus') diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index c3e6dbff8007..8f04fa7dceeb 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -69,7 +69,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { AccountPlatform, AccountType } from '@/types' import { platformLabel as sharedPlatformLabel } from '@/utils/platformColors' -import { normalizePlanType, openAIPlanTypeLabel } from '@/utils/planType' +import { isOpenAIBusinessPlanType, normalizePlanType, openAIPlanTypeLabel } from '@/utils/planType' import GrokFreeIcon from './GrokFreeIcon.vue' import PlatformIcon from './PlatformIcon.vue' import Icon from '@/components/icons/Icon.vue' @@ -265,7 +265,8 @@ const planBadgeClass = computed(() => { if (normalizedPlanType.value === 'plus') { return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300' } - if (normalizedPlanType.value === 'team' || normalizedPlanType.value === 'selfservebusinessprolite') { + if (normalizedPlanType.value === 'team' || normalizedPlanType.value === 'selfservebusinessprolite' || + (props.platform === 'openai' && isOpenAIBusinessPlanType(props.planType))) { return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300' } if ( diff --git a/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts b/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts index e8ce24ecd58b..f8158b565acd 100644 --- a/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts +++ b/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts @@ -55,6 +55,16 @@ describe('PlatformTypeBadge ChatGPT plan tiers', () => { } }) + it('shows generic and usage-based Business plans without inventing a personal Pro tier', () => { + for (const [planType, label] of [['business', 'Business'], ['self_serve_business_usage_based', 'Business (usage-based)']]) { + const wrapper = mountPlan('openai', planType) + expect(wrapper.text()).toContain(label) + expect(wrapper.html()).toContain('bg-indigo-100') + expect(wrapper.text()).not.toContain('Pro 20x') + expect(wrapper.text()).not.toContain('Pro 5x') + } + }) + it('keeps plus, free and abnormal labels unchanged', () => { const plus = mountPlan('openai', 'plus') expect(plus.text()).toContain('Plus') diff --git a/frontend/src/i18n/locales/en/admin/accountTasks.ts b/frontend/src/i18n/locales/en/admin/accountTasks.ts index 4c010ff963de..6bffb0f1bf1a 100644 --- a/frontend/src/i18n/locales/en/admin/accountTasks.ts +++ b/frontend/src/i18n/locales/en/admin/accountTasks.ts @@ -19,7 +19,7 @@ export default { nextOperation: "Next operation", selectionChanged: "The selected accounts have changed or include unsupported accounts. Select them again.", confirmAction: "This operation will affect {count} accounts.", - accountUnchanged: "Ticket harvesting does not change account status or business scheduling.", + accountUnchanged: "Route acquisition preserves account status and the scheduling switch; route validation still controls model eligibility.", historyAction: "Operation history", title: 'Operation history', description: 'Track account imports and bulk operations.', @@ -51,7 +51,7 @@ export default { }, kinds: { extension_operation: "Plugin operation", - codex_ticket_harvest: 'Manual 292 harvest', + codex_ticket_harvest: 'Codex route acquisition and verification', account_batch_test: 'Batch connection tests', account_import: 'Data import', account_import_codex: 'Codex import', diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 651999ca529b..37626cd954cc 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -16,15 +16,15 @@ export default {accounts: { textTestPrompt: { label: 'Text test prompt', hint: 'Blank uses hi. Remembered only for this site and administrator in this browser.', reset: 'Restore default', tooLong: 'Prompt exceeds 8192 characters. Shorten it to continue.' }, tickets: { - stages: { request: 'Request', configuration: 'Configuration', lifecycle: 'Task state', account_auth: 'Account authentication', proxy_auth: 'Proxy authentication', proxy_protocol: 'Proxy protocol', proxy_dns: 'Proxy DNS', proxy_connect: 'Proxy connection', tls: 'TLS certificate', ticket_validation: 'Ticket validation', upstream_http: 'Upstream HTTP', persistence: 'Persistence' }, - title: 'Cookie routing / renewal', description: 'Each account/model cycle has at most one acquisition and one business-route verification. Valid qualifications are skipped. Cookies live at most 120 seconds; demand renewal starts 20 seconds early and stops after two failed cycles.', - disabled: 'The ticket switch is off.', force: 'Harvest again even with a valid ticket', ineligible: 'Not eligible: requires a non-shadow OpenAI OAuth or Setup Token account.', - valid: 'Valid', stop: 'Stop automatic renewal', start: 'Start harvesting', limit: 'Select up to 100 accounts.', loadFailed: 'Could not load accounts or ticket configuration.', - submitFailed: 'Operation failed. Check the current status before retrying.', next: 'Next renewal', last: 'Last attempt', length: 'Ticket length', expires: 'Expires', - states: { idle: 'Awaiting manual harvest', ready: 'Renewal scheduled', retry: 'Waiting for retry after expiry', stopped: 'Automatic renewal stopped', manual_running: 'Manual harvest running', pre_running: 'Renewing before expiry', post_running: 'Retrying after expiry' } + stages: { request: 'Request', configuration: 'Configuration', lifecycle: 'Task state', account_auth: 'Account authentication', proxy_auth: 'Proxy authentication', proxy_protocol: 'Proxy protocol', proxy_dns: 'Proxy DNS', proxy_connect: 'Proxy connection', tls: 'TLS certificate', ticket_validation: 'Legacy STATE validation', upstream_http: 'Upstream HTTP', upstream_stream: 'Upstream stream', upstream_policy: 'Upstream policy', persistence: 'Persistence' }, + title: 'Codex route acquisition and verification', description: 'Each account/model cycle has at most one acquisition and one business-route verification. Valid routes are skipped. Routing records last at most 120 seconds locally and renew on demand; this is not an upstream guarantee. Material validity, response completion and matching model declarations are separate evidence. Quality is not tested here; STATE length is an observation only.', + disabled: 'Route acquisition and verification is off.', force: 'Acquire and verify again even with a valid route', ineligible: 'Not eligible: requires a non-shadow OpenAI OAuth or Setup Token account.', + valid: 'Routing record valid; quality unverified', stop: 'Stop automatic renewal', start: 'Acquire and verify', limit: 'Select up to 100 accounts.', loadFailed: 'Could not load accounts or route configuration.', + submitFailed: 'Operation failed. Check the current status before retrying.', next: 'Next renewal', last: 'Last attempt', length: 'STATE length (observation only)', expires: 'Routing record expires', + states: { idle: 'Awaiting manual acquisition', ready: 'Routing record valid; awaiting renewal', retry: 'Waiting for route retry', stopped: 'Automatic renewal stopped', manual_running: 'Acquiring and verifying', pre_running: 'Verifying route renewal', post_running: 'Retrying route verification', needs_cookie_verification: 'Awaiting business-route verification' } }, ticketProxy: { - address: 'Full harvest proxy address', protocol: 'Protocol', test: 'Test connection', clear: 'Clear proxy and disable tickets', hint: 'Blur only parses the input. Testing uses the draft without saving, OAuth credentials or model requests. Full proxy credentials are visible to administrators only.', + address: 'Full harvest proxy address', protocol: 'Protocol', test: 'Test connection', clear: 'Clear proxy and disable route acquisition', hint: 'Blur only parses the input. Testing uses the draft without saving, OAuth credentials or model requests. Full proxy credentials are visible to administrators only.', trust: 'Certificate verification', copyFailed: 'Could not copy. Copy the address manually.', testFailed: 'Proxy test did not complete. Try again.', stages: { parse: 'Input parsing', dns: 'Proxy DNS', tcp: 'Proxy TCP', connect: 'CONNECT', tls: 'TLS', http: 'ChatGPT response', protocol: 'Protocol handshake', auth: 'Proxy authentication', pinned_connection: 'Reconnect using pinned trust' }, errors: { too_long: 'Proxy input is too long.', unknown_field: 'Unknown field. Use host, port, username and password labels.', duplicate_field: 'Duplicate field. Check the unchanged input.', incomplete: 'Host, port or credentials are incomplete.', ambiguous: 'Ambiguous fields. Use labeled lines.', invalid_url: 'Invalid or unsupported proxy URL.', invalid_port: 'Enter a port between 1 and 65535.' } @@ -1040,11 +1040,11 @@ export default {accounts: { codexFingerprintDevice: 'Device only', codexFingerprintSession: 'Device + Session', codexFingerprintFull: 'Full convergence', - codexTurnTicket: 'Codex Cookie route', - codexTurnTicketDesc: 'Ticket status for the configured models. Requests are paused without a valid ticket only when fail-closed is enabled.', - codexTurnTicketMissing: 'No valid ticket; requests remain allowed', - codexTurnTicketReady: '{time} left', - codexTurnTicketPaused: 'No 292 ticket; this model is paused', + codexTurnTicket: 'Codex route acquisition and verification', + codexTurnTicketDesc: 'Valid routing material, a complete response and a matching model declaration are separate observations. STATE length proves neither plan nor model quality; quality is not tested here. Models without a valid route are paused only when fail-closed is enabled.', + codexTurnTicketMissing: 'No valid route; requests remain allowed', + codexTurnTicketReady: 'Route verified, {time} left; quality unverified', + codexTurnTicketPaused: 'No valid route; this model is paused', codexImageTool: 'Codex image bridge policy', codexImageToolDesc: 'Controls the hosted image_generation bridge and client-declared image tools on Codex /responses text requests. Hosted auto-injection applies only to non-Responses Lite requests. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 4ddefada2a64..1ba4fd66752d 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -594,10 +594,10 @@ export default { claudeCodeVersionAutoSyncHint: 'Fetches the latest Claude Code client version from the official release channel every hour, so you never need to upgrade this service just to keep the version current. When disabled, fetching stops but the previously synced version remains available. The manual version above always takes priority.', claudeCodeVersionSyncedValue: 'Currently synced: {version}', codexHardeningTitle: "Codex Settings", - codexTicketEnabled: "292 ticket harvest", + codexTicketEnabled: "Codex route acquisition and verification", codexTicketEnabledDesc: - "When off, no tickets are harvested or injected. When on, manual harvesting is available. Only successful account/model pairs renew with a limited retry; missing tickets still block the corresponding models.", - codexTicketHarvestProxy: "292 harvest proxy", + "Enables account/model-specific material acquisition and business-route verification, with bounded renewal for enrolled routes. STATE length does not determine plan or model quality. Quality is not tested here.", + codexTicketHarvestProxy: "Codex route acquisition proxy", codexTicketHarvestProxyDesc: "Used only for harvesting. Saved changes apply in about 5 seconds without restart. Supports HTTP(S), SOCKS5(H), and labeled fields in any order. Business traffic uses account proxies. Use Clear and save to remove the proxy.", codexTicketHarvestProxyPlaceholder: "http://user:pass{'@'}proxy.example.com:1080", diff --git a/frontend/src/i18n/locales/zh/admin/accountTasks.ts b/frontend/src/i18n/locales/zh/admin/accountTasks.ts index f9c623c380d0..40712d0667b2 100644 --- a/frontend/src/i18n/locales/zh/admin/accountTasks.ts +++ b/frontend/src/i18n/locales/zh/admin/accountTasks.ts @@ -19,7 +19,7 @@ export default { nextOperation: "下一个操作", selectionChanged: "选中账号已变化或包含不适用账号,请重新选择。", confirmAction: "即将对 {count} 个账号执行此操作。", - accountUnchanged: "打票不改变账号状态或业务调度设置。", + accountUnchanged: "路由采集不修改账号状态或调度开关;模型是否可调度仍受路由验证结果约束。", historyAction: "操作记录", title: '操作记录', description: '查看近期账号操作及其执行结果。', @@ -51,7 +51,7 @@ export default { }, kinds: { extension_operation: "插件操作", - codex_ticket_harvest: '手动打292', + codex_ticket_harvest: 'Codex 路由采集与验证', account_batch_test: '批量测试连接', account_import: '数据导入', account_import_codex: 'Codex 导入', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 813898398e9a..081a7b90f68f 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -16,15 +16,15 @@ export default {accounts: { textTestPrompt: { label: '文本测试提示词', hint: '留空使用 hi;仅当前站点和管理员浏览器记忆。', reset: '恢复默认', tooLong: '提示词超过8192字符,请缩短后重试。' }, tickets: { - stages: { request: '请求', configuration: '配置检查', lifecycle: '任务状态', account_auth: '账号鉴权', proxy_auth: '代理鉴权', proxy_protocol: '代理协议', proxy_dns: '代理DNS', proxy_connect: '代理连接', tls: 'TLS证书', ticket_validation: '票据校验', upstream_http: '上游HTTP', persistence: '持久化' }, - title: 'Cookie 路由 / 管理续获', description: '每个账号和模型每周期最多一次采集和一次业务出口复验;有效资格默认跳过。Cookie 最长120秒,按需提前20秒续获,连续失败两周期停止。', - disabled: '票据总开关未开启。', force: '重新打票(已有有效票据也执行一次)', ineligible: '不适用:仅 OpenAI OAuth/Setup Token 非影子账号。', - valid: '有效', stop: '停止自动续期', start: '采集并复验', limit: '一次最多选择100个账号。', loadFailed: '无法读取账号或票据配置。', - submitFailed: '操作失败,请查看状态后重试。', next: '下次续期', last: '最近尝试', length: '票据长度', expires: '到期时间', - states: { idle: '待手动打票', ready: '自动续期待命', retry: '等待到期后重试', stopped: '自动续期已停止', manual_running: '手动打票中', pre_running: '到期前续期中', post_running: '到期后重试中' } + stages: { request: '请求', configuration: '配置检查', lifecycle: '任务状态', account_auth: '账号鉴权', proxy_auth: '代理鉴权', proxy_protocol: '代理协议', proxy_dns: '代理DNS', proxy_connect: '代理连接', tls: 'TLS证书', ticket_validation: '历史 STATE 校验', upstream_http: '上游HTTP', upstream_stream: '上游响应流', upstream_policy: '上游策略', persistence: '持久化' }, + title: 'Codex 路由采集与验证', description: '每个账号和模型每周期最多一次采集和一次业务出口复验;有效资格默认跳过。路由记录最多保留120秒并按需续期,此本地上限不代表上游承诺。材料有效、响应完整、模型声明一致分别记录;本项未验证质量,STATE 长度仅作观测。', + disabled: '路由采集与验证未开启。', force: '重新采集并验证(已有有效路由也执行一次)', ineligible: '不适用:仅 OpenAI OAuth/Setup Token 非影子账号。', + valid: '路由记录有效,质量未验证', stop: '停止自动续期', start: '采集并验证', limit: '一次最多选择100个账号。', loadFailed: '无法读取账号或路由配置。', + submitFailed: '操作失败,请查看状态后重试。', next: '下次续期', last: '最近尝试', length: 'STATE 长度(仅观测)', expires: '路由记录到期时间', + states: { idle: '待手动采集', ready: '路由记录有效,待续期', retry: '等待路由重试', stopped: '自动续期已停止', manual_running: '手动采集验证中', pre_running: '路由续期验证中', post_running: '路由重试中', needs_cookie_verification: '待业务出口验证' } }, ticketProxy: { - address: '打票代理完整地址', protocol: '协议', test: '测试连接', clear: '清除代理并关闭开关', hint: '失焦仅解析格式;测试使用草稿,不保存配置,不发送OAuth凭据或模型请求。完整账密仅管理员可见。', + address: '路由采集代理完整地址', protocol: '协议', test: '测试连接', clear: '清除代理并关闭开关', hint: '失焦仅解析格式;测试使用草稿,不保存配置,不发送OAuth凭据或模型请求。完整账密仅管理员可见。', trust: '证书验证', copyFailed: '复制失败,请手动复制。', testFailed: '代理测试未完成,请重试。', stages: { parse: '输入解析', dns: '代理DNS', tcp: '代理TCP', connect: 'CONNECT', tls: 'TLS', http: 'ChatGPT响应', protocol: '协议握手', auth: '代理鉴权', pinned_connection: '固定证书后的再次连接' }, errors: { too_long: '代理输入过长。', unknown_field: '无法识别字段,请使用主机、端口、用户名、密码标签。', duplicate_field: '存在重复字段,保留原输入,请检查。', incomplete: '主机、端口或账密不完整。', ambiguous: '字段存在歧义,请用带标签的分行格式。', invalid_url: '代理URL无法识别或包含不支持的内容。', invalid_port: '请填写1至65535之间的端口。' } @@ -1153,11 +1153,11 @@ export default {accounts: { codexFingerprintDevice: '仅设备', codexFingerprintSession: '设备+会话', codexFingerprintFull: '完全收敛', - codexTurnTicket: 'Codex Cookie 路由', - codexTurnTicketDesc: '显示已配置模型的门票状态。仅在启用缺票拦截时,没有有效门票的模型才会暂停调度。', - codexTurnTicketMissing: '暂无有效门票,仍允许请求', - codexTurnTicketReady: '剩余 {time}', - codexTurnTicketPaused: '该模型尚无有效 Cookie 路由资格', + codexTurnTicket: 'Codex 路由采集与验证', + codexTurnTicketDesc: '路由材料有效、响应完整和模型声明一致分别表示不同证据。STATE 长度仅作观测,不能证明套餐或模型质量;本项不包含质量验证。仅在开启拦截时,没有有效路由资格的模型才暂停调度。', + codexTurnTicketMissing: '暂无有效路由资格,仍允许请求', + codexTurnTicketReady: '路由已验证,剩余 {time};质量未验证', + codexTurnTicketPaused: '暂无有效路由资格,该模型已暂停调度', codexImageTool: 'Codex 图片桥接策略', codexImageToolDesc: '统一控制 Codex /responses 文本请求的 hosted image_generation 桥接和客户端图片工具声明。hosted 工具自动注入仅适用于非 Responses Lite 请求;账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 0216d17591a7..f929df7c90f5 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -587,12 +587,12 @@ export default { claudeCodeVersionAutoSyncHint: '每小时从官方发布渠道获取最新版本的 Claude Code 客户端版本号,无需为了跟版本而升级本服务。关闭后停止获取新版本,已同步的版本仍可使用;上方手填版本始终优先。', claudeCodeVersionSyncedValue: '当前同步到:{version}', codexHardeningTitle: 'Codex 设置', - codexTicketEnabled: '292 打票', + codexTicketEnabled: 'Codex 路由采集与验证', codexTicketEnabledDesc: - '关闭后不打票、不注入票据。开启后可手动打292;仅成功的账号和模型有限续期,缺票模型仍暂停调度。', - codexTicketHarvestProxy: '292 打票代理', + '开启后可按账号和模型采集路由材料并验证业务出口;已登记的路由按需有限续期。STATE 长度不用于判断套餐或模型质量;本项不包含质量验证。', + codexTicketHarvestProxy: 'Codex 路由采集代理', codexTicketHarvestProxyDesc: - '仅用于打票,保存后约5秒生效,无需重启。支持HTTP(S)、SOCKS5(H)和带标签的乱序账密格式。业务请求使用账号代理。清除代理需点击清除并保存。', + '仅用于采集路由材料,保存后约5秒生效,无需重启。支持HTTP(S)、SOCKS5(H)和带标签的乱序账密格式。业务请求使用账号代理。清除代理需点击清除并保存。', codexTicketHarvestProxyPlaceholder: "http://user:pass{'@'}proxy.example.com:1080", codexTicketHarvestProxyConfigured: '已配置。管理员可查看和复制完整账密。', codexClientRestrictionTitle: 'Codex 客户端限制', diff --git a/frontend/src/utils/planType.ts b/frontend/src/utils/planType.ts index c1514763aa4c..131f4322592b 100644 --- a/frontend/src/utils/planType.ts +++ b/frontend/src/utils/planType.ts @@ -35,9 +35,17 @@ export function openAIPlanTypeLabel(value?: string | null): string { return 'Business Premium' case 'team': return 'Business Standard' + case 'business': + return 'Business' + case 'selfservebusinessusagebased': + return 'Business (usage-based)' case 'free': return 'Free' default: return '' } } + +export function isOpenAIBusinessPlanType(value?: string | null): boolean { + return ['team', 'business', 'selfservebusinessprolite', 'selfservebusinessusagebased'].includes(normalizePlanType(value)) +} diff --git a/plugins/account-tools/manifest.source.json b/plugins/account-tools/manifest.source.json index c08ccd611633..4d551244a258 100644 --- a/plugins/account-tools/manifest.source.json +++ b/plugins/account-tools/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.account-tools", "name": "账号工具", - "version": "0.2.12", + "version": "0.2.13", "description": "账号分类操作策略与连接测试选项", "author": "HTExplicit", "requires": { "sub2api": ">=0.2.7-codexrip.2 <0.2.9", "tested_sub2api_versions": [], "plugin_protocol": 1, "transport_api": 1, "ui_bridge": 1, "extension_api": 1 }, diff --git a/plugins/account-tools/ui/src/locales/en-accounts.ts b/plugins/account-tools/ui/src/locales/en-accounts.ts index f2e55c9ca1fb..620b9fe92ccc 100644 --- a/plugins/account-tools/ui/src/locales/en-accounts.ts +++ b/plugins/account-tools/ui/src/locales/en-accounts.ts @@ -2,15 +2,15 @@ export default {accounts: { textTestPrompt: { label: 'Text test prompt', hint: 'Blank uses hi. Remembered only for this site and administrator in this browser.', reset: 'Restore default', tooLong: 'Prompt exceeds 8192 characters. Shorten it to continue.' }, tickets: { - stages: { request: 'Request', configuration: 'Configuration', lifecycle: 'Task state', account_auth: 'Account authentication', proxy_auth: 'Proxy authentication', proxy_protocol: 'Proxy protocol', proxy_dns: 'Proxy DNS', proxy_connect: 'Proxy connection', tls: 'TLS certificate', ticket_validation: 'Ticket validation', upstream_http: 'Upstream HTTP', persistence: 'Persistence' }, - title: 'Cookie routing / renewal', description: 'Each account/model cycle has at most one acquisition and one business-route verification. Valid qualifications are skipped. Cookies live at most 120 seconds; demand renewal starts 20 seconds early and stops after two failed cycles.', - disabled: 'The ticket switch is off.', force: 'Harvest again even with a valid ticket', ineligible: 'Not eligible: requires a non-shadow OpenAI OAuth or Setup Token account.', - valid: 'Valid', stop: 'Stop automatic renewal', start: 'Start harvesting', limit: 'Select up to 100 accounts.', loadFailed: 'Could not load accounts or ticket configuration.', - submitFailed: 'Operation failed. Check the current status before retrying.', next: 'Next renewal', last: 'Last attempt', length: 'Ticket length', expires: 'Expires', - states: { idle: 'Awaiting manual harvest', ready: 'Renewal scheduled', retry: 'Waiting for retry after expiry', stopped: 'Automatic renewal stopped', manual_running: 'Manual harvest running', pre_running: 'Renewing before expiry', post_running: 'Retrying after expiry' } + stages: { request: 'Request', configuration: 'Configuration', lifecycle: 'Task state', account_auth: 'Account authentication', proxy_auth: 'Proxy authentication', proxy_protocol: 'Proxy protocol', proxy_dns: 'Proxy DNS', proxy_connect: 'Proxy connection', tls: 'TLS certificate', ticket_validation: 'Legacy STATE validation', upstream_http: 'Upstream HTTP', upstream_stream: 'Upstream stream', upstream_policy: 'Upstream policy', persistence: 'Persistence' }, + title: 'Codex route acquisition and verification', description: 'Each account/model cycle has at most one acquisition and one business-route verification. Valid routes are skipped. Routing records last at most 120 seconds locally and renew on demand; this is not an upstream guarantee. Material validity, response completion and matching model declarations are separate evidence. Quality is not tested here; STATE length is an observation only.', + disabled: 'Route acquisition and verification is off.', force: 'Acquire and verify again even with a valid route', ineligible: 'Not eligible: requires a non-shadow OpenAI OAuth or Setup Token account.', + valid: 'Routing record valid; quality unverified', stop: 'Stop automatic renewal', start: 'Acquire and verify', limit: 'Select up to 100 accounts.', loadFailed: 'Could not load accounts or route configuration.', + submitFailed: 'Operation failed. Check the current status before retrying.', next: 'Next renewal', last: 'Last attempt', length: 'STATE length (observation only)', expires: 'Routing record expires', + states: { idle: 'Awaiting manual acquisition', ready: 'Routing record valid; awaiting renewal', retry: 'Waiting for route retry', stopped: 'Automatic renewal stopped', manual_running: 'Acquiring and verifying', pre_running: 'Verifying route renewal', post_running: 'Retrying route verification', needs_cookie_verification: 'Awaiting business-route verification' } }, ticketProxy: { - address: 'Full harvest proxy address', protocol: 'Protocol', test: 'Test connection', clear: 'Clear proxy and disable tickets', hint: 'Blur only parses the input. Testing uses the draft without saving, OAuth credentials or model requests. Full proxy credentials are visible to administrators only.', + address: 'Full harvest proxy address', protocol: 'Protocol', test: 'Test connection', clear: 'Clear proxy and disable route acquisition', hint: 'Blur only parses the input. Testing uses the draft without saving, OAuth credentials or model requests. Full proxy credentials are visible to administrators only.', trust: 'Certificate verification', copyFailed: 'Could not copy. Copy the address manually.', testFailed: 'Proxy test did not complete. Try again.', stages: { parse: 'Input parsing', dns: 'Proxy DNS', tcp: 'Proxy TCP', connect: 'CONNECT', tls: 'TLS', http: 'ChatGPT response', protocol: 'Protocol handshake', auth: 'Proxy authentication', pinned_connection: 'Reconnect using pinned trust' }, errors: { too_long: 'Proxy input is too long.', unknown_field: 'Unknown field. Use host, port, username and password labels.', duplicate_field: 'Duplicate field. Check the unchanged input.', incomplete: 'Host, port or credentials are incomplete.', ambiguous: 'Ambiguous fields. Use labeled lines.', invalid_url: 'Invalid or unsupported proxy URL.', invalid_port: 'Enter a port between 1 and 65535.' } @@ -1001,11 +1001,11 @@ export default {accounts: { codexFingerprintDevice: 'Device only', codexFingerprintSession: 'Device + Session', codexFingerprintFull: 'Full convergence', - codexTurnTicket: 'Codex Cookie route', - codexTurnTicketDesc: 'Ticket status for the configured models. Requests are paused without a valid ticket only when fail-closed is enabled.', - codexTurnTicketMissing: 'No valid ticket; requests remain allowed', - codexTurnTicketReady: '{time} left', - codexTurnTicketPaused: 'No 292 ticket; this model is paused', + codexTurnTicket: 'Codex route acquisition and verification', + codexTurnTicketDesc: 'Valid routing material, a complete response and a matching model declaration are separate observations. STATE length proves neither plan nor model quality; quality is not tested here. Models without a valid route are paused only when fail-closed is enabled.', + codexTurnTicketMissing: 'No valid route; requests remain allowed', + codexTurnTicketReady: 'Route verified, {time} left; quality unverified', + codexTurnTicketPaused: 'No valid route; this model is paused', codexImageTool: 'Codex image bridge policy', codexImageToolDesc: 'Controls the hosted image_generation bridge and client-declared image tools on Codex /responses text requests. Hosted auto-injection applies only to non-Responses Lite requests. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.', diff --git a/plugins/account-tools/ui/src/locales/zh-accounts.ts b/plugins/account-tools/ui/src/locales/zh-accounts.ts index ef04765ff1c7..add7d5c5e292 100644 --- a/plugins/account-tools/ui/src/locales/zh-accounts.ts +++ b/plugins/account-tools/ui/src/locales/zh-accounts.ts @@ -2,15 +2,15 @@ export default {accounts: { textTestPrompt: { label: '文本测试提示词', hint: '留空使用 hi;仅当前站点和管理员浏览器记忆。', reset: '恢复默认', tooLong: '提示词超过8192字符,请缩短后重试。' }, tickets: { - stages: { request: '请求', configuration: '配置检查', lifecycle: '任务状态', account_auth: '账号鉴权', proxy_auth: '代理鉴权', proxy_protocol: '代理协议', proxy_dns: '代理DNS', proxy_connect: '代理连接', tls: 'TLS证书', ticket_validation: '票据校验', upstream_http: '上游HTTP', persistence: '持久化' }, - title: 'Cookie 路由 / 管理续获', description: '每个账号和模型每周期最多一次采集和一次业务出口复验;有效资格默认跳过。Cookie 最长120秒,按需提前20秒续获,连续失败两周期停止。', - disabled: '票据总开关未开启。', force: '重新打票(已有有效票据也执行一次)', ineligible: '不适用:仅 OpenAI OAuth/Setup Token 非影子账号。', - valid: '有效', stop: '停止自动续期', start: '采集并复验', limit: '一次最多选择100个账号。', loadFailed: '无法读取账号或票据配置。', - submitFailed: '操作失败,请查看状态后重试。', next: '下次续期', last: '最近尝试', length: '票据长度', expires: '到期时间', - states: { idle: '待手动打票', ready: '自动续期待命', retry: '等待到期后重试', stopped: '自动续期已停止', manual_running: '手动打票中', pre_running: '到期前续期中', post_running: '到期后重试中' } + stages: { request: '请求', configuration: '配置检查', lifecycle: '任务状态', account_auth: '账号鉴权', proxy_auth: '代理鉴权', proxy_protocol: '代理协议', proxy_dns: '代理DNS', proxy_connect: '代理连接', tls: 'TLS证书', ticket_validation: '历史 STATE 校验', upstream_http: '上游HTTP', upstream_stream: '上游响应流', upstream_policy: '上游策略', persistence: '持久化' }, + title: 'Codex 路由采集与验证', description: '每个账号和模型每周期最多一次采集和一次业务出口复验;有效资格默认跳过。路由记录最多保留120秒并按需续期,此本地上限不代表上游承诺。材料有效、响应完整、模型声明一致分别记录;本项未验证质量,STATE 长度仅作观测。', + disabled: '路由采集与验证未开启。', force: '重新采集并验证(已有有效路由也执行一次)', ineligible: '不适用:仅 OpenAI OAuth/Setup Token 非影子账号。', + valid: '路由记录有效,质量未验证', stop: '停止自动续期', start: '采集并验证', limit: '一次最多选择100个账号。', loadFailed: '无法读取账号或路由配置。', + submitFailed: '操作失败,请查看状态后重试。', next: '下次续期', last: '最近尝试', length: 'STATE 长度(仅观测)', expires: '路由记录到期时间', + states: { idle: '待手动采集', ready: '路由记录有效,待续期', retry: '等待路由重试', stopped: '自动续期已停止', manual_running: '手动采集验证中', pre_running: '路由续期验证中', post_running: '路由重试中', needs_cookie_verification: '待业务出口验证' } }, ticketProxy: { - address: '打票代理完整地址', protocol: '协议', test: '测试连接', clear: '清除代理并关闭开关', hint: '失焦仅解析格式;测试使用草稿,不保存配置,不发送OAuth凭据或模型请求。完整账密仅管理员可见。', + address: '路由采集代理完整地址', protocol: '协议', test: '测试连接', clear: '清除代理并关闭开关', hint: '失焦仅解析格式;测试使用草稿,不保存配置,不发送OAuth凭据或模型请求。完整账密仅管理员可见。', trust: '证书验证', copyFailed: '复制失败,请手动复制。', testFailed: '代理测试未完成,请重试。', stages: { parse: '输入解析', dns: '代理DNS', tcp: '代理TCP', connect: 'CONNECT', tls: 'TLS', http: 'ChatGPT响应', protocol: '协议握手', auth: '代理鉴权', pinned_connection: '固定证书后的再次连接' }, errors: { too_long: '代理输入过长。', unknown_field: '无法识别字段,请使用主机、端口、用户名、密码标签。', duplicate_field: '存在重复字段,保留原输入,请检查。', incomplete: '主机、端口或账密不完整。', ambiguous: '字段存在歧义,请用带标签的分行格式。', invalid_url: '代理URL无法识别或包含不支持的内容。', invalid_port: '请填写1至65535之间的端口。' } @@ -1083,11 +1083,11 @@ export default {accounts: { codexFingerprintDevice: '仅设备', codexFingerprintSession: '设备+会话', codexFingerprintFull: '完全收敛', - codexTurnTicket: 'Codex Cookie 路由', - codexTurnTicketDesc: '显示已配置模型的门票状态。仅在启用缺票拦截时,没有有效门票的模型才会暂停调度。', - codexTurnTicketMissing: '暂无有效门票,仍允许请求', - codexTurnTicketReady: '剩余 {time}', - codexTurnTicketPaused: '该模型尚无有效 Cookie 路由资格', + codexTurnTicket: 'Codex 路由采集与验证', + codexTurnTicketDesc: '路由材料有效、响应完整和模型声明一致分别表示不同证据。STATE 长度仅作观测,不能证明套餐或模型质量;本项不包含质量验证。仅在开启拦截时,没有有效路由资格的模型才暂停调度。', + codexTurnTicketMissing: '暂无有效路由资格,仍允许请求', + codexTurnTicketReady: '路由已验证,剩余 {time};质量未验证', + codexTurnTicketPaused: '暂无有效路由资格,该模型已暂停调度', codexImageTool: 'Codex 图片桥接策略', codexImageToolDesc: '统一控制 Codex /responses 文本请求的 hosted image_generation 桥接和客户端图片工具声明。hosted 工具自动注入仅适用于非 Responses Lite 请求;账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', diff --git a/plugins/admin-observability/manifest.source.json b/plugins/admin-observability/manifest.source.json index 7662dd053f51..44943bba6911 100644 --- a/plugins/admin-observability/manifest.source.json +++ b/plugins/admin-observability/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.admin-observability", "name": "界面与观测", - "version": "0.2.12", + "version": "0.2.13", "description": "账号流量观测规则与指标展示", "author": "HTExplicit", "requires": { diff --git a/plugins/cindy-provider/manifest.source.json b/plugins/cindy-provider/manifest.source.json index d447ef56b938..01083df58c2c 100644 --- a/plugins/cindy-provider/manifest.source.json +++ b/plugins/cindy-provider/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.cindy-provider", "name": "Cindy Provider", - "version": "0.2.12", + "version": "0.2.13", "description": "Cindy 模型目录、协议能力、价格参考及账号健康判定", "author": "HTExplicit", "requires": { diff --git a/plugins/codex-runtime/internal/tickets/outcome.go b/plugins/codex-runtime/internal/tickets/outcome.go index 9accb785c119..9f0743ca7d3e 100644 --- a/plugins/codex-runtime/internal/tickets/outcome.go +++ b/plugins/codex-runtime/internal/tickets/outcome.go @@ -3,6 +3,7 @@ package tickets import ( "encoding/json" "strconv" + "strings" "time" extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" @@ -11,17 +12,20 @@ import ( func (o Outcome) MarshalJSON() ([]byte, error) { type wire Outcome message := map[string]string{ - "routing_verified": "Cookie 已通过同账号业务出口完整响应验证", "routing_candidate": "已取得候选 Cookie,尚未获得业务出口资格", - "routing_model_mismatch": "完整响应模型不匹配,未发布路由资格", "routing_incomplete": "响应未完整结束或缺少原始模型,未发布路由资格", + "routing_verified": "业务出口响应完整、模型声明一致;本次未验证质量", "routing_candidate": "已取得候选路由材料,尚未通过业务出口验证", + "routing_model_mismatch": "上游模型声明不匹配,未发布路由资格", "routing_incomplete": "响应未完整结束或缺少模型声明,未发布路由资格", + "routing_capacity": "上游容量不足或流内限流,未完成路由验证", "routing_policy": "上游策略检查阻止请求,未完成路由验证", + "routing_cancelled": "客户端取消,本次响应未完成验证", "routing_legacy_retired": "旧 STATE 材料已丢弃,尚未获得路由资格", "routing_cookie_missing": "响应未给出可用路由 Cookie", "routing_cookie_expired": "Cookie 已到期,需要重新验证", "routing_transport": "采集或业务出口验证连接失败", "routing_stale": "账号主体、出口、指纹或 Cookie 代次已变化", "routing_cookie_deleted": "上游已撤销路由 Cookie", "routing_budget_spent": "本次验证的固定调用预算已使用", "routing_upstream": "上游拒绝路由验证请求", "routing_connection_unknown": "无法绑定经过验证的实际连接", - "ticket_ready": "已取得有效292票据", "ticket_skipped": "已有有效票据,本次跳过", "ticket_stopped": "自动续期已停止", - "ticket_disabled": "票据功能已关闭", "ticket_model_invalid": "模型不在票据配置范围内", "ticket_proxy_missing": "尚未配置采集代理", - "ticket_busy": "账号和模型已有进行中的操作", "ticket_interrupted": "前次请求结果未确认,未重复发送", "ticket_not_due": "未到续期时间", + "ticket_ready": "历史 STATE 采集记录,不代表当前路由资格", "ticket_skipped": "已有路由验证记录,本次跳过;本次未验证质量", "ticket_stopped": "路由自动续期已停止", + "ticket_disabled": "路由采集与验证已关闭", "ticket_model_invalid": "模型不在路由配置范围内", "ticket_proxy_missing": "尚未配置采集代理", + "ticket_missing": "该模型尚无有效路由资格", + "ticket_busy": "账号和模型已有进行中的操作", "ticket_interrupted": "前次请求结果未确认,未重复发送", "ticket_not_due": "未到续期时间", "ticket_token": "无法取得账号访问令牌", "ticket_transport": "采集连接失败", "ticket_upstream": "上游拒绝采集请求", - "ticket_length": "票据长度不符合292规则", "ticket_prefix": "票据格式不符合规则", "ticket_persist": "票据保存失败", + "ticket_length": "历史 STATE 长度检查失败(该规则已停用)", "ticket_prefix": "历史 STATE 格式检查失败(该规则已停用)", "ticket_persist": "路由验证记录保存失败", "ticket_stale": "账号主体或操作状态已变化", "ticket_canceled": "操作已取消", "proxy_connection_failed": "代理连接或证书验证失败", "pinned_connection_failed": "固定证书验证失败", "invalid_proxy": "代理地址或协议不正确", }[o.Code] @@ -33,10 +37,35 @@ func (o Outcome) MarshalJSON() ([]byte, error) { facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "上游状态", "en": "Upstream status"}, Value: strconv.Itoa(o.HTTPStatus)}) } if o.ObservedLength > 0 { - facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "票据长度", "en": "Ticket length"}, Value: strconv.Itoa(o.ObservedLength)}) + facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "STATE 长度(仅观测)", "en": "STATE length (observation only)"}, Value: strconv.Itoa(o.ObservedLength)}) + } + if observation := o.Observation; observation != nil { + material, completed, matched := "未证实有效", "未证实完整", "未知" + if o.Code == "routing_candidate" { + material = "候选材料,待业务出口验证" + } else if o.Code == "routing_verified" && o.Success { + material = "验证时有效" + } + if observation.Completed { + completed = "完整结束" + } + if observation.ResponseModel != "" { + matched = "不一致" + if observation.ModelMatched { + matched = "一致" + } + facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "上游模型声明", "en": "Upstream model declaration"}, Value: observation.ResponseModel}) + } + facts = append(facts, + extensionv1.DisplayFact{Label: map[string]string{"zh": "路由材料", "en": "Routing material"}, Value: material}, + extensionv1.DisplayFact{Label: map[string]string{"zh": "完整响应", "en": "Response completion"}, Value: completed}, + extensionv1.DisplayFact{Label: map[string]string{"zh": "模型声明匹配", "en": "Model declaration match"}, Value: matched}) + } + if strings.HasPrefix(o.Code, "routing_") || o.Code == "ticket_skipped" || o.Code == "ticket_ready" { + facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "质量验证", "en": "Quality validation"}, Value: "本次未验证 / Not tested by this operation"}) } if o.ExpiresAt != nil { - facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "有效期至", "en": "Expires"}, Value: o.ExpiresAt.Format(time.RFC3339), Timestamp: true}) + facts = append(facts, extensionv1.DisplayFact{Label: map[string]string{"zh": "路由记录有效期至", "en": "Routing record expires"}, Value: o.ExpiresAt.Format(time.RFC3339), Timestamp: true}) } return json.Marshal(struct { wire diff --git a/plugins/codex-runtime/internal/tickets/outcome_test.go b/plugins/codex-runtime/internal/tickets/outcome_test.go new file mode 100644 index 000000000000..4b892772c178 --- /dev/null +++ b/plugins/codex-runtime/internal/tickets/outcome_test.go @@ -0,0 +1,49 @@ +package tickets + +import ( + "encoding/json" + "strings" + "testing" + + extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" +) + +func TestRoutingOutcomeSeparatesModelAndQualityEvidence(t *testing.T) { + for _, length := range []int{292, 332, 780} { + outcome := Outcome{Success: true, Code: "routing_verified", ObservedLength: length, Observation: &extensionv1.CodexRoutingObservation{Completed: true, ModelMatched: true, RequestedModel: "gpt-6-astra", ResponseModel: "gpt-6-astra"}} + raw, err := json.Marshal(outcome) + if err != nil { + t.Fatal(err) + } + var result struct { + Facts []extensionv1.DisplayFact `json:"facts"` + } + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + facts := map[string]string{} + for _, fact := range result.Facts { + facts[fact.Label["en"]] = fact.Value + } + if facts["STATE length (observation only)"] == "" || facts["Response completion"] == "" || facts["Model declaration match"] == "" || !strings.Contains(facts["Quality validation"], "Not tested") { + t.Fatalf("length %d confused routing evidence with quality: %s", length, raw) + } + } +} + +func TestRoutingOutcomeKeepsFailureCategories(t *testing.T) { + for _, code := range []string{"routing_capacity", "routing_policy", "routing_cancelled", "routing_legacy_retired"} { + raw, err := json.Marshal(Outcome{Code: code}) + if err != nil { + t.Fatal(err) + } + var result struct { + Code string `json:"code"` + Success bool `json:"success"` + Message string `json:"message"` + } + if json.Unmarshal(raw, &result) != nil || result.Code != code || result.Success || result.Message == "" || strings.Contains(result.Message, "操作未完成,请检查") { + t.Fatalf("failure category was lost: %s", raw) + } + } +} diff --git a/plugins/codex-runtime/internal/tickets/routing_test.go b/plugins/codex-runtime/internal/tickets/routing_test.go index 2bb8f36ad5ee..a3a36594fd46 100644 --- a/plugins/codex-runtime/internal/tickets/routing_test.go +++ b/plugins/codex-runtime/internal/tickets/routing_test.go @@ -34,7 +34,7 @@ func TestRoutingConfigMigratesOnlyUnversionedExactDefaults(t *testing.T) { func TestRoutingMigrationAndTwoFailuresPreserveStop(t *testing.T) { for _, phase := range []string{"ready", "retry", "stopped", "manual_running"} { - state := State{Phase: phase, Ticket: &Ticket{State: "old292"}} + state := State{Phase: phase, Ticket: json.RawMessage(`{"state":"retired-state"}`)} state.migrateRouting() if state.Ticket != nil || state.Qualification != nil { t.Fatal("legacy state became qualification") diff --git a/plugins/codex-runtime/internal/tickets/state.go b/plugins/codex-runtime/internal/tickets/state.go index 670daff4b99c..e3a967b7b20e 100644 --- a/plugins/codex-runtime/internal/tickets/state.go +++ b/plugins/codex-runtime/internal/tickets/state.go @@ -1,34 +1,16 @@ package tickets import ( + "encoding/json" "errors" - "strings" "time" extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) -const Length = 292 -const TTL = time.Hour - var ErrNotDue = errors.New("ticket_not_due") var ErrRunning = errors.New("ticket_attempt_in_progress") -type Ticket struct { - State string `json:"state"` - AccountID int64 `json:"account_id"` - Identity string `json:"identity"` - Model string `json:"model"` - CapturedAt time.Time `json:"captured_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -func (t *Ticket) Valid(now time.Time, accountID int64, identity, model string) bool { - return t != nil && t.AccountID == accountID && t.Identity == identity && t.Model == model && - len(t.State) == Length && strings.HasPrefix(t.State, "gAAAAA") && !t.CapturedAt.After(now) && - now.Before(t.ExpiresAt) && !t.ExpiresAt.After(t.CapturedAt.Add(TTL)) -} - // State includes the spent automatic attempt before any network IO. A process // restart resumes only persisted pending stages, never a running/spent attempt. type State struct { @@ -45,10 +27,13 @@ type State struct { ManualWasEnrolled bool `json:"manual_was_enrolled,omitempty"` LastCode string `json:"last_code,omitempty"` LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` - Ticket *Ticket `json:"ticket,omitempty"` + // Opaque only so every legacy representation can be discarded without + // interpreting its length, timestamps or account-plan-specific format. + Ticket json.RawMessage `json:"ticket,omitempty"` } func (s *State) migrateRouting() { + s.Ticket = nil if s.Schema >= extensionv1.CodexRoutingSchema { return } @@ -58,10 +43,12 @@ func (s *State) migrateRouting() { s.Phase = "needs_cookie_verification" now := time.Now().UTC() s.NextAt = &now + } else { + s.NextAt = nil } // Old STATE is never a Cookie qualification. Keep stopped/running markers // and operation IDs so migration cannot resurrect a spent attempt. - s.Ticket, s.Qualification, s.ExpiresAt = nil, nil, nil + s.Qualification, s.ExpiresAt = nil, nil } func (s *State) beginRouting(now time.Time, manual bool) error { @@ -100,76 +87,6 @@ func (s *State) completeRouting(now time.Time, q *extensionv1.CodexRoutingQualif s.NextAt = &due } -func (s *State) Begin(now time.Time, manual bool) error { - switch s.Phase { - case "manual_running", "pre_running", "post_running": - return ErrRunning - } - if manual { - s.ManualWasEnrolled = s.Phase == "ready" || s.Phase == "retry" - s.Phase = "manual_running" - s.LastAttemptAt = &now - s.NextAt = nil - return nil - } - if s.NextAt == nil || now.Before(*s.NextAt) || s.ExpiresAt == nil { - return ErrNotDue - } - switch s.Phase { - case "ready": - if now.Before(*s.ExpiresAt) { - s.Phase = "pre_running" - } else { - due := s.ExpiresAt.Add(time.Minute) - s.Phase = "retry" - s.NextAt = &due - if now.Before(due) { - return ErrNotDue - } - s.Phase = "post_running" - } - case "retry": - s.Phase = "post_running" - default: - return ErrNotDue - } - s.LastAttemptAt = &now - s.NextAt = nil - return nil -} - -func (s *State) Complete(now time.Time, ticket *Ticket, code string) { - prior := s.Phase - s.LastCode = code - if ticket != nil { - s.Ticket = ticket - s.ExpiresAt = &ticket.ExpiresAt - due := ticket.ExpiresAt.Add(-time.Minute) - s.NextAt = &due - s.Phase = "ready" - return - } - if s.ExpiresAt != nil { - if prior == "manual_running" && s.ManualWasEnrolled && now.Before(*s.ExpiresAt) { - due := s.ExpiresAt.Add(-time.Minute) - s.Phase = "ready" - if !now.Before(due) { - due = s.ExpiresAt.Add(time.Minute) - s.Phase = "retry" - } - s.NextAt = &due - return - } - if prior == "pre_running" { - due := s.ExpiresAt.Add(time.Minute) - s.NextAt = &due - s.Phase = "retry" - return - } - } - s.Stop() -} - func (s *State) Stop() { s.Phase = "stopped"; s.NextAt = nil } func Eligible(platform, accountType string, shadow bool) bool { diff --git a/plugins/codex-runtime/internal/tickets/state_test.go b/plugins/codex-runtime/internal/tickets/state_test.go index d48feff37e89..116c1e2a4c57 100644 --- a/plugins/codex-runtime/internal/tickets/state_test.go +++ b/plugins/codex-runtime/internal/tickets/state_test.go @@ -6,20 +6,14 @@ import ( "strings" "testing" "time" + + extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1" ) func TestRenewalStagesPersistAndStop(t *testing.T) { now := time.Date(2026, 9, 19, 8, 0, 0, 0, time.UTC) - ticket := &Ticket{State: "gAAAAA" + strings.Repeat("a", 286), AccountID: 1, Identity: "subject", Model: "astra", CapturedAt: now, ExpiresAt: now.Add(TTL)} - state := State{} - if err := state.Begin(now, true); err != nil { - t.Fatal(err) - } - state.Complete(now, ticket, "ticket_ready") - if !ticket.Valid(now, 1, "subject", "astra") || ticket.Valid(now, 1, "different", "astra") { - t.Fatal("ticket ownership mismatch") - } - if err := state.Begin(now.Add(59*time.Minute), false); err != nil { + state := State{Schema: extensionv1.CodexRoutingSchema, Enrolled: true, Phase: "ready", NextAt: &now} + if err := state.beginRouting(now, false); err != nil { t.Fatal(err) } raw, _ := json.Marshal(state) @@ -27,25 +21,51 @@ func TestRenewalStagesPersistAndStop(t *testing.T) { if err := json.Unmarshal(raw, &restarted); err != nil { t.Fatal(err) } - if !errors.Is(restarted.Begin(now.Add(59*time.Minute), false), ErrRunning) { + if !errors.Is(restarted.beginRouting(now, false), ErrRunning) { t.Fatal("restarted running stage was replayed") } - state.Complete(now.Add(59*time.Minute), nil, "ticket_length") - if !errors.Is(state.Begin(now.Add(time.Hour), false), ErrNotDue) { - t.Fatal("post expiry retry ran early") + state.completeRouting(now, nil, extensionv1.CodexRoutingObservation{Code: "routing_incomplete"}) + if !errors.Is(state.beginRouting(now, false), ErrNotDue) { + t.Fatal("retry ran before its persisted due time") } - if err := state.Begin(now.Add(61*time.Minute), false); err != nil { + due := *state.NextAt + if err := state.beginRouting(due, false); err != nil { t.Fatal(err) } - state.Complete(now.Add(61*time.Minute), nil, "ticket_length") + state.completeRouting(due, nil, extensionv1.CodexRoutingObservation{Code: "routing_incomplete"}) if state.Phase != "stopped" || state.NextAt != nil { t.Fatal("renewal did not stop after its second failure") } - if !errors.Is(state.Begin(now.Add(2*time.Hour), false), ErrNotDue) { + if !errors.Is(state.beginRouting(now.Add(2*time.Hour), false), ErrNotDue) { t.Fatal("stopped renewal restarted") } } +func TestRoutingMigrationDiscardsEveryLegacyStateShape(t *testing.T) { + for _, length := range []int{292, 312, 332, 356, 780} { + for _, phase := range []string{"ready", "retry", "stopped", "manual_running"} { + // Legacy timestamps are intentionally opaque: retired material must + // neither reject the account nor become current routing evidence. + raw, _ := json.Marshal(map[string]any{"phase": phase, "operation_id": "spent-job", "ticket": map[string]any{"state": strings.Repeat("x", length), "expires_at": "retired-format"}}) + var state State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state.migrateRouting() + if state.Ticket != nil || state.Qualification != nil || state.ExpiresAt != nil || state.OperationID != "spent-job" { + t.Fatalf("length %d: legacy material was not retired", length) + } + if phase == "stopped" || phase == "manual_running" { + if state.Phase != phase || state.Enrolled || state.NextAt != nil { + t.Fatalf("length %d: spent state was revived", length) + } + } else if state.Phase != "needs_cookie_verification" || !state.Enrolled { + t.Fatalf("length %d: renewal intent was lost", length) + } + } + } +} + func TestTicketEligibilityDoesNotDependOnBusinessStatus(t *testing.T) { if !Eligible("openai", "oauth", false) || !Eligible("openai", "setup-token", false) { t.Fatal("OAuth credentials excluded") diff --git a/plugins/codex-runtime/manifest.source.json b/plugins/codex-runtime/manifest.source.json index ff26649aa98e..e5e470268ad1 100644 --- a/plugins/codex-runtime/manifest.source.json +++ b/plugins/codex-runtime/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.codex-runtime", "name": "Codex 运行扩展", - "version": "0.2.12", + "version": "0.2.13", "description": "Cookie 路由资格、按需有界续获与出站指纹", "author": "HTExplicit", "requires": { @@ -35,16 +35,16 @@ { "id": "codex-recovery-settings", "slot": "surface", "capability": "extensions.recovery.v1", "permission": "admin", "label": { "zh": "推理状态与恢复策略", "en": "Reasoning state and recovery" } }, { "id": "proxy-settings", "slot": "admin.settings", "capability": "extensions.admin.v1", "action": "proxy.test", "entrypoint": "ui/index.html", "permission": "admin", - "label": { "zh": "Codex 票据设置", "en": "Codex ticket settings" } + "label": { "zh": "Codex 路由采集与验证", "en": "Codex route acquisition and verification" } }, { "id": "ticket-harvest", "slot": "account.actions", "capability": "extensions.credentials.v1", "action": "harvest", "entrypoint": "ui/index.html", "permission": "admin", - "label": { "zh": "采集并验证 Cookie 路由", "en": "Acquire and verify Cookie route" }, + "label": { "zh": "采集并验证 Codex 路由", "en": "Acquire and verify Codex route" }, "account_filter": { "platforms": ["openai"], "types": ["oauth", "setup-token"], "exclude_shadows": true } }, { "id": "ticket-stop", "slot": "account.actions", "capability": "extensions.admin.v1", "action": "stop", "entrypoint": "ui/index.html", "permission": "admin", - "label": { "zh": "停止票据续期", "en": "Stop ticket renewal" }, + "label": { "zh": "停止路由续期", "en": "Stop route renewal" }, "account_filter": { "platforms": ["openai"], "types": ["oauth", "setup-token"], "exclude_shadows": true } } ], diff --git a/plugins/codex-runtime/scripts/check-ui.py b/plugins/codex-runtime/scripts/check-ui.py index e8ffeb1d84a2..75c3dd6bf0d4 100644 --- a/plugins/codex-runtime/scripts/check-ui.py +++ b/plugins/codex-runtime/scripts/check-ui.py @@ -145,7 +145,7 @@ def offline(route): control.check() frame.get_by_role("button", name="保存设置", exact=True).click() page.wait_for_function("saved.at(-1)?.request_zstd===true") - frame.get_by_role("button", name="清除代理并停用票据", exact=True).click() + frame.get_by_role("button", name="清除代理并停用路由采集", exact=True).click() page.wait_for_function("saved.at(-1)?.enabled===false && saved.at(-1)?.request_zstd===true") assert control.is_checked() control.uncheck() @@ -170,7 +170,7 @@ def offline(route): frame.get_by_role("button", name="测试连接", exact=True).wait_for() # Inspect the live accessibility names before interaction. labels = frame.get_by_role("button").all_text_contents() - assert labels == ["测试连接", "复制", "清除代理并停用票据", "保存设置"], labels + assert labels == ["测试连接", "复制", "清除代理并停用路由采集", "保存设置"], labels dropdown = frame.get_by_label("协议").bounding_box() test_button = frame.get_by_role("button", name="测试连接", exact=True).bounding_box() assert abs(dropdown["y"] + dropdown["height"] - test_button["y"] - test_button["height"]) <= 1 @@ -179,7 +179,7 @@ def offline(route): frame.get_by_text("代理链路已连接,目标返回 HTTP 405;本次未发送账号凭据。", exact=True).wait_for() assert frame.locator("body").evaluate("node=>node.scrollWidth<=innerWidth") page.screenshot(path=str(args.output / f"proxy-{width}-{theme}.png"), full_page=True) - frame.get_by_role("button", name="清除代理并停用票据", exact=True).click() + frame.get_by_role("button", name="清除代理并停用路由采集", exact=True).click() frame.get_by_text("已清除并关闭", exact=True).wait_for() assert page.evaluate("saved.at(-1).enabled===false && saved.at(-1).proxy_url===''") assert not errors, errors diff --git a/plugins/codex-runtime/ui/assets/app.js b/plugins/codex-runtime/ui/assets/app.js index bcf7a2c27a71..b5af3199f9a4 100644 --- a/plugins/codex-runtime/ui/assets/app.js +++ b/plugins/codex-runtime/ui/assets/app.js @@ -19,11 +19,12 @@ return control } const configuration = context.mode === 'configuration' || context.mode === 'admin.settings' - app.append(el('h2', configuration ? text('Codex 运行设置', 'Codex runtime settings') : text('Cookie 路由操作', 'Cookie route operation'))) + app.append(el('h2', configuration ? text('Codex 路由设置', 'Codex route settings') : text('Codex 路由采集与验证', 'Codex route acquisition and verification'))) + app.append(el('p', text('路由材料有效、响应完整、模型声明一致是不同证据。STATE 长度仅供观测,不能证明账号套餐或模型质量;本页操作不包含质量验证。', 'Valid routing material, a complete response and a matching model declaration are separate observations. STATE length proves neither account plan nor model quality; operations on this page do not test quality.'), 'muted')) if (configuration) { let config = await bridge.config() const enabled = el('input'); enabled.type = 'checkbox'; enabled.checked = !!config.enabled - const toggle = el('label', '', 'check'); toggle.append(enabled, document.createTextNode(text('启用 Cookie 采集与业务出口复验', 'Enable Cookie acquisition and route verification'))); app.append(toggle) + const toggle = el('label', '', 'check'); toggle.append(enabled, document.createTextNode(text('启用 Codex 路由采集与验证', 'Enable Codex route acquisition and verification'))); app.append(toggle) const compression = el('input'); compression.type = 'checkbox'; compression.checked = config.request_zstd !== false const compressionToggle = el('label', '', 'check'); compressionToggle.append(compression, document.createTextNode(text('压缩 Codex Responses 请求体', 'Compress Codex Responses requests'))); app.append(compressionToggle) const address = el('textarea'); address.rows = 4; address.autocomplete = 'off'; address.spellcheck = false; address.value = config.proxy_url || '' @@ -45,10 +46,10 @@ if (!document.execCommand('copy')) throw new Error(text('复制失败,请手动复制。', 'Copy failed; copy the selected text manually.')) show(text('已复制', 'Copied')) })) - controls.append(button(text('清除代理并停用票据', 'Clear proxy and stop tickets'), async () => { + controls.append(button(text('清除代理并停用路由采集', 'Clear proxy and stop route acquisition'), async () => { config = await bridge.save({ ...config, proxy_url: '', enabled: false }); address.value = ''; enabled.checked = false; show(text('已清除并关闭', 'Cleared and disabled')) })) - app.append(controls, el('p', text('连接测试验证代理与证书;成功连接不代表 Cookie 路由已验证。', 'Connection tests verify the proxy and certificate; they do not verify the Cookie route.'), 'muted')) + app.append(controls, el('p', text('连接测试验证代理与证书;成功连接不代表 Codex 路由已验证。', 'Connection tests verify the proxy and certificate; they do not verify the Codex route.'), 'muted')) const actions = el('div', '', 'actions') actions.append(button(text('保存设置', 'Save settings'), async () => { config = await bridge.save({ ...config, enabled: enabled.checked, proxy_url: address.value, proxy_protocol: protocol.value, request_zstd: compression.checked }) diff --git a/plugins/image-tools/manifest.source.json b/plugins/image-tools/manifest.source.json index aa6581ef56e4..884fe5de01eb 100644 --- a/plugins/image-tools/manifest.source.json +++ b/plugins/image-tools/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.image-tools", "name": "图像工具", - "version": "0.2.12", + "version": "0.2.13", "description": "Image Studio 请求计划、模型能力和 Responses 图像桥接策略", "author": "HTExplicit", "requires": { "sub2api": ">=0.2.7-codexrip.2 <0.2.9", "tested_sub2api_versions": [], "plugin_protocol": 1, "transport_api": 1, "ui_bridge": 1, "extension_api": 1 }, diff --git a/plugins/model-policy/manifest.source.json b/plugins/model-policy/manifest.source.json index db6a0a686502..63e5e402581e 100644 --- a/plugins/model-policy/manifest.source.json +++ b/plugins/model-policy/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.model-policy", "name": "模型目录与容量策略", - "version": "0.2.12", + "version": "0.2.13", "description": "精确模型别名、供应商范围与经过核对的容量参考", "author": "HTExplicit", "requires": { diff --git a/plugins/prompt-skills/manifest.source.json b/plugins/prompt-skills/manifest.source.json index 22d326434728..5d8cca67c609 100644 --- a/plugins/prompt-skills/manifest.source.json +++ b/plugins/prompt-skills/manifest.source.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "codexrip.prompt-skills", "name": "提示词与技能策略", - "version": "0.2.12", + "version": "0.2.13", "description": "提示词完整性、应用范围与协议载体策略", "author": "HTExplicit", "requires": { "sub2api": ">=0.2.7-codexrip.2 <0.2.9", "tested_sub2api_versions": [], "plugin_protocol": 1, "transport_api": 1, "ui_bridge": 1, "extension_api": 1 },