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 @@ - +