From 6ac5b39d3bf8b93b489d95d436814a0096400ccb Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 22:40:06 +0800 Subject: [PATCH 1/3] refactor(codex): remove what the ticket-era work left behind The ticket removal (#229-#231) kept several pieces that only existed for it, some under new names, plus tolerance code for its stored data. This removes them and restores the upstream or pre-ticket form in each place. Deleted: the response model-declaration guard on every path (upstream behaviour: the reply is delivered and the usage log records upstream_model_mismatch); the codex-quality-runs diagnostics; the Codex outbound fingerprint panel with its per-request wire observation and profile selection; plugin-owner job metadata decoding; the retired-key, retired-extra and retired-job tolerance added by #229/#230. Replaced: the native Codex runtime host (lease, epoch, generation, receipts, encrypted config, module dispatch) by one plain settings row {request_zstd} and direct calls into codexruntime/profile and codexruntime/recovery. GET/PUT /admin/settings/codex-runtime keep their path and body. Restored: x-codex-turn-state handling to the fork's contract from before the routing commits; Responses-Lite default instructions to upstream. Migration 264 carries the compression value over from the recorded config hash (it refuses an unknown hash instead of guessing), drops sub2api_plugin_state and sub2api_plugin_leases, removes the retired codex-runtime installation's stored configuration and package, and writes the fingerprint mode explicitly for OAuth accounts that had none. Co-Authored-By: Claude Opus 5.5 --- .downstream/codex-quality-diagnostics.md | 59 -- ...-prompts.md => gpt6-models-and-prompts.md} | 20 +- .downstream/native-domains.md | 147 ++--- .../scripts/test_model_context_capacity.sh | 2 +- .github/scripts/test_native_domains.sh | 9 +- .../test_upstream_automation_contract.py | 4 +- .github/workflows/downstream-verify.yml | 20 +- DOWNSTREAM.md | 22 +- backend/cmd/server/main.go | 7 - backend/cmd/server/wire.go | 9 +- backend/cmd/server/wire_gen.go | 20 +- backend/cmd/server/wire_gen_test.go | 1 - backend/internal/codexruntime/core/module.go | 178 ------ .../internal/codexruntime/core/module_test.go | 89 --- .../internal/codexruntime/profile/identity.go | 96 ++-- .../codexruntime/profile/identity_test.go | 35 +- .../codexruntime/profile/transport.go | 17 - .../codexruntime/profile/transport_test.go | 27 - .../internal/codexruntime/recovery/policy.go | 43 +- backend/internal/config/config.go | 3 +- .../admin/account_bulk_job_scope_test.go | 8 - .../admin/account_codex_fingerprint.go | 51 -- .../handler/admin/account_codex_quality.go | 112 ---- .../admin/account_data_handler_test.go | 22 - .../handler/admin/account_data_import_job.go | 3 - .../internal/handler/admin/account_handler.go | 6 - .../handler/admin/account_job_execution.go | 3 - .../handler/admin/account_job_handler.go | 4 +- .../handler/admin/account_job_submit_test.go | 2 - .../handler/admin/account_test_plan_test.go | 2 +- .../handler/admin/setting_codex_runtime.go | 82 --- .../setting_codex_runtime_receipt_test.go | 89 --- .../internal/handler/admin/setting_handler.go | 52 +- .../setting_handler_codex_runtime_test.go | 67 +++ .../setting_handler_codex_ticket_test.go | 39 -- .../handler/admin/setting_handler_update.go | 1 - .../handler/dto/account_mapper_redact_test.go | 28 - .../handler/openai_gateway_handler.go | 29 +- .../handler/openai_official_http_failover.go | 4 - .../handler/openai_request_rejected_test.go | 42 -- backend/internal/handler/ops_error_logger.go | 35 -- .../internal/handler/ops_error_logger_test.go | 44 -- backend/internal/handler/wire.go | 7 +- backend/internal/nativeapi/account_create.go | 135 ----- backend/internal/nativeapi/account_edit.go | 102 ---- backend/internal/nativeapi/account_view.go | 212 -------- backend/internal/nativeapi/api.go | 145 +---- backend/internal/nativeapi/codex_identity.go | 37 +- backend/internal/nativeapi/codex_runtime.go | 5 + backend/internal/nativeapi/codex_transport.go | 15 - backend/internal/nativeapi/observability.go | 11 - backend/internal/nativeapi/resources.go | 32 -- .../internal/repository/account_job_repo.go | 4 +- backend/internal/repository/account_repo.go | 69 +-- .../account_repo_model_context_test.go | 76 +-- .../account_repo_opencode_go_usage_test.go | 2 +- ...repo_upstream_billing_probe_update_test.go | 8 +- .../internal/repository/codex_native_lease.go | 154 ------ .../codex_native_lease_monitor_test.go | 111 ---- .../repository/codex_native_runtime.go | 203 ------- .../repository/codex_native_runtime_test.go | 87 --- .../internal/repository/codex_native_state.go | 52 -- .../repository/codex_quality_breaker.go | 18 - ...plugin_purge_migration_integration_test.go | 410 ++++++++++++++ ...ticket_purge_migration_integration_test.go | 246 --------- backend/internal/repository/http_upstream.go | 4 +- .../repository/native_feature_bootstrap.go | 24 +- ...tive_feature_bootstrap_integration_test.go | 28 +- .../internal/repository/scheduler_cache.go | 10 +- backend/internal/repository/wire.go | 1 - .../internal/server/middleware/audit_log.go | 5 +- .../server/middleware/audit_log_test.go | 37 -- .../server/routes/account_job_routes_test.go | 32 -- backend/internal/server/routes/admin.go | 11 +- backend/internal/service/account_job.go | 63 +-- .../internal/service/account_job_legacy.go | 268 --------- .../service/account_job_legacy_test.go | 30 - backend/internal/service/account_job_test.go | 28 - .../service/account_test_prompt_test.go | 22 +- .../account_test_reasoning_wire_test.go | 5 +- .../internal/service/account_test_service.go | 24 +- ...ccount_test_service_openai_compact_test.go | 5 +- .../account_test_service_openai_image_test.go | 4 +- .../account_test_service_openai_test.go | 32 +- .../internal/service/account_usage_service.go | 2 +- backend/internal/service/admin_account.go | 5 - .../service/codex_client_reference.json | 223 -------- .../service/codex_credential_owner.go | 31 -- .../service/codex_credential_owner_test.go | 23 - .../service/codex_identity_observation.go | 87 --- .../codex_identity_observation_test.go | 37 -- backend/internal/service/codex_model_guard.go | 509 ----------------- .../service/codex_model_guard_test.go | 52 -- .../codex_native_compatibility_test.go | 125 ----- .../internal/service/codex_native_contract.go | 77 --- .../service/codex_native_fixture_test.go | 116 ---- backend/internal/service/codex_native_host.go | 113 ---- .../internal/service/codex_native_provider.go | 75 --- .../codex_native_provider_receipt_test.go | 225 -------- .../internal/service/codex_native_runtime.go | 280 ---------- .../service/codex_native_ws_observation.go | 45 -- .../service/codex_profile_selection.go | 67 --- .../service/codex_quality_composition_test.go | 115 ---- .../service/codex_quality_contract_test.go | 52 -- .../internal/service/codex_quality_health.go | 74 --- .../service/codex_quality_manual_stop_test.go | 75 --- .../service/codex_quality_observation.go | 408 -------------- .../service/codex_quality_observation_test.go | 179 ------ .../internal/service/codex_quality_runtime.go | 513 ------------------ .../internal/service/codex_quality_state.go | 373 ------------- .../service/codex_quality_state_test.go | 129 ----- .../internal/service/codex_quality_test.go | 438 --------------- .../service/codex_quality_wire_test.go | 270 --------- .../service/codex_wire_fingerprint.go | 147 ----- .../service/codex_wire_fingerprint_test.go | 96 ---- .../service/native_feature_bootstrap.go | 14 +- .../service/native_feature_bootstrap_test.go | 14 +- .../service/native_policy_fixtures.go | 17 +- .../service/native_setting_load_test.go | 78 ++- .../openai_account_runtime_block_fastpath.go | 5 - .../service/openai_account_scheduler_test.go | 53 -- .../service/openai_alpha_search_test.go | 4 +- .../service/openai_chat_reasoning_replay.go | 1 - .../service/openai_codex_client_identity.go | 149 ++--- .../openai_codex_client_identity_test.go | 114 +++- .../service/openai_codex_fingerprint.go | 60 +- .../internal/service/openai_codex_identity.go | 21 + .../service/openai_codex_identity_snapshot.go | 18 +- .../service/openai_codex_logical_turn.go | 60 -- .../openai_codex_request_compression.go | 69 +-- .../openai_codex_request_compression_test.go | 58 +- .../service/openai_codex_turn_scope_test.go | 61 --- .../service/openai_codex_turn_state.go | 160 ++---- .../service/openai_codex_turn_state_test.go | 29 +- .../service/openai_compat_model_test.go | 29 +- .../service/openai_gateway_forward.go | 15 +- .../service/openai_gateway_messages.go | 38 +- .../service/openai_gateway_passthrough.go | 27 +- .../openai_gateway_response_handling.go | 32 +- .../service/openai_gateway_scheduling.go | 33 +- .../service/openai_gateway_service.go | 1 - .../openai_gateway_turn_state_commit_test.go | 21 +- .../internal/service/openai_gateway_usage.go | 19 +- .../service/openai_messages_continuation.go | 35 +- .../internal/service/openai_model_mapping.go | 24 - .../service/openai_plugin_transport.go | 40 +- .../service/openai_proxy_stream_circuit.go | 10 +- .../service/openai_reasoning_recovery.go | 1 - .../service/openai_setup_token_compat_test.go | 4 +- .../openai_upstream_transport_error.go | 4 +- .../internal/service/openai_v183_compat.go | 6 +- backend/internal/service/openai_ws_client.go | 8 - .../service/openai_ws_forwarder_ingress.go | 54 +- .../service/openai_ws_forwarder_v2.go | 22 +- ...ai_ws_forwarder_v2_execution_scope_test.go | 24 +- .../internal/service/openai_ws_http_bridge.go | 11 +- .../openai_ws_http_bridge_isolation_test.go | 41 +- .../service/openai_ws_http_bridge_test.go | 10 +- .../openai_ws_v2_passthrough_adapter.go | 4 - .../internal/service/plugin_codex_identity.go | 111 ---- .../service/plugin_codex_identity_test.go | 108 ---- .../internal/service/plugin_codex_recovery.go | 98 +--- .../service/plugin_codex_recovery_test.go | 75 +-- .../service/plugin_codex_scope_test.go | 247 --------- .../service/plugin_codex_transport.go | 54 -- .../service/plugin_prompt_fixture_test.go | 51 -- backend/internal/service/plugin_provider.go | 10 - backend/internal/service/policy_context.go | 3 +- .../internal/service/policy_context_test.go | 31 +- backend/internal/service/quota_estimate.go | 19 +- .../internal/service/quota_estimate_test.go | 20 + backend/internal/service/ratelimit_service.go | 3 - .../internal/service/setting_codex_runtime.go | 41 ++ backend/internal/service/wire.go | 12 +- backend/internal/testextensions/fixtures.go | 20 +- .../264_purge_codex_runtime_plugin_data.sql | 188 +++++++ deploy/.env.example | 1 + deploy/config.example.yaml | 2 + .../accountOperationIdempotency.spec.ts | 53 +- .../src/api/accountOperationIdempotency.ts | 4 +- frontend/src/api/admin/codexRuntime.ts | 18 +- .../account/BulkEditAccountModal.vue | 2 +- .../components/account/CreateAccountModal.vue | 13 +- ...reateAccountModal.codexFingerprint.spec.ts | 217 -------- .../__tests__/CreateAccountModal.spec.ts | 22 + .../account-jobs/AccountOperationDialog.vue | 4 +- .../account-jobs/AccountOperationProgress.vue | 1 - .../admin/account/AccountDetailsDrawer.vue | 4 - .../admin/account/AccountTestModal.vue | 9 +- .../account/AccountTestReasoningSelect.vue | 9 +- .../admin/account/AccountTextTestPrompt.vue | 8 +- .../admin/account/CodexFingerprintPanel.vue | 118 ---- .../__tests__/CodexFingerprintPanel.spec.ts | 69 --- .../admin/codex/CodexRuntimeSettings.vue | 53 +- .../__tests__/CodexRuntime.native.spec.ts | 28 +- .../layout/__tests__/AppSidebar.spec.ts | 1 - .../src/i18n/locales/en/admin/accountTasks.ts | 2 - .../src/i18n/locales/en/admin/accounts.ts | 3 +- .../src/i18n/locales/zh/admin/accountTasks.ts | 2 - .../src/i18n/locales/zh/admin/accounts.ts | 3 +- frontend/src/styles/console/controls.css | 2 +- .../src/styles/console/pages/accounts.css | 5 +- frontend/src/types/index.ts | 3 - .../__tests__/accountQualityCheck.spec.ts | 6 +- frontend/src/utils/accountQualityCheck.ts | 7 +- .../admin/__tests__/AccountsView.lite.spec.ts | 1 - .../AccountsView.selectionScope.spec.ts | 1 - .../admin/__tests__/SettingsView.spec.ts | 1 - 208 files changed, 1661 insertions(+), 10868 deletions(-) delete mode 100644 .downstream/codex-quality-diagnostics.md rename .downstream/{gpt6-routing-and-prompts.md => gpt6-models-and-prompts.md} (76%) delete mode 100644 backend/internal/codexruntime/core/module.go delete mode 100644 backend/internal/codexruntime/core/module_test.go delete mode 100644 backend/internal/codexruntime/profile/transport.go delete mode 100644 backend/internal/codexruntime/profile/transport_test.go delete mode 100644 backend/internal/handler/admin/account_codex_fingerprint.go delete mode 100644 backend/internal/handler/admin/account_codex_quality.go delete mode 100644 backend/internal/handler/admin/setting_codex_runtime.go delete mode 100644 backend/internal/handler/admin/setting_codex_runtime_receipt_test.go create mode 100644 backend/internal/handler/admin/setting_handler_codex_runtime_test.go delete mode 100644 backend/internal/handler/admin/setting_handler_codex_ticket_test.go delete mode 100644 backend/internal/nativeapi/account_create.go delete mode 100644 backend/internal/nativeapi/account_edit.go delete mode 100644 backend/internal/nativeapi/account_view.go create mode 100644 backend/internal/nativeapi/codex_runtime.go delete mode 100644 backend/internal/nativeapi/codex_transport.go delete mode 100644 backend/internal/nativeapi/resources.go delete mode 100644 backend/internal/repository/codex_native_lease.go delete mode 100644 backend/internal/repository/codex_native_lease_monitor_test.go delete mode 100644 backend/internal/repository/codex_native_runtime.go delete mode 100644 backend/internal/repository/codex_native_runtime_test.go delete mode 100644 backend/internal/repository/codex_native_state.go delete mode 100644 backend/internal/repository/codex_quality_breaker.go create mode 100644 backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go delete mode 100644 backend/internal/repository/codex_ticket_purge_migration_integration_test.go delete mode 100644 backend/internal/service/account_job_legacy.go delete mode 100644 backend/internal/service/account_job_legacy_test.go delete mode 100644 backend/internal/service/codex_client_reference.json delete mode 100644 backend/internal/service/codex_credential_owner.go delete mode 100644 backend/internal/service/codex_credential_owner_test.go delete mode 100644 backend/internal/service/codex_identity_observation.go delete mode 100644 backend/internal/service/codex_identity_observation_test.go delete mode 100644 backend/internal/service/codex_model_guard.go delete mode 100644 backend/internal/service/codex_model_guard_test.go delete mode 100644 backend/internal/service/codex_native_compatibility_test.go delete mode 100644 backend/internal/service/codex_native_contract.go delete mode 100644 backend/internal/service/codex_native_fixture_test.go delete mode 100644 backend/internal/service/codex_native_host.go delete mode 100644 backend/internal/service/codex_native_provider.go delete mode 100644 backend/internal/service/codex_native_provider_receipt_test.go delete mode 100644 backend/internal/service/codex_native_runtime.go delete mode 100644 backend/internal/service/codex_native_ws_observation.go delete mode 100644 backend/internal/service/codex_profile_selection.go delete mode 100644 backend/internal/service/codex_quality_composition_test.go delete mode 100644 backend/internal/service/codex_quality_contract_test.go delete mode 100644 backend/internal/service/codex_quality_health.go delete mode 100644 backend/internal/service/codex_quality_manual_stop_test.go delete mode 100644 backend/internal/service/codex_quality_observation.go delete mode 100644 backend/internal/service/codex_quality_observation_test.go delete mode 100644 backend/internal/service/codex_quality_runtime.go delete mode 100644 backend/internal/service/codex_quality_state.go delete mode 100644 backend/internal/service/codex_quality_state_test.go delete mode 100644 backend/internal/service/codex_quality_test.go delete mode 100644 backend/internal/service/codex_quality_wire_test.go delete mode 100644 backend/internal/service/codex_wire_fingerprint.go delete mode 100644 backend/internal/service/codex_wire_fingerprint_test.go delete mode 100644 backend/internal/service/openai_codex_logical_turn.go delete mode 100644 backend/internal/service/openai_codex_turn_scope_test.go delete mode 100644 backend/internal/service/plugin_codex_identity.go delete mode 100644 backend/internal/service/plugin_codex_identity_test.go delete mode 100644 backend/internal/service/plugin_codex_scope_test.go delete mode 100644 backend/internal/service/plugin_codex_transport.go delete mode 100644 backend/internal/service/plugin_prompt_fixture_test.go delete mode 100644 backend/internal/service/plugin_provider.go create mode 100644 backend/internal/service/setting_codex_runtime.go create mode 100644 backend/migrations/264_purge_codex_runtime_plugin_data.sql delete mode 100644 frontend/src/components/account/__tests__/CreateAccountModal.codexFingerprint.spec.ts delete mode 100644 frontend/src/components/admin/account/CodexFingerprintPanel.vue delete mode 100644 frontend/src/components/admin/account/__tests__/CodexFingerprintPanel.spec.ts diff --git a/.downstream/codex-quality-diagnostics.md b/.downstream/codex-quality-diagnostics.md deleted file mode 100644 index fe3969780886..000000000000 --- a/.downstream/codex-quality-diagnostics.md +++ /dev/null @@ -1,59 +0,0 @@ -# Codex 固定账号质量诊断 - -此入口用于管理员在真实 `/v1/responses` 链路上,对一个固定的 OpenAI OAuth 账号运行有预算的对照。完整响应、上游模型声明和回答质量是不同证据;服务健康或完整响应不代表回答质量。 - -诊断发送走该账号的普通转发路径:代理、传输、出站身份、请求头和 zstd 压缩都与业务流量相同,没有路由采集、Cookie、连接租约或 acquire/verify 阶段。诊断授权、预算、原始观测和私有选号仍独立于普通调度。 - -## 接口与范围 - -所有管理操作位于 `/api/v1/admin/accounts/:id/codex-quality-runs`,要求真实管理员身份。运行绑定该管理员自己的业务 Key 及其分组、目标账号、账号代理、凭据主体、模型、推理强度和题面 SHA-256。 - -| 操作 | 请求 | 行为 | -| --- | --- | --- | -| `POST` 基础路径 | `run_id`、`api_key_id`、`prompt_sha256`、`model`、`reasoning_effort`、`max_sends`、`ttl_seconds` | 创建或恢复同一运行并换发短时 grant。`max_sends` 为 0 或缺省时取 60,范围 1–60;`ttl_seconds` 为 0 或缺省时取 7200,范围 60–7200 | -| `GET /:run_id` | 无正文 | 任一管理员可读绑定、预算和每次发送的原始上游观测;不返回 grant | -| `POST /:run_id/close` | 空对象 | 仅创建者可关闭;吊销 grant,保留账本 | - -`POST /:run_id/renew-route` 已删除,请求返回 404。成功响应为 `{"code":0,"message":"success","data":<运行视图>}`;服务端拒绝为 HTTP 409,消息给出具体原因。 - -`run_id` 与 trial 使用规范化 UUID。`model` 与 `reasoning_effort` 必填,去除首尾空白,模型名最多 256 字节。同一 run 恢复时,管理员、Key、账号、分组、代理、凭据主体、模型、强度、题面和 `max_sends` 都不能改变;换发 grant 使旧 grant 失效,已花费次数保留,预算不增长;closed 运行不能重开。 - -创建时校验推理强度:须属于账号测试按模型解析出的档位(#224);解析不到档位时须为 `none`、`minimal`、`low`、`medium`、`high`、`xhigh` 之一,账号模型元数据声明该模型无推理时拒绝。Key 分组(OpenAI 或组合分组)的推理强度上限、映射或超限拒绝会改写或拒绝该强度时也拒绝创建:普通转发在准入后套用该策略,最终强度与绑定值不同的发送会被拒绝。创建、准入、私有选号和发送前预留都重新检查账号: - -- 手动停调度(`schedulable=false`)、非影子的 OpenAI OAuth 账号,`plan_type` 为 `pro` 或 `chatgpt_pro`;仍在 Key 的分组中,代理和凭据主体与创建时一致。 -- 模型按普通转发规则可用:账号支持该模型,渠道定价和上游渠道不限制它,渠道映射与账号映射都不改名;诊断只发送绑定模型本身。 -- 没有账号运行时阻断、模型临时阻断、代理流熔断、运行时熔断(Redis)或调度阈值暂停,满足分组隐私要求;Key 有效、未过期、额度未用尽。私有选号还须取得账号并发槽。 - -## 业务发送 - -业务仍用绑定的 Key 作为 `Authorization: Bearer`,并各携带一个 `X-Sub2API-Quality-Grant` 和 `X-Sub2API-Quality-Trial`(每次发送一个新的规范 UUID,同一 trial 不能再次发送)。只接受 HTTP `POST /v1/responses`;正文为最多 1 MiB、无重复键的 JSON,顶层字段限于 `model`、`input`、`reasoning`、`stream`、`store`、`prompt_cache_key`、`client_metadata`。`input` 必须是 SHA-256 与绑定一致的原题字符串,`model` 与 `reasoning.effort` 等于绑定值,`stream=true`、`store=false`。 - -grant 与 trial header 在 handler 开始时移入私有执行上下文,不转发给上游。错误、过期、已换发或已消费的授权直接失败,不回落到普通选号:准入失败返回 409 `codex_quality_unavailable`,原因只写入服务日志和 Ops 记录;进入转发后的失败(含发送前预留被拒绝)返回 502 `codex_quality_failed`。 - -- 私有选号只使用运行绑定的账号,仅在该私有判断中跳过手动停调度开关;账号、Key、分组和代理配置不修改。 -- 最终出站请求(含 zstd)生成后再核对运行、Key 和账号,并要求最终正文仍携带绑定的模型与强度,否则拒绝发送且不花费预算;随后以持久 CAS 预留一次发送,再开始上游 I/O。传输结果未知的发送保留为 `unknown` 并计入预算,不能重放该 trial;预留次数是保守的发送上限,不能宣称每次预留都得到了上游响应。 -- 读取次序固定为网络 → 原始诊断观察 → 模型声明守卫 → 业务响应改写。`created_model`、`terminal_model`、HTTP/事件模型头分别保留,模型头名称不区分大小写,冲突记入 `header_models`。被守卫拦下的已读原始帧仍属于同一次发送,未读取的正文模型保持未知;缺失的 token 数据为 null,不替换为零。 -- 首输出前的模型错配不写入下游,已经输出的请求不会自动重放。SSE 单事件上限复用 `Gateway.MaxLineSize`,JSON 复用 `Gateway.UpstreamResponseReadMaxBytes`,不使用无界响应缓冲。 -- 读取最终请求时兼容 reasoning recovery 清除 `GetBody` 的不可重放请求:有界读取实际编码字节后原样恢复 `Body`,保持 `GetBody=nil`、编码头和长度;编码体与解码体分别最多 2 MiB 和 1 MiB。读取或关闭失败时阻止发送,不预留预算,不重新启用透明 POST 重放。 -- 普通健康、调度恢复、proxy circuit 和自动恢复通知与诊断隔离;实际用量账单及额度快照按真实请求记录。诊断发送与普通流量一样记入账号“Codex 出站指纹”的最近实际出站。 -- `completed` 只代表原始上游成功终态观测;同一条记录仍可能因早期模型、模型头冲突或客户端取消而不能作为通过样本。它不能替代模型核验或数学判分。 - -普通 Codex 的 Responses-Lite 请求保留 `input` 中已有基础提示和工具,不再额外填入顶层默认 `instructions`。OAuth STATE 只在同一账号主体、会话和显式逻辑 turn 内保留首个已提交值;Messages 只有完整响应成功写入下游后才保存。缺少 turn 证据不复用旧值,API Key 会话续接契约保持。 - -## 账本 - -账本存于 `sub2api_plugin_state`:plugin key `codexrip.codex-runtime`,namespace `codex-routing-private`,key `quality-run.`。新记录显式保存 `model`、`reasoning_effort` 和凭据主体 `owner_identity`;账本不保存 grant、Key、题面、Cookie 或 STATE 值。 - -路由资格诊断时期写入的旧记录(无 `model`)按 `gpt-6-astra`/`high` 显示且只读:GET 和 close 仍可用,同一 `run_id` 不能再创建、恢复或发送(409,需换用新 run id)。旧记录的 `scope`、`qualification`、`route_generation`、`route_runtime_generation` 及旧 attempt 字段在重写(如 close)时原样保留;视图只对旧记录只读显示 `route_generation`、`route_expires_at` 和 `connection_fingerprint`,旧 attempt 可能带 `acquire`/`verify` 阶段。旧记录引用的路由 bundle 与 Cookie 状态(`bundle.*`、`clock.*`、`seen.*`)已由迁移 263 删除,账本本身原样保留。 - -## 运维执行器 - -项目运维目录的 `scripts/invoke-codex-quality-acceptance.py` 驱动上述接口,用法见该目录 `scripts/README.md`。新运行必须使用新账本并显式给出 `--model` 与 `--reasoning-effort`,二者随账本绑定;`artifacts/evidence/codex-quality-20260923.json` 是路由资格诊断时期的账本,只读,仍可 `status`、`local-status`、`review`。 - -`baseline`(6 次)与 `verification`(12 次)为真实生成操作,必须传发布者核实的 `--version` 和 `--source-sha`;前两次只观察,修后计分序列从首个计分完成响应起至少跨 6 分钟。生成期间由单一执行者持有本地账本锁,发布者不得同时替换容器。 - -`review` 只对已有 trial 登记数学结论,不调用模型。通过条件包括完整响应、固定账号、绑定的模型与强度、原始模型和全部已提供模型头一致,以及答案和证明均正确;不能用正则找到“21”便自动通过。题目基准为 9 圆+12 星,且需证明 20 不足。 - -`reconcile` 不发送模型请求。只有收到应用明确的发送前拒绝、服务器账本也无该 trial,才允许保留原记录并安排新 trial;网络超时或代理错误不能当作“没发送”。诊断在第一次业务发送前失败时,可在仅修诊断的新版本上线并排空旧进程后执行 `restart-baseline --version --source-sha --reason `;它不发模型请求、不重置服务器账本。 - -修前基线与修后序列须在同一账号、模型和强度下采集;不能先应用修复再把结果命名为修前基线。 diff --git a/.downstream/gpt6-routing-and-prompts.md b/.downstream/gpt6-models-and-prompts.md similarity index 76% rename from .downstream/gpt6-routing-and-prompts.md rename to .downstream/gpt6-models-and-prompts.md index c75822d07158..e9cb2e0850ca 100644 --- a/.downstream/gpt6-routing-and-prompts.md +++ b/.downstream/gpt6-models-and-prompts.md @@ -1,6 +1,6 @@ -# GPT-6 模型、Codex 出站与提示词 +# GPT-6 模型、Codex 运行与提示词 -官方基线见 [upstream-base](upstream-base)。内置提示词能力在宿主内原生运行,随宿主交付;第三方插件框架独立保留。GPT-6 Sol/Luna 的定价与识别沿用官方实现,Codex 出站指纹与模型守卫、指令模板、实时容量和日期快照约定见下文。 +官方基线见 [upstream-base](upstream-base)。内置提示词能力在宿主内原生运行,随宿主交付;第三方插件框架独立保留。GPT-6 Sol/Luna 的定价与识别沿用官方实现,指令模板、实时容量和日期快照约定见下文。 ## 模型同步与容量 @@ -24,19 +24,9 @@ API Key 使用完整 Responses。OAuth 目录保留实际 Lite 与上下文字 两款型号有独立价卡及严格大于 272,000 输入的长请求倍率,保留人工定价。未知 GPT-6 名称不套 Astra 身份或价格。固定来源与指令摘要见 [官方提取记录](../backend/internal/pkg/openai/gpt6_codex_reference.json)。 -## Codex 出站指纹与模型守卫 +## Codex 流式响应 -Codex 路由采集(打票:采集与复验探针、Cookie 路由资格、连接租约、调度与请求路由门控)已删除。OpenAI OAuth 账号的调度和转发不再需要路由资格,经账号普通代理与传输发送。客户端 WS 入站经 HTTP bridge 的条件恢复为官方 v0.2.11 行为(例如 Grok 账号或插件管理器接管 OAuth 时强制 bridge);GPT-6 不再因路由资格被强制改走 HTTP bridge。 - -- 模型声明守卫:OAuth 账号发往 `chatgpt.com/backend-api/codex/responses`(不含 compact)的 HTTP 请求,在普通转发、透传(含 WS 的 HTTP bridge)、Messages 和账号测试路径上,要求 200 响应声明的模型等于请求模型,否则以模型错配失败;首输出前的错配不写入下游。API Key 账号、compact 和原生 WS 帧不经此守卫。 -- 原生 turn-state 在同一轮固定首次值,下一轮或换账号清除。 -- ChatGPT 边缘自 2026-09-23 起不再为 `/backend-api/codex/responses` 的流式响应发送 `Content-Type`。宿主对声明接受 SSE 的 200 响应补回 `text/event-stream`,模型守卫对无类型响应按 SSE 帧识别。 - -账号详情右栏的“Codex 出站指纹”读取配置和已经记录的出站事实,刷新不会发模型请求。页面展示配置来源、profile、UA、版本和身份收敛方式,以及最近实际出站的入站与上游传输、请求压缩、TLS/ALPN、账号代理、STATE 和身份字段一致性。新建账号采用官方 Windows CLI 0.156.0 的应用层参照;已有配置保留,可显式选择新版参照,设备种子和手动 UA 覆盖不被重置。 - -本机官方 CLI 通过隔离合成认证、本地可信证书和受控 HTTP/WS 端点取得参照。生产 Go TLS 的实际值与参照分开展示;未捕获生产 ClientHello 时不宣称 JA3/JA4 相同。Bearer 和代理密码不进入这些页面或通用诊断。 - -管理员接口位于 `/api/v1/admin/accounts/:id/codex-fingerprint`,profile 选择使用其 `/profile` 子路径和账号版本 CAS。 +ChatGPT 边缘自 2026-09-23 起不再为 `/backend-api/codex/responses` 的流式响应发送 `Content-Type`。宿主对声明接受 SSE 的 200 响应补回 `text/event-stream`。 ## 系统提示词 @@ -61,7 +51,7 @@ Claude OAuth 伪装系统块只由上游设置决定:设置 → 网关的 `ena ## Codex 运行设置 -侧栏“Codex 运行设置”(`/admin/codex-runtime`)只保留“压缩 Codex Responses 请求体”开关和保存(沿用 TOTP 二次验证),对应 `GET/PUT /api/v1/admin/settings/codex-runtime` 的 `{"request_zstd": }`;接口契约见 [native domains](native-domains.md)。 +侧栏“Codex 运行设置”(`/admin/codex-runtime`)有一个开关“压缩 Codex Responses 请求体”:开启时 OAuth 账号发往 ChatGPT Codex 后端的流式 `/responses` 请求体以 zstd 压缩发送,compact、models 等其他请求保持明文。保存沿用 TOTP 二次验证,对应 `GET/PUT /api/v1/admin/settings/codex-runtime` 的 `{"request_zstd": }`;接口契约与未保存时的默认值见 [native domains](native-domains.md)。 ## 账号连接测试与批量测试 diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 5435074a47ed..111ce45bc5aa 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -12,110 +12,52 @@ authentication and the existing step-up policy. | Interface | Contract | | --- | --- | -| `GET/PUT /admin/settings/codex-runtime` | Codex runtime configuration `{"request_zstd": }` (request body compression), without an additional response envelope; `Cache-Control: no-store`. The source is encrypted at rest. | +| `GET/PUT /admin/settings/codex-runtime` | Codex request body compression switch (`request_zstd`; a missing key is on). | | `GET/PUT /admin/settings/image-tools` | Image Studio switch (`studio_enabled`; a missing key is off). | | `GET/PUT /admin/settings/observability` | Telemetry and native theme switches. | -A Codex runtime `PUT` without `request_zstd` stores `true`. The keys of the -retired Codex route acquisition (`enabled`, `fail_closed`, `proxy_url`, -`proxy_protocol`, `proxy_selection_id`, `models`, `routing_schema`) are dropped -and not stored again; any other key, or a `request_zstd` that is not a boolean -(including `null`), returns HTTP 400. +Each of the three is stored as one `settings` row holding a JSON object of +boolean switches; `GET` and `PUT` answer with the switches in the standard +response envelope. A `PUT` body that is not a JSON object, or that carries any +other key or a non-boolean value (including `null`), returns HTTP 400 and stores +nothing. While no Codex runtime row is stored, +`gateway.openai_codex_request_zstd` decides the compression switch. -The route acquisition endpoints (`/admin/accounts/codex-tickets/*`, -`/admin/accounts/:id/codex-tickets/*`, `/admin/accounts/:id/codex-routing/validate` -and `/admin/settings/openai-codex-ticket/*`) are removed. Migration 263 deletes -the job rows of the retired kinds `codex_ticket_harvest`, `codex_ticket_stop` -and `extension_operation`; their display names are gone. A job whose kind this -version has no executor for still lists and reads with `retry_eligible=false` -and `retry_unavailable_reason=kind_unsupported`; a retry returns HTTP 409, and -its items still pending fail with `kind_unsupported`. Ordinary account bulk editing -remains HTTP 202; this change does not replace background tasks with synchronous -editing. Existing API-key reveal and account-field protections remain. - -### Codex runtime receipt after v0.2.8-codexrip.5 - -The headers below extend the native API after `v0.2.8-codexrip.5`. -That published image remains immutable; these headers are not part of its contract. - -`GET /admin/settings/codex-runtime` retains the raw JSON body and -`Cache-Control: no-store`, and adds: - -| Response header | Value | -| --- | --- | -| `X-Sub2API-Codex-Hosting-Mode` | `native` | -| `X-Sub2API-Codex-Config-Version` | Active snapshot configuration version. | -| `X-Sub2API-Codex-Config-SHA256` | Lowercase SHA-256 of the exact snapshot body bytes. | -| `X-Sub2API-Codex-Runtime-Generation` | Active snapshot runtime generation. | - -Version and generation are positive signed 64-bit integers serialized as canonical -decimal strings without leading zeros. The hash covers the exact `snapshot.raw` -UTF-8 bytes after decoding any response `Content-Encoding`; do not reorder JSON, -pretty-print it, or append a newline. - -HTTP 200 requires the body and receipt to come from the same active snapshot, -whose runtime has started and is not cancelled, with matching persisted fence -metadata and configuration hash. Otherwise the endpoint returns HTTP 503 with -`{"code":503,"message":"Codex runtime is unavailable"}`, without receipt headers -or raw configuration. The receipt describes the epoch observed by that read; -later operations still use their existing execution fences. Retained -disabled installation rows stay unchanged; no generic metadata endpoint is added. +Ordinary account bulk editing remains HTTP 202; this change does not replace +background tasks with synchronous editing. Existing API-key reveal and +account-field protections remain. ## Startup and retained data -Before loading native settings or starting either runtime, one transaction: +Migrations run first. Migration 264 stops startup, changing nothing, on a +database whose `codexrip.codex-runtime` installation was never retired, whose +saved Codex runtime configuration has no hash recorded by v0.2.11-codexrip.6, +.7 or .8, or whose `sub2api_plugin_state` table holds rows of another plugin +key. Starting one of those three releases once satisfies the first two +conditions. + +Then, before native settings load and before Image Studio or the plugin manager +starts, one transaction retires the other first-party plugin installations a +database still holds: 1. Validates the saved first-party capability scopes and decrypts configuration. 2. Imports the effective image and observability switches only when their native settings keys are absent. Existing keys are never overwritten. The retired Cindy provider installation is disabled without importing its switches. 3. Saves original installation/binding/bootstrap intent under - `deplugin_retired_plugins`, disables the seven installations and their bindings, + `deplugin_retired_plugins`, disables the installations and their bindings, and marks their bootstrap records removed. -4. Advances the existing Codex `runtime_generation` once. A fresh database instead - receives a disabled state anchor with generation 1 and no executable/artifact. Missing settings may use deployment defaults. Database errors, malformed saved settings and unknown/non-equivalent capability scopes stop startup; they never -silently enable features. A disabled whole Codex installation requires an -explicit migration decision rather than automatic activation. - -The original installation rows, encrypted configuration, artifacts, state and -lease tables remain. The upstream plugin manager cannot list, modify, delete or -replace the retired first-party keys. Native state access uses the original -`codexrip.codex-runtime` owner, namespaces, state keys and CAS. -Closed quality-run records and their budgets are not rewritten. - -An unchanged normalized configuration keeps its epoch and generation. A changed -Codex configuration is validated, the old epoch is drained, and encrypted source, -configuration hash/version and generation are committed together. A stored -configuration that still holds the retired route acquisition keys loads with -those keys dropped. Its normalized hash then differs from the recorded one, so -the first start advances `runtime_generation` once and records the new -configuration hash and version; the encrypted source keeps its old bytes until -the next save. - -Migration `263_purge_retired_codex_ticket_data.sql` deletes the data written by -the retired route acquisition: the triggers, trigger functions and tables of -migrations 244 and 245; the settings `openai_codex_ticket_enabled` and -`openai_codex_ticket_harvest_proxy_url`; the `codexrip.codex-runtime` state -namespaces `tickets`, `routing-demand` and `proxy-trust` and its -`routing-account` and `tickets` leases; the route and Cookie material in -`codex-routing-private` (`bundle.*`, `clock.*`, `seen.*`, `validation-result.*`); -the account `extra` keys `codex_turn_ticket:*`, `codex_ticket_runtime:*` and -`codex_harvest_proxy_url` and the `codexrip.codex-runtime` entry of -`plugin_account_projections` (the map goes once it is empty); and job rows of -the retired kinds. `codex-routing-private` keeps the ledgers `quality-run.*`, -`validation.*` and `spent.*`, and the `wire.*` observations without the raw -Cookie values (`cookies`) they stored. Accounts without these keys are not -rewritten. Account create, update and bulk edit drop these `extra` keys and that -projection entry, so importing an account export taken before this release does -not bring them back. The encrypted Codex runtime source keeps its bytes until -the next save, as described above. - -Migration `254_plugin_job_action_digest.sql` preserves full 64-character historical -task action digests. History and retries continue to use saved targets; unsupported -legacy operations fail without choosing other accounts or models. +silently enable features. An installation that is disabled as a whole, in a +domain without an equivalent native switch, requires an explicit migration +decision rather than automatic activation. + +The retired installation rows remain and are listed read-only, with the +receipt, at `GET /admin/plugins/retired`; the `codexrip.codex-runtime` row holds +no saved configuration, package, manifest or bindings. The upstream plugin +manager cannot list, modify, delete or replace the retired first-party keys. ## Release and rollback boundary @@ -127,25 +69,26 @@ backups, canaries, model calls or automatic rollback. deployer recognizes the old and native prompt/skill directory locations by the same content hashes; missing or ambiguous trees and changed schema remain errors. -Returning to a plugin-based host additionally needs an explicit restoration of -legacy activation/configuration intent before starting that host. The retained -receipt and artifacts supply the original data; **never restore the receipt's old -runtime generation or delete its native anchor**. A plain image change does not -perform this reverse conversion. Such a legacy-host rollback has not been run as -part of the native migration and is not an automatic failure path. - -Migration 263 has no down path; the data it deletes (listed above) comes back -only from a database dump taken before it. An image that still has the route -acquisition starts without its tickets and routes. Images from -`v0.2.8-codexrip.5` (source `9f7b08dfc`) on never read the 244/245 tables; older -images still query them, so returning to one of those needs that dump. +Migration 264 has no down path. An image change back to v0.2.11-codexrip.6, .7 +or .8 after it starts only on a database that keeps a retired +`codexrip.codex-runtime` installation; without that row those images exit at +start. They do not read the stored compression switch, so request compression +follows `gateway.openai_codex_request_zstd`, and without `sub2api_plugin_state` +their outbound fingerprint view and quality-run endpoints fail while forwarding +continues. Images at or before v0.2.11-codexrip.5 also need +`sub2api_plugin_state` and `sub2api_plugin_leases` for their route code and +require a database dump taken before migration 264. + +Returning to a plugin-based host is not an image change. That host needs schema +objects and plugin data the current database no longer holds, so it requires a +database dump that still has them. Such a legacy-host rollback has not been run +and is not an automatic failure path. ## Verification Normal required PR checks validate the native domain packages, account scope, -configuration/lease/ledger compatibility and frontend behavior. The release -reuses that source validation. Health endpoints establish availability only; -actual model-quality diagnostics remain separately budgeted and coordinated. +configuration/storage boundaries and frontend behavior. The release reuses that +source validation. Health endpoints establish availability only. ## Console theme diff --git a/.github/scripts/test_model_context_capacity.sh b/.github/scripts/test_model_context_capacity.sh index 3c875075fe03..4825b053ed2a 100644 --- a/.github/scripts/test_model_context_capacity.sh +++ b/.github/scripts/test_model_context_capacity.sh @@ -7,5 +7,5 @@ cd "$repo_root/backend" # Offline policy, persistence, and wire-contract fixtures only; no model calls. go test -p=1 -tags unit \ ./internal/service ./internal/repository ./internal/handler ./internal/handler/admin ./cmd/server \ - -run 'Test.*(ModelContext|OfficialModelCatalog|ContextCapacit|GroupModelCapacity|ConvertOpenAIModelListLiveCapacity|CodexCapacityProjection|SyncUpstreamModelCatalog.*Capacity|RunCodexContextContractVerification)' \ + -run 'Test.*(ModelContext|OfficialModelCatalog|ContextCapacit|GroupModelCapacity|CodexCapacityProjection|SyncUpstreamModelCatalog.*Capacity|RunCodexContextContractVerification)' \ -count=1 diff --git a/.github/scripts/test_native_domains.sh b/.github/scripts/test_native_domains.sh index f997022816c0..220c6f41ab80 100644 --- a/.github/scripts/test_native_domains.sh +++ b/.github/scripts/test_native_domains.sh @@ -4,14 +4,13 @@ set -euo pipefail repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) cd "$repo_root" -# The same domain policies now compile into the host. Lifecycle concurrency is -# selected once by the workflow's dedicated race step; no package/signing process. +# The same domain policies now compile into the host; no package/signing process. go -C backend test -p=1 -tags unit \ ./internal/accounttools/... \ ./internal/codexruntime/... \ - -skip '^TestModuleServesTransportPlanFromItsConfiguration$' -count=1 + -count=1 -# Preserve configuration/ledger/storage boundaries without starting real IO. +# Preserve configuration/storage boundaries without starting real IO. go -C backend test -p=1 -tags unit ./internal/service ./internal/repository \ - -run '^(TestNativeCodex(ConfigGenerationIsAtomicAndIdempotent|StoredConfigDistinguishesMissingAndReadError|StoredRetiredSettingsStillBoot|ErrorDiagnosticsUseStoredAccountsAndRemovePrivateFields)|TestNativeRuntimeLease.*|TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties|TestNativeFeatureBootstrapPreservesEffectiveConfiguration|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestImageTools.*|TestSwitchingImageStudio(OnStartsTheRuntime|OffStopsDetachedUpstreamIO)|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ + -run '^(TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault|TestPrepareOpenAICodexWireRequest.*|TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt|TestNativePluginBoundaryProtectsRetiredRecordsAndAllowsThirdParties|TestNativeFeatureBootstrapPreservesEffectiveConfiguration|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestImageTools.*|TestSwitchingImageStudio(OnStartsTheRuntime|OffStopsDetachedUpstreamIO)|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ -count=1 diff --git a/.github/scripts/test_upstream_automation_contract.py b/.github/scripts/test_upstream_automation_contract.py index 834357c317b9..578aaef422ed 100644 --- a/.github/scripts/test_upstream_automation_contract.py +++ b/.github/scripts/test_upstream_automation_contract.py @@ -111,7 +111,6 @@ def test_native_ci_preserves_required_checks_and_selects_existing_contracts(self self.assertEqual(len(commands), 2) for command in commands: selector = command[command.index("-run") + 1] if "-run" in command else r"^Test" - skipped = command[command.index("-skip") + 1] if "-skip" in command else r"(?!)" packages = [arg for arg in command if arg.startswith("./")] self.assertTrue(packages) for package in packages: @@ -126,9 +125,8 @@ def test_native_ci_preserves_required_checks_and_selects_existing_contracts(self if build and re.search(r"\b(integration|windows)\b", build.group(1)): continue names.extend(re.findall(r"(?m)^func (Test\w+)\(", source)) - selected = [name for name in names if re.search(selector, name) and not re.search(skipped, name)] + selected = [name for name in names if re.search(selector, name)] self.assertTrue(selected, f"{package}: {selector} selected no unit tests") - self.assertIn("-race ./internal/codexruntime/core -run '^TestModuleServesTransportPlanFromItsConfiguration$'", verify) def test_bot_chain_is_explicit_and_does_not_overwrite_manual_resolutions(self): self.assertIn("upstream_tag:", self.sync) diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 7d8d5ec2a281..ad59ff5a769e 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -42,7 +42,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|AlphaSearch|CodexQuality|SelectedWorkspace)|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|AlphaSearch|SelectedWorkspace)|Test(UpdateService|NormalizeAccountTaxonomyName|FilterConsoleAccounts|AccountConsolePlan|AccountFacetMatcher)' -count=1 - name: Upstream automation classifier tests run: | python3 .github/scripts/test_upstream_risk.py @@ -53,11 +53,9 @@ jobs: - name: Gateway management isolation race test working-directory: backend run: go test -race ./internal/handler -run '^TestOpenAIBlockedUpstreamsDoNotSerializeManagementPlane$' -count=1 - - name: Native policy cancellation race tests + - name: Native policy cancellation race test working-directory: backend - run: | - go test -p=1 -race -tags unit ./internal/service -run '^(TestNativePolicyCancellationSurvivesDetachedClientContext|TestNativeCodexSameConfigSaveKeepsActiveEpoch)$' -count=1 - go test -p=1 -race ./internal/codexruntime/core -run '^TestModuleServesTransportPlanFromItsConfiguration$' -count=1 + run: go test -p=1 -race -tags unit ./internal/service -run '^TestNativePolicyCancellationSurvivesDetachedClientContext$' -count=1 - name: Image, model list, and turn-state tests working-directory: backend run: | @@ -70,18 +68,18 @@ jobs: working-directory: backend run: | go test -p=1 -tags unit ./internal/handler ./internal/handler/admin -run 'Test.*(Refusal|Cyber|Continuation|AlphaSearch)' -count=1 - go test -p=1 ./internal/handler/admin -run 'TestUpdateSettings(PartialPayload|RejectsTwoCaptchaProviders|RequiresFourTencentCaptchaCredentials|RetainsStoredTencentCaptchaCredentials|ValidatesTencentCaptchaAppID|ValidatesTurnstileSiteKey)' -count=1 + go test -p=1 -tags unit ./internal/handler/admin -run 'TestUpdateSettings(PartialPayload|RejectsTwoCaptchaProviders|RequiresFourTencentCaptchaCredentials|RetainsStoredTencentCaptchaCredentials|ValidatesTencentCaptchaAppID|ValidatesTurnstileSiteKey)' -count=1 go test -p=1 ./internal/handler/admin ./internal/service -run 'Test(ExportData|PreviewDataImport|ImportData|DataAccountIdentityKeys|DataIdentityIndex|AccountJobDataImport|OpsMetricsCollectorRecordsDatabasePoolWaitDelta|BulkUpdateAccepts|AccountHandler)' -count=1 go build ./internal/repository - - name: Account job and legacy view contracts + - name: Account job contracts working-directory: backend - run: go test -p=1 -tags unit ./internal/service ./internal/handler/admin ./internal/handler/dto ./internal/repository -run '^(TestAccountJobBulk.*|TestAccountJobRetiredKindsStayReadableButCannotRun|TestAccountJobLegacyViewComparesSavedDigestWithoutQueryingTargets)$' -count=1 + run: go test -p=1 -tags unit ./internal/handler/admin -run '^TestAccountJobBulk.*$' -count=1 - name: Account taxonomy migration integration test working-directory: backend - run: go test -p=1 -tags integration ./internal/repository -run '^(Test(AccountTaxonomy.*|DeleteAccountFolder.*)Integration|TestAPIKeyRepoSuite/TestReleaseAcceptanceKeyIsHiddenButAuthenticates)$' -count=1 + run: go test -p=1 -tags integration ./internal/repository -run '^Test(AccountTaxonomy.*|DeleteAccountFolder.*)Integration$|^TestAPIKeyRepoSuite$/^TestReleaseAcceptanceKeyIsAdminListedReadOnlyAndAuthenticates$' -count=1 - name: Repository retirement and audit integration tests working-directory: backend - run: go test -p=1 -tags integration ./internal/repository -run '^Test(PGXBatchInsertAuditAndSystemLogs|Migration236.*|NativeFeatureRetirementIsAtomicAndPreservesLedger)$' -count=1 + run: go test -p=1 -tags integration ./internal/repository -run '^Test(PGXBatchInsertAuditAndSystemLogs|Migration236.*|NativeFeatureRetirementIsAtomic)$' -count=1 - name: Historical Cindy migration tests working-directory: backend run: | @@ -116,7 +114,6 @@ jobs: src/components/admin/account/__tests__/AccountTestModal.spec.ts \ src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts \ src/components/admin/account/__tests__/AccountTestModal.policyPlan.spec.ts \ - src/components/admin/account/__tests__/CodexFingerprintPanel.spec.ts \ src/components/keys/__tests__/UseKeyModal.spec.ts \ src/views/admin/__tests__/SettingsView.spec.ts \ src/components/common/__tests__/VersionBadge.downstream.spec.ts \ @@ -145,7 +142,6 @@ jobs: src/components/account/__tests__/AccountUsageCell.list.spec.ts \ src/components/account/__tests__/UsageProgressBar.spec.ts \ src/components/account/__tests__/CreateAccountModal.spec.ts \ - src/components/account/__tests__/CreateAccountModal.codexFingerprint.spec.ts \ src/components/payment/__tests__/StripePaymentInline.contrast.spec.ts \ src/views/user/__tests__/StripePaymentView.spec.ts \ src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts \ diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index 9ba1233cfb36..c4ff21441323 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -22,22 +22,12 @@ semantics. A late select-all response cannot replace a newer manual selection. Invalid nonempty ID lists cannot fall back to all filter results. Folder/tag filters, explicit field clearing and untouched-field preservation -remain supported. Old jobs retain actor, encrypted payload, expiry and frozen -item targets. An old filter-only job without a saved target fails rather than -selecting a fresh set of accounts. - -Codex route acquisition has been removed: the route harvest and verification -probes, Cookie-routing qualification, connection leases, the scheduler and -request route gates, and their admin endpoints, settings and job kinds. OpenAI -OAuth accounts are scheduled without route qualification and send through their -ordinary proxy and transport. An OAuth Codex `/responses` reply over HTTP that -declares a different model than the request is still rejected. The Codex -runtime setting keeps only request compression. Migration 263 deletes the data -the feature wrote, old jobs of the retired kinds included; quality ledgers and -the observed outbound requests stay, without stored Cookie values (see -[native domains](.downstream/native-domains.md)). Quality diagnostics bind an -explicit model and reasoning effort and send through the account's ordinary -path; see [quality diagnostics](.downstream/codex-quality-diagnostics.md). +remain supported. A bulk-update item without a saved target account fails with +`target_missing` rather than selecting a fresh set of accounts. + +The Codex runtime setting is one switch, request body compression for the +streaming `/responses` turns of OpenAI OAuth accounts; its contract is in +[native domains](.downstream/native-domains.md). ## Official behavior and downstream contracts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 992fe8685ef5..b138f9b3a622 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -208,13 +208,6 @@ func runMainServer() { log.Fatalf("Failed to initialize application: %v", err) } defer app.Cleanup() - if app.CodexRuntime != nil { - if err := app.CodexRuntime.Start(context.Background()); err != nil { - log.Printf("Native Codex runtime could not start: %v", err) - app.Cleanup() - os.Exit(1) - } - } if app.PluginManager != nil { if err := app.PluginManager.Start(context.Background()); err != nil { log.Printf("Plugin manager started in degraded state: %v", err) diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index a4319ab70d07..1398b2b79f4e 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -32,7 +32,6 @@ type Application struct { AccountJobs *service.AccountJobRuntime ImageStudio *service.ImageStudioRuntime CodexIdentity *service.CodexClientIdentityBackfillService - CodexRuntime *service.NativeCodexRuntime Cleanup func() } @@ -64,7 +63,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { provideCleanup, // Application struct - wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "ImageStudio", "CodexIdentity", "CodexRuntime", "Cleanup"), + wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "ImageStudio", "CodexIdentity", "Cleanup"), ) return nil, nil } @@ -146,7 +145,6 @@ func provideCleanup( accountJobs *service.AccountJobRuntime, imageStudioRuntime *service.ImageStudioRuntime, pluginManager *service.PluginManager, - codexRuntime *service.NativeCodexRuntime, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -469,11 +467,6 @@ func provideCleanup( } } - if codexRuntime != nil { - if err := codexRuntime.Stop(ctx); err != nil { - log.Printf("[Cleanup] native Codex runtime did not stop cleanly") - } - } runParallel(parallelSteps) if pluginManager != nil { pluginManager.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 676ec7575e4f..cb405bb22d92 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -219,8 +219,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { pluginRepository := repository.ProvidePluginRepository(db, nativeFeatureBootstrap) pluginHostInfo := providePluginHostInfo(buildInfo) pluginKVStore := repository.NewPluginKVStore(redisClient) - accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig) - pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, quotaActivityService, openAIGatewayService, accountTrafficObserveCache) + pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, openAIGatewayService) accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager, quotaActivityService) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) @@ -256,7 +255,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) - settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService, secretEncryptor) + settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -309,11 +308,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) + accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig) accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache, configConfig) openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, settingService, openAIGatewayService, openCodeGoUsageService, claudeResetCreditService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, openCodeGoUsageService, claudeResetCreditService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -366,8 +366,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } codexClientIdentityBackfillService := service.ProvideCodexClientIdentityBackfillService(accountRepository) - nativeCodexRepository := repository.NewNativeCodexRepository(db, accountRepository) - nativeCodexRuntime := service.ProvideNativeCodexRuntime(openAIGatewayService, nativeCodexRepository, settingRepository, secretEncryptor, configConfig, nativeFeatureBootstrap, quotaActivityService) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository) @@ -389,7 +387,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, imageStudioRuntime, pluginManager, nativeCodexRuntime) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, imageStudioRuntime, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -397,7 +395,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { AccountJobs: accountJobRuntime, ImageStudio: imageStudioRuntime, CodexIdentity: codexClientIdentityBackfillService, - CodexRuntime: nativeCodexRuntime, Cleanup: v, } return application, nil @@ -412,7 +409,6 @@ type Application struct { AccountJobs *service.AccountJobRuntime ImageStudio *service.ImageStudioRuntime CodexIdentity *service.CodexClientIdentityBackfillService - CodexRuntime *service.NativeCodexRuntime Cleanup func() } @@ -493,7 +489,6 @@ func provideCleanup( accountJobs *service.AccountJobRuntime, imageStudioRuntime *service.ImageStudioRuntime, pluginManager *service.PluginManager, - codexRuntime *service.NativeCodexRuntime, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -815,11 +810,6 @@ func provideCleanup( } } - if codexRuntime != nil { - if err := codexRuntime.Stop(ctx); err != nil { - log.Printf("[Cleanup] native Codex runtime did not stop cleanly") - } - } runParallel(parallelSteps) if pluginManager != nil { pluginManager.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index f373d7d19410..99e59704226d 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -112,7 +112,6 @@ func minimalDependencyCleanup(autoReset *service.OpenAIQuotaAutoResetService) fu nil, // accountJobRuntime nil, // imageStudioRuntime nil, // pluginManager - nil, // nativeCodexRuntime ) } diff --git a/backend/internal/codexruntime/core/module.go b/backend/internal/codexruntime/core/module.go deleted file mode 100644 index 2ae8895a500e..000000000000 --- a/backend/internal/codexruntime/core/module.go +++ /dev/null @@ -1,178 +0,0 @@ -// Package core hosts the native Codex runtime module: reasoning-replay -// recovery, Codex client identity and request transport planning. -package core - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "slices" - "strings" - "sync" - - "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" - "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// Config is the complete runtime configuration. -type Config struct { - RequestZstd bool `json:"request_zstd"` -} - -// retiredSettings belonged to the removed Codex route-qualification feature. -// Stored configurations and older admin pages may still carry them; they are -// dropped without being read, so such a configuration still loads. -var retiredSettings = map[string]bool{ - "enabled": true, "fail_closed": true, "proxy_url": true, "proxy_protocol": true, - "proxy_selection_id": true, "models": true, "routing_schema": true, -} - -// ErrStopping is returned while the module stops. It wraps context.Canceled -// for callers that treat it so. -var ErrStopping = fmt.Errorf("codex runtime is stopping or reloading: %w", context.Canceled) - -type Module struct { - lifecycleMu sync.Mutex - mu sync.RWMutex - started bool - accepting bool - work sync.WaitGroup - config Config -} - -func NewModule() *Module { - return &Module{accepting: true, config: defaultConfig()} -} - -func defaultConfig() Config { return Config{RequestZstd: true} } - -// ValidateConfig returns the normalized configuration. Retired settings are -// dropped; any other unknown or null setting is rejected. -func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.RawMessage, error) { - cfg := defaultConfig() - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return nil, fmt.Errorf("invalid codex runtime configuration: %w", err) - } - if fields == nil { - return nil, errors.New("invalid codex runtime configuration: expected a JSON object") - } - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("invalid codex runtime configuration: %w", err) - } - var nullKeys, unknownKeys []string - for key, value := range fields { - switch { - case retiredSettings[key]: - case key == "request_zstd": - if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - nullKeys = append(nullKeys, key) - } - default: - unknownKeys = append(unknownKeys, key) - } - } - if len(unknownKeys) > 0 { - slices.Sort(unknownKeys) - return nil, fmt.Errorf("unknown codex runtime setting: %s", strings.Join(unknownKeys, ", ")) - } - if len(nullKeys) > 0 { - return nil, fmt.Errorf("null codex runtime setting: %s", strings.Join(nullKeys, ", ")) - } - return json.Marshal(cfg) -} - -// Start opens the module for transport planning. It has no background work. -func (m *Module) Start(ctx context.Context) error { - m.lifecycleMu.Lock() - defer m.lifecycleMu.Unlock() - m.mu.RLock() - started := m.started - m.mu.RUnlock() - if started { - return nil - } - if err := m.drain(ctx); err != nil { - return err - } - m.mu.Lock() - m.started, m.accepting = true, true - m.mu.Unlock() - return nil -} - -// Stop refuses new transport plans and waits for the ones in progress. -func (m *Module) Stop(ctx context.Context) error { - m.lifecycleMu.Lock() - defer m.lifecycleMu.Unlock() - m.mu.Lock() - m.started = false - m.mu.Unlock() - return m.drain(ctx) -} - -func (m *Module) drain(ctx context.Context) error { - m.mu.Lock() - m.accepting = false - m.mu.Unlock() - done := make(chan struct{}) - go func() { m.work.Wait(); close(done) }() - select { - case <-done: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// ApplyConfig replaces the configuration. An invalid configuration leaves the -// current one in effect. -func (m *Module) ApplyConfig(ctx context.Context, raw json.RawMessage) error { - normalized, err := m.ValidateConfig(ctx, raw) - if err != nil { - return err - } - var cfg Config - if err = json.Unmarshal(normalized, &cfg); err != nil { - return err - } - m.lifecycleMu.Lock() - defer m.lifecycleMu.Unlock() - m.mu.Lock() - m.config, m.accepting = cfg, true - m.mu.Unlock() - return nil -} - -func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Capability == extensionv1.CapabilityRecovery { - return recovery.Invoke(ctx, in) - } - if in.Capability == extensionv1.CapabilityRequest && strings.HasPrefix(in.Operation, "codex.identity.") { - return profile.Invoke(ctx, in) - } - m.mu.Lock() - if !m.accepting { - m.mu.Unlock() - return extensionv1.Result{}, ErrStopping - } - cfg := m.config - m.work.Add(1) - m.mu.Unlock() - defer m.work.Done() - if in.Capability != extensionv1.CapabilityRequest || in.Operation != "codex.transport.plan" { - return extensionv1.Result{}, errors.New("unsupported codex runtime operation") - } - if err := ctx.Err(); err != nil { - return extensionv1.Result{}, err - } - var query extensionv1.CodexTransportQuery - if len(in.Payload) > 16384 || json.Unmarshal(in.Payload, &query) != nil { - return extensionv1.Result{}, errors.New("invalid transport metadata") - } - raw, err := json.Marshal(profile.TransportPlan(query, cfg.RequestZstd)) - return extensionv1.Result{Payload: raw}, err -} diff --git a/backend/internal/codexruntime/core/module_test.go b/backend/internal/codexruntime/core/module_test.go deleted file mode 100644 index a90bccdaa712..000000000000 --- a/backend/internal/codexruntime/core/module_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestValidateConfigDropsRetiredSettings(t *testing.T) { - module := NewModule() - for raw, want := range map[string]string{ - // Every setting a stored configuration of the retired route feature holds. - `{"routing_schema":2,"enabled":true,"fail_closed":true,"proxy_url":"http://user:secret@proxy.test:8080","proxy_protocol":"https","proxy_selection_id":"selection","models":["gpt-6-astra","gpt-6-sol"],"request_zstd":false}`: `{"request_zstd":false}`, - // Retired settings are never interpreted, whatever their value. - `{"enabled":null,"models":null,"proxy_url":null,"routing_schema":"x","fail_closed":"yes"}`: `{"request_zstd":true}`, - `{}`: `{"request_zstd":true}`, - } { - normalized, err := module.ValidateConfig(context.Background(), json.RawMessage(raw)) - if err != nil || string(normalized) != want { - t.Fatalf("ValidateConfig(%s) = %s, %v; want %s", raw, normalized, err, want) - } - } - for raw, want := range map[string]string{ - `null`: "expected a JSON object", - `{} {}`: "invalid codex runtime configuration", - `{"enabled":true,"extra":true}`: "unknown codex runtime setting: extra", - `{"request_zstd":null}`: "null codex runtime setting: request_zstd", - `{"request_zstd":"yes"}`: "invalid codex runtime configuration", - `{"Request_Zstd":false,"models":[]}`: "unknown codex runtime setting: Request_Zstd", - } { - if _, err := module.ValidateConfig(context.Background(), json.RawMessage(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Fatalf("ValidateConfig(%s) error %v, want %q", raw, err, want) - } - } -} - -func TestModuleServesTransportPlanFromItsConfiguration(t *testing.T) { - module := NewModule() - ctx := context.Background() - plan := func() (extensionv1.CodexTransportPlan, error) { - result, err := module.Invoke(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.transport.plan", - Payload: []byte(`{"method":"POST","path":"/backend-api/codex/responses","body_present":true}`)}) - var value extensionv1.CodexTransportPlan - if err == nil { - err = json.Unmarshal(result.Payload, &value) - } - return value, err - } - if err := module.ApplyConfig(ctx, json.RawMessage(`{"enabled":true,"request_zstd":false}`)); err != nil { - t.Fatal(err) - } - if err := module.Start(ctx); err != nil { - t.Fatal(err) - } - if value, err := plan(); err != nil || value.Compress { - t.Fatalf("plan with request_zstd=false: %+v, %v", value, err) - } - for _, raw := range []string{`null`, `{"extra":true}`, `{"request_zstd":null}`} { - if err := module.ApplyConfig(ctx, json.RawMessage(raw)); err == nil { - t.Fatalf("accepted invalid configuration %s", raw) - } - } - if value, err := plan(); err != nil || value.Compress { - t.Fatalf("an invalid configuration changed the plan: %+v, %v", value, err) - } - if err := module.ApplyConfig(ctx, json.RawMessage(`{"request_zstd":true}`)); err != nil { - t.Fatal(err) - } - if value, err := plan(); err != nil || !value.Compress || !value.Enabled { - t.Fatalf("plan with request_zstd=true: %+v, %v", value, err) - } - if _, err := module.Invoke(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "inject"}); err == nil { - t.Fatal("a retired route operation was served") - } - if err := module.Stop(ctx); err != nil { - t.Fatal(err) - } - if _, err := plan(); !errors.Is(err, ErrStopping) { - t.Fatalf("plan after Stop: %v", err) - } - identity, err := module.Invoke(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.available", Payload: []byte(`{}`)}) - if err != nil || !strings.Contains(string(identity.Payload), `"valid":true`) { - t.Fatalf("identity policy after Stop: %s, %v", identity.Payload, err) - } -} diff --git a/backend/internal/codexruntime/profile/identity.go b/backend/internal/codexruntime/profile/identity.go index 8877b741b358..9bc0de31e16f 100644 --- a/backend/internal/codexruntime/profile/identity.go +++ b/backend/internal/codexruntime/profile/identity.go @@ -1,9 +1,7 @@ package profile import ( - "context" "crypto/sha256" - "encoding/json" "errors" "fmt" "regexp" @@ -12,56 +10,42 @@ import ( extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -func Invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if err := ctx.Err(); err != nil { - return extensionv1.Result{}, err - } - if in.Capability != extensionv1.CapabilityRequest || len(in.Payload) > 8192 { - return extensionv1.Result{}, errors.New("invalid Codex identity request") - } - var query extensionv1.CodexIdentityQuery - if json.Unmarshal(in.Payload, &query) != nil { - return extensionv1.Result{}, errors.New("invalid Codex identity query") +// ErrInvalidVersion reports a Codex client version that cannot be written into +// a User-Agent. +var ErrInvalidVersion = errors.New("invalid identity version") + +// codexClientVersionPattern allows the two official forms, 0.146.0 and +// 0.147.0-alpha.4. +var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`) + +// Derive returns the client identity of a fingerprint seed. +func Derive(seed string) extensionv1.CodexClientProfile { + return extensionv1.CodexClientProfile(deriveCodexClientIdentity(seed)) +} + +// Valid reports whether an identity passes the schema check. +func Valid(profile extensionv1.CodexClientProfile) bool { + return codexClientIdentity(profile).valid() +} + +// UserAgent returns the Codex TUI User-Agent of an identity at a client +// version. An identity that fails the schema check, or a malformed version, is +// an error: neither may be written into an outbound header. +func UserAgent(profile extensionv1.CodexClientProfile, version string) (string, error) { + identity := codexClientIdentity(profile) + if !identity.valid() { + return "", errors.New("invalid identity profile") } - result := extensionv1.CodexIdentityResult{DefaultFingerprintMode: "device"} - switch in.Operation { - case "codex.identity.available": - result.Valid = true - case "codex.identity.derive": - if query.Seed == "" || len(query.Seed) > 1024 { - return extensionv1.Result{}, errors.New("invalid identity seed") - } - identity := capturedCodexClientIdentity() - if query.Preset == "legacy" { - identity = deriveCodexClientIdentity(query.Seed) - } - result.Profile, result.Valid = extensionv1.CodexClientProfile(identity), true - case "codex.identity.validate": - result.Valid = codexClientIdentity(query.Profile).valid() - case "codex.identity.plan": - identity := codexClientIdentity(query.Profile) - if !identity.valid() && query.Seed != "" && len(query.Seed) <= 1024 { - identity = deriveCodexClientIdentity(query.Seed) - } - if identity.valid() { - if len(query.Version) > 64 || !regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`).MatchString(query.Version) { - return extensionv1.Result{}, errors.New("invalid identity version") - } - result.Profile, result.Valid = extensionv1.CodexClientProfile(identity), true - result.UserAgent, result.Sandbox = identity.UserAgent(query.Version), identity.Sandbox - } - case "codex.identity.agent": - if !codexClientIdentity(query.Profile).valid() || len(query.Version) > 64 || !regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`).MatchString(query.Version) { - return extensionv1.Result{}, errors.New("invalid identity agent") - } - result.UserAgent = codexClientIdentity(query.Profile).UserAgent(query.Version) - case "codex.identity.sandbox": - result.Sandbox = codexSandboxForUserAgent(query.UserAgent) - default: - return extensionv1.Result{}, errors.New("unknown Codex identity operation") + if len(version) > 64 || !codexClientVersionPattern.MatchString(version) { + return "", ErrInvalidVersion } - raw, err := json.Marshal(result) - return extensionv1.Result{Payload: raw}, err + return identity.UserAgent(version), nil +} + +// SandboxForUserAgent returns the sandbox tag that belongs to the operating +// system a User-Agent declares, or "" when the User-Agent declares none. +func SandboxForUserAgent(userAgent string) string { + return codexSandboxForUserAgent(userAgent) } const codexClientIdentitySchemaVersion = 1 @@ -97,9 +81,6 @@ var ( // windows_sandbox=Windows、seccomp=Ubuntu),版本/架构/终端形态受限且不含空白或 // CR/LF。持久化值或外部写入的坏值一律视为缺失并重新按种子派生,避免脏值进入 UA。 func (id codexClientIdentity) valid() bool { - if id.Version == 2 { - return id == capturedCodexClientIdentity() || (id.GeneratedAt != "" && func() bool { id.GeneratedAt = ""; return id == capturedCodexClientIdentity() }()) - } if id.Version != codexClientIdentitySchemaVersion { return false } @@ -151,9 +132,6 @@ func codexSandboxForUserAgent(userAgent string) string { // 首段版本、尾部括号组版本与 version 头必须同源(codex-rs 三处都取同一个 // CARGO_PKG_VERSION)。 func (id codexClientIdentity) UserAgent(version string) string { - if id.Version == 2 { - return "codex_exec/0.156.0 (Windows 10.0.26220; x86_64) dumb (codex_exec; 0.156.0)" - } version = strings.TrimSpace(version) return fmt.Sprintf("%s/%s (%s %s; %s) %s (%s; %s)", codexTUIOriginator, version, id.OSType, id.OSVersion, id.Arch, id.Terminal, codexTUIOriginator, version) @@ -163,12 +141,6 @@ func (id codexClientIdentity) UserAgent(version string) string { // clientInfo.name,codex-rs tui/src/lib.rs)。 const codexTUIOriginator = "codex-tui" -// This is a reference-derived application profile, not a claim that the -// gateway's Go transport was captured from the native client. -func capturedCodexClientIdentity() codexClientIdentity { - return codexClientIdentity{Version: 2, Source: "reference_derived_windows_cli", Originator: "codex_exec", ClientVersion: "0.156.0", OSType: "Windows", OSVersion: "10.0.26220", Arch: "x86_64", Terminal: "dumb", Sandbox: "windows_sandbox"} -} - // deriveCodexClientIdentity 从种子确定性派生一份自洽身份。分布按真实 TUI 用户 // 的大致构成加权:macOS 60%(arm64 为主),Windows 25%,Ubuntu 15%;终端与 // 系统匹配,sandbox 标签与系统匹配。 diff --git a/backend/internal/codexruntime/profile/identity_test.go b/backend/internal/codexruntime/profile/identity_test.go index 35e825354f83..781cca914eff 100644 --- a/backend/internal/codexruntime/profile/identity_test.go +++ b/backend/internal/codexruntime/profile/identity_test.go @@ -1,8 +1,7 @@ package profile import ( - "context" - "encoding/json" + "errors" "strings" "testing" @@ -23,34 +22,14 @@ func TestIdentityPolicyPreservesDeterminismAndProtocolPairing(t *testing.T) { if bad.valid() { t.Fatal("header injection accepted") } - query, _ := json.Marshal(extensionv1.CodexIdentityQuery{Profile: extensionv1.CodexClientProfile(first), Version: "0.150.0\r\nx: value"}) - _, err := Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.agent", Payload: query}) - if err == nil { + if _, err := UserAgent(extensionv1.CodexClientProfile(first), "0.150.0\r\nx: value"); !errors.Is(err, ErrInvalidVersion) { t.Fatal("invalid version accepted") } -} - -func TestIdentityCapturedDefaultPreservesExplicitLegacyProfiles(t *testing.T) { - query, _ := json.Marshal(extensionv1.CodexIdentityQuery{Seed: "account-seed"}) - result, err := Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.derive", Payload: query}) - if err != nil { - t.Fatal(err) - } - var value extensionv1.CodexIdentityResult - if json.Unmarshal(result.Payload, &value) != nil || value.Profile.Version != 2 || value.Profile.Source != "reference_derived_windows_cli" { - t.Fatal("new account did not receive explicit captured-reference profile") - } - profile := codexClientIdentity(value.Profile) - if !profile.valid() || !strings.HasPrefix(profile.UserAgent("0.145.0"), "codex_exec/0.156.0 ") { - t.Fatal("captured reference version changed") - } - legacy := deriveCodexClientIdentity("old-seed") - query, _ = json.Marshal(extensionv1.CodexIdentityQuery{Seed: "old-seed", Profile: extensionv1.CodexClientProfile(legacy), Version: "0.156.0"}) - result, err = Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.plan", Payload: query}) - if err != nil { - t.Fatal(err) + if _, err := UserAgent(extensionv1.CodexClientProfile(bad), "0.150.0"); err == nil { + t.Fatal("invalid profile accepted") } - if json.Unmarshal(result.Payload, &value) != nil || value.Profile.Version != 1 || value.Profile.OSType != legacy.OSType || !strings.HasPrefix(value.UserAgent, "codex-tui/0.156.0 ") { - t.Fatal("legacy profile was silently migrated") + typed := Derive("1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55") + if got, err := UserAgent(typed, "0.150.0"); err != nil || got != ua || !Valid(typed) || SandboxForUserAgent(ua) != first.Sandbox { + t.Fatal("typed calls disagree with the profile") } } diff --git a/backend/internal/codexruntime/profile/transport.go b/backend/internal/codexruntime/profile/transport.go deleted file mode 100644 index 972d32464863..000000000000 --- a/backend/internal/codexruntime/profile/transport.go +++ /dev/null @@ -1,17 +0,0 @@ -package profile - -import ( - "net/http" - "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TransportPlan(query extensionv1.CodexTransportQuery, enabled bool) extensionv1.CodexTransportPlan { - plan := extensionv1.CodexTransportPlan{Enabled: enabled} - plan.Compress = enabled && query.BodyPresent && query.Method == http.MethodPost && - strings.HasSuffix(strings.TrimSuffix(query.Path, "/"), "/codex/responses") && - strings.TrimSpace(query.ContentEncoding) == "" && - (query.ContentType == "" || strings.Contains(strings.ToLower(query.ContentType), "json")) - return plan -} diff --git a/backend/internal/codexruntime/profile/transport_test.go b/backend/internal/codexruntime/profile/transport_test.go deleted file mode 100644 index 8866eb0aec36..000000000000 --- a/backend/internal/codexruntime/profile/transport_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package profile - -import ( - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestTransportPolicyControlsOnlyUnencodedResponsesBodies(t *testing.T) { - query := extensionv1.CodexTransportQuery{Method: "POST", Path: "/backend-api/codex/responses", ContentType: "application/json", BodyPresent: true} - if !TransportPlan(query, true).Compress || TransportPlan(query, false).Compress { - t.Fatal("compression flag not honored") - } - for _, change := range []func(*extensionv1.CodexTransportQuery){ - func(q *extensionv1.CodexTransportQuery) { q.Path += "/compact" }, - func(q *extensionv1.CodexTransportQuery) { q.Method = "GET" }, - func(q *extensionv1.CodexTransportQuery) { q.ContentEncoding = "gzip" }, - func(q *extensionv1.CodexTransportQuery) { q.ContentType = "multipart/form-data; boundary=fixture" }, - func(q *extensionv1.CodexTransportQuery) { q.BodyPresent = false }, - } { - candidate := query - change(&candidate) - if TransportPlan(candidate, true).Compress { - t.Fatalf("unexpected compression: %+v", candidate) - } - } -} diff --git a/backend/internal/codexruntime/recovery/policy.go b/backend/internal/codexruntime/recovery/policy.go index f474c1786485..4ba4eebec6e9 100644 --- a/backend/internal/codexruntime/recovery/policy.go +++ b/backend/internal/codexruntime/recovery/policy.go @@ -1,9 +1,6 @@ package recovery import ( - "context" - "encoding/json" - "errors" "regexp" "strconv" @@ -64,40 +61,8 @@ func Select(in extensionv1.RecoverySelectionQuery) extensionv1.RecoverySelection return extensionv1.RecoverySelection{Indices: append([]int(nil), in.CipherIndices...), Reason: "recovery_not_dispatched"} } -func Invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { - if err := ctx.Err(); err != nil { - return extensionv1.Result{}, err - } - if in.Capability != extensionv1.CapabilityRecovery { - return extensionv1.Result{}, errors.New("unsupported recovery capability") - } - var value any - switch in.Operation { - case "codex.replay.rules": - value = ReplayPolicy() - case "codex.recovery.available": - value = true - case "codex.recovery.enabled": - var input extensionv1.RecoverySetting - if json.Unmarshal(in.Payload, &input) != nil { - return extensionv1.Result{}, errors.New("invalid recovery setting") - } - value = !input.Configured || (input.Valid && input.Value) - case "codex.recovery.rejection": - var input extensionv1.RecoveryEnvelope - if json.Unmarshal(in.Payload, &input) != nil { - return extensionv1.Result{}, errors.New("invalid recovery envelope") - } - value = Rejection(input) - case "codex.recovery.select": - var input extensionv1.RecoverySelectionQuery - if json.Unmarshal(in.Payload, &input) != nil { - return extensionv1.Result{}, errors.New("invalid recovery selection") - } - value = Select(input) - default: - return extensionv1.Result{}, errors.New("unknown recovery operation") - } - raw, err := json.Marshal(value) - return extensionv1.Result{Payload: raw}, err +// Enabled reports an account's reasoning policy setting: on unless it is +// configured to anything but true. +func Enabled(in extensionv1.RecoverySetting) bool { + return !in.Configured || (in.Valid && in.Value) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 68d0eb2001b0..2e2ff1d8711a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1016,7 +1016,8 @@ type GatewayConfig struct { // OpenAICodexRequestZstd: 对 OpenAI OAuth-like 账号发往 ChatGPT 后端的流式 /responses 请求体 // 做 zstd(level 3)压缩并带 Content-Encoding: zstd,与官方 Codex 客户端一致;compact 等其他 // 请求不压缩。默认开启;出现兼容问题时置 false 回退为明文 JSON。零值为关闭,手工构造的 - // Config 不会意外压缩。 + // Config 不会意外压缩。管理后台「Codex 运行设置」保存过开关后以保存值为准,本项只在 + // 未保存时生效。 OpenAICodexRequestZstd bool `mapstructure:"openai_codex_request_zstd"` // OpenAIRequestIntegrityMode: off|observe|enforce。对 OpenAI OAuth-like 账号,把客户端 /responses // 请求体与最终出站明文请求体(zstd 之前)按语义字段比较(model/input/instructions/reasoning/tools/ diff --git a/backend/internal/handler/admin/account_bulk_job_scope_test.go b/backend/internal/handler/admin/account_bulk_job_scope_test.go index 20c838ed0b04..bb5bb739af99 100644 --- a/backend/internal/handler/admin/account_bulk_job_scope_test.go +++ b/backend/internal/handler/admin/account_bulk_job_scope_test.go @@ -208,14 +208,6 @@ func TestAccountJobBulkReplayUsesSubmissionIdentityWithoutWrites(t *testing.T) { require.Equal(t, job.ID, replayedJob.ID) require.Equal(t, original, repo.created[0], "resubmission must retain the originally frozen target, not the newly supplied seeds") require.Equal(t, 1, len(repo.created)) - legacy := original - legacy.Metadata = json.RawMessage(`{"plugin_id":7,"plugin_generation":2,"target_count":1}`) - legacyRepo := &bulkJobScopeRepository{accountJobSubmitRepository: &accountJobSubmitRepository{created: []service.CreateAccountJobParams{legacy}}} - legacyJobs := service.NewAccountJobService(legacyRepo, accountJobTestEncryptor{}) - _, _, err = legacyJobs.ReplaySubmission(context.Background(), 77, service.AccountJobKindBulkUpdate, "owner-bound", payload) - require.ErrorIs(t, err, service.ErrAccountJobIdempotencyConflict, "a native request cannot silently claim a historical plugin-owned submission") - require.Equal(t, []service.CreateAccountJobParams{legacy}, legacyRepo.created) - } type bulkJobScopeAdmin struct { diff --git a/backend/internal/handler/admin/account_codex_fingerprint.go b/backend/internal/handler/admin/account_codex_fingerprint.go deleted file mode 100644 index 4db0b28859e2..000000000000 --- a/backend/internal/handler/admin/account_codex_fingerprint.go +++ /dev/null @@ -1,51 +0,0 @@ -package admin - -import ( - "strconv" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -func (h *AccountHandler) CodexFingerprint(c *gin.Context) { - c.Header("Cache-Control", "no-store") - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || id <= 0 { - response.BadRequest(c, "Invalid account identifier") - return - } - if h.codexGateway == nil { - response.Error(c, 503, "Codex runtime unavailable") - return - } - view, err := h.codexGateway.CodexFingerprint(c.Request.Context(), id) - if err != nil { - response.Error(c, 503, "Codex fingerprint unavailable: "+err.Error()) - return - } - response.Success(c, view) -} - -func (h *AccountHandler) SelectCodexProfile(c *gin.Context) { - c.Header("Cache-Control", "no-store") - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - var choice service.CodexProfileSelection - if err != nil || id <= 0 { - response.BadRequest(c, "Invalid profile selection: account id "+strconv.Quote(c.Param("id"))) - return - } - if err := c.ShouldBindJSON(&choice); err != nil { - response.BadRequest(c, "Invalid profile selection: "+err.Error()) - return - } - if h.codexGateway == nil { - response.Error(c, 503, "Codex runtime unavailable") - return - } - if err := h.codexGateway.SelectCodexProfile(c.Request.Context(), id, choice); err != nil { - response.Error(c, 409, "Profile not applied: "+err.Error()) - return - } - response.Success(c, gin.H{"applied": true}) -} diff --git a/backend/internal/handler/admin/account_codex_quality.go b/backend/internal/handler/admin/account_codex_quality.go deleted file mode 100644 index e2492b7d12bc..000000000000 --- a/backend/internal/handler/admin/account_codex_quality.go +++ /dev/null @@ -1,112 +0,0 @@ -package admin - -import ( - "context" - "fmt" - "strconv" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/server/middleware" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -func codexQualityAdmin(c *gin.Context) (int64, int64, bool) { - c.Header("Cache-Control", "no-store") - actor, ok := middleware.GetAuthSubjectFromContext(c) - role, roleOK := middleware.GetUserRoleFromContext(c) - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - switch { - case !ok || actor.UserID <= 0: - response.Error(c, 403, "Codex quality run unavailable: no authenticated administrator") - case !roleOK || role != "admin": - response.Error(c, 403, fmt.Sprintf("Codex quality run unavailable: role %q is not admin", role)) - case err != nil || id <= 0: - response.Error(c, 403, "Codex quality run unavailable: invalid account id "+strconv.Quote(c.Param("id"))) - default: - return actor.UserID, id, true - } - return 0, 0, false -} - -func writeCodexQualityResult(c *gin.Context, result *service.CodexQualityRunView, err error) { - if err != nil { - response.Error(c, 409, "Codex quality run unavailable: "+err.Error()) - return - } - if result == nil { - response.Error(c, 409, "Codex quality run unavailable: the runtime returned no run") - return - } - response.Success(c, result) -} - -func codexQualityKeyLookup(h *AccountHandler, actor int64) service.CodexQualityKeyLookup { - return func(ctx context.Context, keyID int64) (*service.APIKey, error) { - keys, _, err := h.adminService.GetUserAPIKeys(ctx, actor, 1, 1000, "", "") - if err != nil { - return nil, fmt.Errorf("list the API keys of administrator %d: %w", actor, err) - } - for index := range keys { - if keys[index].ID == keyID && keys[index].UserID == actor { - return &keys[index], nil - } - } - return nil, fmt.Errorf("API key %d is not one of the %d API keys of administrator %d", keyID, len(keys), actor) - } -} - -func (h *AccountHandler) CreateCodexQualityRun(c *gin.Context) { - actor, id, ok := codexQualityAdmin(c) - if !ok { - return - } - var req service.CodexQualityCreateRequest - if h.codexGateway == nil { - response.Error(c, 409, "Codex quality run unavailable: Codex runtime unavailable") - return - } - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, 409, "Codex quality run unavailable: invalid request: "+err.Error()) - return - } - if req.APIKeyID <= 0 { - response.Error(c, 409, "Codex quality run unavailable: api_key_id is required") - return - } - // A global integration admin key resolves to a real administrator too. Only - // that administrator's own existing downstream key can authorize this run. - key, err := codexQualityKeyLookup(h, actor)(c.Request.Context(), req.APIKeyID) - if err != nil { - response.Error(c, 409, "Codex quality run unavailable: "+err.Error()) - return - } - result, err := h.codexGateway.CreateCodexQualityRun(c.Request.Context(), actor, id, key, req) - writeCodexQualityResult(c, result, err) -} - -func (h *AccountHandler) ReadCodexQualityRun(c *gin.Context) { - actor, id, ok := codexQualityAdmin(c) - if !ok { - return - } - if h.codexGateway == nil { - response.Error(c, 409, "Codex quality run unavailable: Codex runtime unavailable") - return - } - result, err := h.codexGateway.ReadCodexQualityRun(c.Request.Context(), actor, id, c.Param("run_id")) - writeCodexQualityResult(c, result, err) -} - -func (h *AccountHandler) CloseCodexQualityRun(c *gin.Context) { - actor, id, ok := codexQualityAdmin(c) - if !ok { - return - } - if h.codexGateway == nil { - response.Error(c, 409, "Codex quality run unavailable: Codex runtime unavailable") - return - } - result, err := h.codexGateway.CloseCodexQualityRun(c.Request.Context(), actor, id, c.Param("run_id")) - writeCodexQualityResult(c, result, err) -} diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go index 2af7c151887e..ee54f7f95a80 100644 --- a/backend/internal/handler/admin/account_data_handler_test.go +++ b/backend/internal/handler/admin/account_data_handler_test.go @@ -327,25 +327,3 @@ func TestImportDataReusesProxyAndSkipsDefaultGroup(t *testing.T) { require.Len(t, adminSvc.createdAccounts, 1) require.True(t, adminSvc.createdAccounts[0].SkipDefaultGroupBind) } - -// The backup keeps every stored extra key, Codex ticket material included. -func TestExportDataKeepsCodexTicketMaterial(t *testing.T) { - router, adminSvc := setupAccountDataRouter() - extra := map[string]any{ - "codex_turn_ticket:gpt-6-astra": map[string]any{"state": "private-ticket-blob", "length": 292}, - "codex_harvest_proxy_url": "http://user:legacy-proxy-secret@proxy.example.com:8080", - "ordinary": "retained", - } - adminSvc.accounts = []service.Account{{ID: 21, Name: "account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "backup-token"}, Extra: extra}} - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)) - require.Equal(t, http.StatusOK, rec.Code) - var resp dataResponse - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - require.Len(t, resp.Data.Accounts, 1) - require.Equal(t, "retained", resp.Data.Accounts[0].Extra["ordinary"]) - require.Equal(t, "http://user:legacy-proxy-secret@proxy.example.com:8080", resp.Data.Accounts[0].Extra["codex_harvest_proxy_url"]) - require.Contains(t, resp.Data.Accounts[0].Extra, "codex_turn_ticket:gpt-6-astra") - require.Equal(t, "backup-token", resp.Data.Accounts[0].Credentials["access_token"]) - require.Contains(t, rec.Body.String(), "private-ticket-blob") -} diff --git a/backend/internal/handler/admin/account_data_import_job.go b/backend/internal/handler/admin/account_data_import_job.go index 6fa75b9dfe6e..597fdb10c820 100644 --- a/backend/internal/handler/admin/account_data_import_job.go +++ b/backend/internal/handler/admin/account_data_import_job.go @@ -64,9 +64,6 @@ func (h *AccountHandler) PrepareAccountJob( job *service.AccountJob, raw json.RawMessage, ) (context.Context, func(), error) { - if err := service.ValidateRecordedAccountJob(job, raw); err != nil { - return ctx, nil, err - } if h == nil || job == nil || job.Kind != service.AccountJobKindImportData { return ctx, func() {}, nil } diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index fd1295abe03d..9ad43ce6ceb1 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -70,7 +70,6 @@ type AccountHandler struct { ollamaCloudUsage *service.OllamaCloudUsageService trafficObserver *service.AccountTrafficObserver accountJobs *service.AccountJobService - codexGateway *service.OpenAIGatewayService cfg *config.Config opencodeGoUsage *service.OpenCodeGoUsageService @@ -96,11 +95,6 @@ func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsag h.opencodeGoUsage = usage } -// SetCodexGateway attaches the gateway that serves the Codex account views. -func (h *AccountHandler) SetCodexGateway(gateway *service.OpenAIGatewayService) { - h.codexGateway = gateway -} - // NewAccountHandler creates a new admin account handler func NewAccountHandler( adminService service.AdminService, diff --git a/backend/internal/handler/admin/account_job_execution.go b/backend/internal/handler/admin/account_job_execution.go index d83b7ee91bc0..a02480395fd8 100644 --- a/backend/internal/handler/admin/account_job_execution.go +++ b/backend/internal/handler/admin/account_job_execution.go @@ -20,9 +20,6 @@ func (h *AccountHandler) ExecuteAccountJob( if h == nil || job == nil || len(items) != 1 { return nil, errors.New("invalid account job execution") } - if err := service.ValidateRecordedAccountJobTargets(job, payload, items); err != nil { - return nil, err - } item := items[0] result := h.executeAccountJobItem(ctx, job.Kind, payload, item) return []service.AccountJobExecutionResult{result}, nil diff --git a/backend/internal/handler/admin/account_job_handler.go b/backend/internal/handler/admin/account_job_handler.go index 707bc037b6c5..8a5fa32840de 100644 --- a/backend/internal/handler/admin/account_job_handler.go +++ b/backend/internal/handler/admin/account_job_handler.go @@ -165,8 +165,6 @@ func accountJobHTTPError(err error) error { return infraerrors.Conflict("ACCOUNT_JOB_NOT_RETRYABLE", "account job has no failed items to retry") case errors.Is(err, service.ErrAccountJobInvalidMetadata): return infraerrors.BadRequest("ACCOUNT_JOB_METADATA_REJECTED", "account job metadata must not contain credentials") - case errors.Is(err, service.ErrAccountJobPluginUnavailable): - return infraerrors.ServiceUnavailable("ACCOUNT_JOB_PLUGIN_UNAVAILABLE", "account job plugin is unavailable or has changed") default: return err } @@ -195,7 +193,7 @@ func (h *AccountHandler) replayAccountJob(c *gin.Context, kind string, payload a } raw, err := json.Marshal(payload) if err != nil { - response.ErrorFrom(c, service.ErrAccountViewInvalid) + response.ErrorFrom(c, infraerrors.BadRequest("ACCOUNT_JOB_PAYLOAD_INVALID", "invalid account job payload")) return true } job, replayed, err := h.accountJobs.ReplaySubmission(c.Request.Context(), actorID, kind, c.GetHeader("Idempotency-Key"), raw) diff --git a/backend/internal/handler/admin/account_job_submit_test.go b/backend/internal/handler/admin/account_job_submit_test.go index 01b530bbc920..ac54a9b98abe 100644 --- a/backend/internal/handler/admin/account_job_submit_test.go +++ b/backend/internal/handler/admin/account_job_submit_test.go @@ -33,8 +33,6 @@ func TestAccountJobSubmissionRetainsActorAndNativeMetadata(t *testing.T) { var metadata map[string]any require.NoError(t, json.Unmarshal(repo.created[0].Metadata, &metadata)) require.Equal(t, map[string]any{"target_count": float64(1)}, metadata) - require.NotContains(t, metadata, "plugin_id") - require.NotContains(t, metadata, "plugin_generation") require.Equal(t, accountJobTestActorID, repo.created[0].CreatedBy) require.Equal(t, service.AccountJobKindImportData, repo.created[0].Kind) require.Equal(t, "fixture-import", repo.created[0].IdempotencyKey) diff --git a/backend/internal/handler/admin/account_test_plan_test.go b/backend/internal/handler/admin/account_test_plan_test.go index 7669fea219d4..a8615c32ee33 100644 --- a/backend/internal/handler/admin/account_test_plan_test.go +++ b/backend/internal/handler/admin/account_test_plan_test.go @@ -35,7 +35,7 @@ type testPlanOperations struct { contexts []any } -func (f *testPlanOperations) InvokeOperation(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { +func (f *testPlanOperations) InvokeOperation(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { f.calls = append(f.calls, in) f.contexts = append(f.contexts, ctx.Value(testPlanOuterKey{})) if f.rejectAll { diff --git a/backend/internal/handler/admin/setting_codex_runtime.go b/backend/internal/handler/admin/setting_codex_runtime.go deleted file mode 100644 index 173b2d26cab9..000000000000 --- a/backend/internal/handler/admin/setting_codex_runtime.go +++ /dev/null @@ -1,82 +0,0 @@ -package admin - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -const ( - nativeCodexHostingModeHeader = "X-Sub2API-Codex-Hosting-Mode" - nativeCodexConfigVersionHeader = "X-Sub2API-Codex-Config-Version" - nativeCodexConfigSHA256Header = "X-Sub2API-Codex-Config-SHA256" - nativeCodexRuntimeGenerationHeader = "X-Sub2API-Codex-Runtime-Generation" -) - -// SetCodexGateway attaches the gateway that hosts the native Codex runtime. -func (h *SettingHandler) SetCodexGateway(gateway *service.OpenAIGatewayService) { - h.codexGateway = gateway -} - -func (h *SettingHandler) SetNativeCodexConfigEncryptor(encryptor service.SecretEncryptor) { - h.nativeCodexConfigEncryptor = encryptor -} - -func (h *SettingHandler) GetNativeCodexConfiguration(c *gin.Context) { - writeNativeCodexConfiguration(c, h.codexGateway.NativeCodexConfiguration) -} - -func writeNativeCodexConfiguration(c *gin.Context, read func(context.Context) (json.RawMessage, service.NativeCodexMetadata, error)) { - c.Header("Cache-Control", "no-store") - for _, name := range [...]string{nativeCodexHostingModeHeader, nativeCodexConfigVersionHeader, nativeCodexConfigSHA256Header, nativeCodexRuntimeGenerationHeader} { - c.Writer.Header().Del(name) - } - raw, metadata, err := read(c.Request.Context()) - if err != nil { - response.Error(c, 503, "Codex runtime is unavailable: "+err.Error()) - return - } - c.Header(nativeCodexHostingModeHeader, "native") - c.Header(nativeCodexConfigVersionHeader, strconv.FormatInt(metadata.ConfigVersion, 10)) - c.Header(nativeCodexConfigSHA256Header, metadata.ConfigSHA256) - c.Header(nativeCodexRuntimeGenerationHeader, strconv.FormatInt(metadata.RuntimeGeneration, 10)) - c.Data(http.StatusOK, "application/json; charset=utf-8", raw) -} - -func (h *SettingHandler) UpdateNativeCodexConfiguration(c *gin.Context) { - c.Header("Cache-Control", "no-store") - var raw json.RawMessage - if err := c.ShouldBindJSON(&raw); err != nil { - response.BadRequest(c, "Invalid Codex runtime configuration: "+err.Error()) - return - } - if len(raw) > 1<<20 { - response.BadRequest(c, fmt.Sprintf("Invalid Codex runtime configuration: %d bytes exceed the 1 MiB limit", len(raw))) - return - } - normalized, err := service.NormalizeNativeCodexConfig(c.Request.Context(), raw) - if err != nil { - response.BadRequest(c, "Invalid Codex runtime configuration: "+err.Error()) - return - } - if h.codexGateway == nil { - response.Error(c, 503, "Codex runtime is unavailable") - return - } - if err := h.codexGateway.UpdateNativeCodexConfiguration(c.Request.Context(), normalized, h.nativeCodexConfigEncryptor); err != nil { - if errors.Is(err, service.ErrNativeCodexRuntimeChanged) { - response.Error(c, 409, "Codex runtime configuration changed; reload and retry: "+err.Error()) - } else { - response.Error(c, 503, "Codex runtime configuration could not be applied: "+err.Error()) - } - return - } - h.GetNativeCodexConfiguration(c) -} diff --git a/backend/internal/handler/admin/setting_codex_runtime_receipt_test.go b/backend/internal/handler/admin/setting_codex_runtime_receipt_test.go deleted file mode 100644 index f46143356191..000000000000 --- a/backend/internal/handler/admin/setting_codex_runtime_receipt_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package admin - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "math" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestNativeCodexConfigurationHTTPReceiptPreservesExactBody(t *testing.T) { - gin.SetMode(gin.TestMode) - // Deliberate formatting makes re-marshalling or wrapping the snapshot visible. - raw := json.RawMessage("{\n \"enabled\": false, \"models\": [\"gpt-6-astra\"]\n}") - sum := sha256.Sum256(raw) - digest := hex.EncodeToString(sum[:]) - for _, version := range []int64{7, math.MaxInt64} { - t.Run(strconv.FormatInt(version, 10), func(t *testing.T) { - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - requestContext, cancel := context.WithCancel(context.Background()) - defer cancel() - c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/codex-runtime", nil).WithContext(requestContext) - reads := 0 - writeNativeCodexConfiguration(c, func(ctx context.Context) (json.RawMessage, service.NativeCodexMetadata, error) { - require.Same(t, requestContext, ctx) - reads++ - return raw, service.NativeCodexMetadata{ - ID: 7, ConfigVersion: version, RuntimeGeneration: version, ConfigSHA256: digest, - }, nil - }) - require.Equal(t, 1, reads) - require.Equal(t, http.StatusOK, recorder.Code) - require.Equal(t, []byte(raw), recorder.Body.Bytes(), "success must stay raw JSON without an envelope or added newline") - require.Equal(t, "application/json; charset=utf-8", recorder.Header().Get("Content-Type")) - require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) - require.Equal(t, "native", recorder.Header().Get(nativeCodexHostingModeHeader)) - require.Equal(t, strconv.FormatInt(version, 10), recorder.Header().Get(nativeCodexConfigVersionHeader)) - require.Equal(t, strconv.FormatInt(version, 10), recorder.Header().Get(nativeCodexRuntimeGenerationHeader)) - bodyHash := sha256.Sum256(recorder.Body.Bytes()) - require.Equal(t, hex.EncodeToString(bodyHash[:]), recorder.Header().Get(nativeCodexConfigSHA256Header)) - }) - } -} - -func TestNativeCodexConfigurationHTTPReceiptUnavailableIsOpaque(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, name := range []string{"missing_runtime", "read_failure_with_partial_snapshot"} { - t.Run(name, func(t *testing.T) { - recorder := httptest.NewRecorder() - for _, header := range [...]string{nativeCodexHostingModeHeader, nativeCodexConfigVersionHeader, nativeCodexConfigSHA256Header, nativeCodexRuntimeGenerationHeader} { - recorder.Header().Set(header, "stale-receipt") - } - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/codex-runtime", nil) - if name == "missing_runtime" { - (&SettingHandler{}).GetNativeCodexConfiguration(c) - } else { - writeNativeCodexConfiguration(c, func(context.Context) (json.RawMessage, service.NativeCodexMetadata, error) { - return json.RawMessage(`{"proxy_url":"synthetic-private-config"}`), - service.NativeCodexMetadata{ConfigVersion: 1, RuntimeGeneration: 2}, - errors.New("synthetic-private-database-detail") - }) - } - require.Equal(t, http.StatusServiceUnavailable, recorder.Code) - require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) - // The administrator sees the original cause; a partial snapshot and its - // receipt headers are still never returned. - cause := "native Codex runtime unavailable" - if name != "missing_runtime" { - cause = "synthetic-private-database-detail" - } - require.JSONEq(t, `{"code":503,"message":"Codex runtime is unavailable: `+cause+`"}`, recorder.Body.String()) - for _, header := range [...]string{nativeCodexHostingModeHeader, nativeCodexConfigVersionHeader, nativeCodexConfigSHA256Header, nativeCodexRuntimeGenerationHeader} { - require.Empty(t, recorder.Header().Values(header), "unavailable reads must not expose a receipt") - } - require.NotContains(t, recorder.Body.String(), "synthetic-private-config") - }) - } -} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 5dc325886a44..9791f8f18166 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -52,19 +52,17 @@ func firstNonEmpty(values ...string) string { // SettingHandler 系统设置处理器 type SettingHandler struct { - codexGateway *service.OpenAIGatewayService - nativeCodexConfigEncryptor service.SecretEncryptor - settingService *service.SettingService - emailService *service.EmailService - turnstileService *service.TurnstileService - aliyunCaptchaService *service.AliyunCaptchaService - opsService *service.OpsService - paymentConfigService *service.PaymentConfigService - paymentService *service.PaymentService - userAttributeService *service.UserAttributeService - notificationEmailService *service.NotificationEmailService - totpService *service.TotpService - userService *service.UserService + settingService *service.SettingService + emailService *service.EmailService + turnstileService *service.TurnstileService + aliyunCaptchaService *service.AliyunCaptchaService + opsService *service.OpsService + paymentConfigService *service.PaymentConfigService + paymentService *service.PaymentService + userAttributeService *service.UserAttributeService + notificationEmailService *service.NotificationEmailService + totpService *service.TotpService + userService *service.UserService } // NewSettingHandler 创建系统设置处理器 @@ -164,10 +162,36 @@ func (h *SettingHandler) UpdateObservabilitySettings(c *gin.Context) { response.Success(c, req) } +// GetCodexRuntimeSettings returns the Codex request compression switch this +// process applies. +// GET /api/v1/admin/settings/codex-runtime +func (h *SettingHandler) GetCodexRuntimeSettings(c *gin.Context) { + response.Success(c, service.EffectiveCodexRuntimeConfig()) +} + +// UpdateCodexRuntimeSettings saves the Codex request compression switch. An +// omitted switch is on. +// PUT /api/v1/admin/settings/codex-runtime +func (h *SettingHandler) UpdateCodexRuntimeSettings(c *gin.Context) { + req := extensionv1.CodexRuntimeConfig{RequestZstd: true} + raw, err := c.GetRawData() + if err == nil { + err = service.DecodeSwitchSettings(raw, &req, "request_zstd") + } + if err != nil { + response.BadRequest(c, "Invalid Codex runtime settings: "+err.Error()) + return + } + if err := h.settingService.UpdateCodexRuntimeConfig(c.Request.Context(), req); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, req) +} + // GetSettings 获取所有系统设置 // GET /api/v1/admin/settings func (h *SettingHandler) GetSettings(c *gin.Context) { - c.Header("Cache-Control", "no-store") settings, err := h.settingService.GetAllSettings(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/setting_handler_codex_runtime_test.go b/backend/internal/handler/admin/setting_handler_codex_runtime_test.go new file mode 100644 index 000000000000..eede52ad03b5 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_codex_runtime_test.go @@ -0,0 +1,67 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type codexRuntimeSettingRepo struct { + service.SettingRepository + values map[string]string +} + +func (r *codexRuntimeSettingRepo) Set(_ context.Context, key, value string) error { + r.values[key] = value + return nil +} + +// The Codex runtime endpoints read and save one switch in the standard +// envelope; a body that is not exactly a request_zstd boolean object is refused +// and stores nothing. +func TestCodexRuntimeSettingsReadAndSaveOneSwitch(t *testing.T) { + gin.SetMode(gin.TestMode) + old := service.EffectiveCodexRuntimeConfig() + t.Cleanup(func() { service.SetCodexRequestZstdEnabled(old.RequestZstd) }) + service.SetCodexRequestZstdEnabled(true) + repo := &codexRuntimeSettingRepo{values: map[string]string{}} + h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), nil, nil, nil, nil, nil, nil) + call := func(method, body string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(method, "/api/v1/admin/settings/codex-runtime", strings.NewReader(body)) + if method == http.MethodGet { + h.GetCodexRuntimeSettings(c) + } else { + h.UpdateCodexRuntimeSettings(c) + } + return recorder + } + + read := call(http.MethodGet, "") + require.Equal(t, http.StatusOK, read.Code) + require.JSONEq(t, `{"code":0,"message":"success","data":{"request_zstd":true}}`, read.Body.String()) + + saved := call(http.MethodPut, `{"request_zstd":false}`) + require.Equal(t, http.StatusOK, saved.Code) + require.JSONEq(t, `{"code":0,"message":"success","data":{"request_zstd":false}}`, saved.Body.String()) + require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":false}`}, repo.values) + require.JSONEq(t, `{"code":0,"message":"success","data":{"request_zstd":false}}`, call(http.MethodGet, "").Body.String()) + + for _, body := range []string{`{"request_zstd":"true"}`, `{"request_zstd":null}`, `{"request_zstd":true,"other":true}`, `{"other":true}`, `null`, ``} { + rejected := call(http.MethodPut, body) + require.Equal(t, http.StatusBadRequest, rejected.Code, body) + require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":false}`}, repo.values) + require.False(t, service.EffectiveCodexRuntimeConfig().RequestZstd) + } + + require.Equal(t, http.StatusOK, call(http.MethodPut, `{}`).Code) + require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":true}`}, repo.values, "an omitted switch is on") +} diff --git a/backend/internal/handler/admin/setting_handler_codex_ticket_test.go b/backend/internal/handler/admin/setting_handler_codex_ticket_test.go deleted file mode 100644 index 3a9da52f35d8..000000000000 --- a/backend/internal/handler/admin/setting_handler_codex_ticket_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package admin - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -// An older client may still send the retired Codex route-qualification -// settings. The save ignores them: it succeeds, the dormant rows stay as they -// are, and neither the reply nor GET exposes them. -func TestSettingsUpdateIgnoresRetiredCodexTicketFields(t *testing.T) { - const proxyKey, enabledKey = "openai_codex_ticket_harvest_proxy_url", "openai_codex_ticket_enabled" - oldProxy := "http://user:fixture-old-secret@old.example.com:8080" - h, repo := newStepUpSwitchTestHandler(t, map[string]string{proxyKey: oldProxy, enabledKey: "true"}) - rec := doUpdateSettings(t, h, map[string]any{ - proxyKey: "ftp://user:fixture-new-secret@new.example.com:21", - enabledKey: false, - "openai_codex_ticket_clear_proxy": true, - "site_name": "updated", - }, nil) - require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) - require.Equal(t, oldProxy, repo.values[proxyKey], "the dormant row is not written") - require.Equal(t, "true", repo.values[enabledKey]) - require.NotContains(t, rec.Body.String(), "openai_codex_ticket") - require.NotContains(t, rec.Body.String(), "fixture-new-secret") - require.NotContains(t, rec.Body.String(), "fixture-old-secret") - get := httptest.NewRecorder() - c, _ := gin.CreateTestContext(get) - c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) - h.GetSettings(c) - require.Equal(t, http.StatusOK, get.Code) - require.Equal(t, "no-store", get.Header().Get("Cache-Control")) - require.NotContains(t, get.Body.String(), "fixture-old-secret") - require.NotContains(t, get.Body.String(), "openai_codex_ticket") -} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 7ab01874bed0..93fb80df197c 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -491,7 +491,6 @@ func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest { } func (h *SettingHandler) UpdateSettings(c *gin.Context) { - c.Header("Cache-Control", "no-store") var sentFields map[string]json.RawMessage if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) diff --git a/backend/internal/handler/dto/account_mapper_redact_test.go b/backend/internal/handler/dto/account_mapper_redact_test.go index 39eb0f3ec361..9a979a66b61d 100644 --- a/backend/internal/handler/dto/account_mapper_redact_test.go +++ b/backend/internal/handler/dto/account_mapper_redact_test.go @@ -2,9 +2,7 @@ package dto import ( "encoding/json" - "strings" "testing" - "time" "github.com/stretchr/testify/require" @@ -95,32 +93,6 @@ func TestAccountFromServiceShallow_RedactsOllamaCloudManagedExtra(t *testing.T) require.Contains(t, src.Extra, service.OllamaCloudUsageSessionExtraKey) } -// Administrators see the stored Codex ticket keys, including legacy ticket -// state and the retired account-level harvest proxy URL. -func TestAccountFromServiceShallow_KeepsCodexTurnTicketState(t *testing.T) { - blob := "gAAAAA" + strings.Repeat("B", 286) - src := &service.Account{ - ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, - Extra: map[string]any{ - "codex_harvest_proxy_url": "http://user:legacy-proxy-secret@proxy.example.com:8080", - "codex_turn_ticket:gpt-6-astra": map[string]any{ - "state": blob, - "length": 292, - "model": "gpt-6-astra", - "captured_at": time.Now().Add(-time.Minute), - "expires_at": time.Now().Add(time.Hour), - "attempts": 3, - }, - }, - } - got := AccountFromServiceShallow(src) - require.Contains(t, got.Extra, "codex_turn_ticket:gpt-6-astra") - require.Equal(t, "http://user:legacy-proxy-secret@proxy.example.com:8080", got.Extra["codex_harvest_proxy_url"]) - raw, err := json.Marshal(got) - require.NoError(t, err) - require.Contains(t, string(raw), blob) -} - func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) { src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"} got := AccountFromServiceShallow(src) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index e4b147bc3b8c..dcbba023b3cd 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -432,7 +432,6 @@ func NewOpenAIGatewayHandler( // Responses handles OpenAI Responses API endpoint // POST /openai/v1/responses func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { - service.StageCodexQualityHeaders(c) // 局部兜底:确保该 handler 内部任何 panic 都不会击穿到进程级。 streamStarted := false defer h.recoverResponsesPanic(c, &streamStarted) @@ -494,14 +493,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") return } - if err := h.gatewayService.AdmitCodexQualityRequest(c, apiKey, body, h.apiKeyService.GetByID); err != nil { - // The reason names users, groups, API keys and proxies, so the client - // keeps the fixed sentence; the server log and the Ops record carry it. - reqLog.Warn("openai.codex_quality_admission_rejected", zap.Error(err)) - setOpsLocalErrorDetail(c, err.Error()) - h.errorResponse(c, http.StatusConflict, "codex_quality_unavailable", "Codex quality run unavailable") - return - } setOpsRequestContext(c, "", false) sessionHashBody := body @@ -755,10 +746,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { scheduleDecision service.OpenAIAccountScheduleDecision err error ) - if service.IsCodexQualityRequest(c.Request.Context()) { - selection, err = h.gatewayService.SelectCodexQualityAccount(c.Request.Context()) - scheduleDecision.Layer = "codex_quality" - } else if retryingSameAccount { + if retryingSameAccount { selection, err = h.gatewayService.ReacquireOpenAISameAccountSelection(c.Request.Context(), sameAccountRetrySelection) sameAccountRetrySelection = nil scheduleDecision.Layer = "same_account_retry" @@ -852,10 +840,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { accountReleaseFunc, slotResult = h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) } if slotResult == openAISlotAcquireProfitVetoed { - if service.IsCodexQualityRequest(c.Request.Context()) { - h.handleStreamingAwareError(c, http.StatusConflict, "codex_quality_unavailable", "Codex quality run unavailable", streamStarted) - return - } // 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错; // 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。 if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { @@ -921,7 +905,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { stampOpenAIRequestedReasoningEffort(res, c) usageSnapshot := snapshotOpenAIUsageMetadata(c, apiKey, account, subscription, channelMapping, reqModel, res, body) usageInput := usageSnapshot.Input(res, h.apiKeyService, pricingAt) - usageInput.CodexQuality = service.IsCodexQualityRequest(c.Request.Context()) usageAPIKeyID := usageInput.APIKey.ID usageGroupID := usageInput.APIKey.GroupID usageAccountID := usageInput.Account.ID @@ -938,14 +921,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } }) } - if err != nil && service.IsCodexQualityRequest(c.Request.Context()) { - h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(selection) - if shouldSubmitOpenAIUsage(err, result) { - submitResponsesUsage(result) - } - h.handleStreamingAwareError(c, http.StatusBadGateway, "codex_quality_failed", "Diagnostic attempt did not complete", streamStarted || c.Writer.Written()) - return - } if err != nil { if result != nil && result.ClientDisconnect { reqLog.Info("openai.client_disconnected", @@ -2445,7 +2420,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlotWithFailureResponse( selection.Account = latest // 调度器已抢槽路径无门时由选号内部完成 eager 绑定;门下选号内部 // 推迟绑定,这里在终检通过后补准入后绑定。 - if selection.ProfitGateActive() && !service.IsCodexQualityRequest(ctx) { + if selection.ProfitGateActive() { if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } diff --git a/backend/internal/handler/openai_official_http_failover.go b/backend/internal/handler/openai_official_http_failover.go index b9b563dba2a5..f15610188e0f 100644 --- a/backend/internal/handler/openai_official_http_failover.go +++ b/backend/internal/handler/openai_official_http_failover.go @@ -40,10 +40,6 @@ func (h *OpenAIGatewayHandler) reportOpenAIHTTPAccountScheduleResult( firstTokenMs *int, observedErr error, ) { - if c != nil && c.Request != nil && service.IsCodexQualityRequest(c.Request.Context()) { - h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(selection) - return - } if isOpenAIOfficialHTTPFailover(c, account) { if !success && failoverClientGone(c) { return diff --git a/backend/internal/handler/openai_request_rejected_test.go b/backend/internal/handler/openai_request_rejected_test.go index 0328974dcebe..46ae8cec62fd 100644 --- a/backend/internal/handler/openai_request_rejected_test.go +++ b/backend/internal/handler/openai_request_rejected_test.go @@ -4,11 +4,9 @@ package handler import ( "net/http" - "net/http/httptest" "strings" "testing" - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -154,43 +152,3 @@ func TestOpenAIGatewayHandler_CompactRequestRejectedBeforeKeepaliveUsesJSON(t *t require.Equal(t, service.OpenAIRequestRejectedCode, gjson.GetBytes(rec.Body.Bytes(), "error.code").String()) require.NotContains(t, rec.Body.String(), "event:") } - -// The admission reason names users, groups, API keys and proxies: the client -// keeps the fixed sentence and status, the Ops record carries the reason. -func TestOpenAIGatewayHandlerResponses_CodexQualityAdmissionReasonOnlyInOps(t *testing.T) { - gin.SetMode(gin.TestMode) - setupOpsErrorLogTestQueue(t, 2) - handler := newOpenAIResponsesFailoverTestHandler(t, &openAIResponsesGenericBadRequestUpstream{}) - ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - groupID := int64(3131) - router := gin.New() - router.Use(func(c *gin.Context) { - c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ - ID: 99, GroupID: &groupID, - Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, - User: &service.User{ID: 100}, - }) - c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100}) - c.Next() - }) - router.Use(OpsErrorLoggerMiddleware(ops)) - router.POST("/v1/responses", handler.Responses) - - req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.1","stream":true,"input":"hello"}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set(service.CodexQualityGrantHeader, "not-a-grant") - req.Header.Set(service.CodexQualityTrialHeader, "not-a-trial") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusConflict, rec.Code) - require.JSONEq(t, `{"error":{"type":"codex_quality_unavailable","message":"Codex quality run unavailable"}}`, rec.Body.String()) - require.Equal(t, int64(1), OpsErrorLogQueueLength()) - job := <-opsErrorLogQueue - require.Equal(t, http.StatusConflict, job.entry.StatusCode) - require.Equal(t, "Codex quality run unavailable", job.entry.ErrorMessage, "users can read error_message in their error-request view") - require.NotNil(t, job.entry.UpstreamErrorDetail) - require.True(t, strings.HasPrefix(*job.entry.UpstreamErrorDetail, "local: "+service.ErrCodexQualityUnavailable.Error()+": "), - "the admin-only detail names the concrete admission reason: %s", *job.entry.UpstreamErrorDetail) - require.Contains(t, *job.entry.UpstreamErrorDetail, service.CodexQualityGrantHeader) -} diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 4c2d91ff5613..5f1341975aed 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -37,10 +37,6 @@ const ( opsUpstreamModelKey = service.OpsUpstreamModelKey opsRequestTypeKey = "ops_request_type" opsDedicatedEntryEnqueuedKey = "ops_dedicated_entry_enqueued" - // opsLocalErrorDetailKey keeps the concrete reason of a local rejection - // whose client response is a fixed sentence. Only the stored Ops message - // reads it; classification and error filters use the client message. - opsLocalErrorDetailKey = "ops_local_error_detail" // 错误过滤匹配常量 — shouldSkipOpsErrorLog 和错误分类共用 opsErrContextCanceled = "context canceled" @@ -490,36 +486,6 @@ func setOpsSelectedAccount(c *gin.Context, accountID int64, platform ...string) } } -// setOpsLocalErrorDetail records, for administrators only, why a local check -// rejected a request whose client response stays a fixed sentence. -func setOpsLocalErrorDetail(c *gin.Context, detail string) { - if c == nil { - return - } - if detail = strings.TrimSpace(detail); detail != "" { - c.Set(opsLocalErrorDetailKey, truncateString(detail, 2048)) - } -} - -// applyOpsLocalErrorDetail stores the local reason in upstream_error_detail, -// prefixed "local:". It runs after classification, and error_message keeps the -// client's sentence: the user error view returns error_message and error_body -// but never upstream_error_detail. -func applyOpsLocalErrorDetail(c *gin.Context, entry *service.OpsInsertErrorLogInput) { - if c == nil || entry == nil { - return - } - detail := strings.TrimSpace(c.GetString(opsLocalErrorDetailKey)) - if detail == "" { - return - } - local := "local: " + detail - if entry.UpstreamErrorDetail != nil && strings.TrimSpace(*entry.UpstreamErrorDetail) != "" { - local += "\n" + *entry.UpstreamErrorDetail - } - entry.UpstreamErrorDetail = &local -} - // attachOpsRefusalRecoveryEvidence puts the evidence of HTTP refusal rewrites // and prompt retries in front of the attempts of a request that finally // failed. It is never classification input: the top-level upstream fields @@ -1345,7 +1311,6 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } } suppressOpsUpstreamAttributionForLocalModelConfiguration(c, entry) - applyOpsLocalErrorDetail(c, entry) attachOpsRefusalRecoveryEvidence(c, entry) if apiKey != nil { diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index ede7071c35ae..00fa5eee64a3 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -2,7 +2,6 @@ package handler import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -593,49 +592,6 @@ func TestOpsErrorLoggerMiddleware_StreamFailureGatewaySentenceKeepsUpstreamMessa require.Equal(t, "Previous response with id 'resp_1' not found.", *job.entry.UpstreamErrorMessage) } -// A local rejection keeps the client's sentence in error_message; the reason -// sits in upstream_error_detail, which the user error view never returns. -func TestOpsErrorLoggerMiddleware_LocalErrorDetailStaysAdminOnly(t *testing.T) { - setupOpsErrorLogTestQueue(t, 2) - gin.SetMode(gin.TestMode) - - const reason = "quality run r1 belongs to API key 7 of user 8 in group 9" - ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - router := gin.New() - router.Use(OpsErrorLoggerMiddleware(ops)) - router.POST("/v1/responses", func(c *gin.Context) { - setOpsLocalErrorDetail(c, reason) - c.JSON(http.StatusConflict, gin.H{"error": gin.H{"type": "codex_quality_unavailable", "message": "Codex quality run unavailable"}}) - }) - - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) - - require.JSONEq(t, `{"error":{"type":"codex_quality_unavailable","message":"Codex quality run unavailable"}}`, recorder.Body.String()) - require.Equal(t, int64(1), OpsErrorLogQueueLength()) - job := <-opsErrorLogQueue - require.Equal(t, "Codex quality run unavailable", job.entry.ErrorMessage) - require.NotEqual(t, "upstream", job.entry.ErrorPhase) - require.Nil(t, job.entry.UpstreamErrorMessage) - require.NotNil(t, job.entry.UpstreamErrorDetail) - require.Equal(t, "local: "+reason, *job.entry.UpstreamErrorDetail) - - stored := &service.OpsErrorLogDetail{ - OpsErrorLog: service.OpsErrorLog{ - Phase: job.entry.ErrorPhase, - Type: job.entry.ErrorType, - StatusCode: job.entry.StatusCode, - Message: job.entry.ErrorMessage, - }, - ErrorBody: job.entry.ErrorBody, - UpstreamErrorDetail: *job.entry.UpstreamErrorDetail, - } - userView, err := json.Marshal(service.ToUserErrorRequestDetail(stored)) - require.NoError(t, err) - require.NotContains(t, string(userView), "API key 7") - require.Contains(t, string(userView), "Codex quality run unavailable") -} - // A refusal the gateway rewrote or retried away is evidence, not an upstream // error: a request that succeeds leaves no Ops row, and a request that // finally fails carries the evidence without turning it into its upstream diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index e2350c9941f4..62a5a98f8956 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -53,13 +53,9 @@ func ProvideAdminHandlers( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, accountTrafficObserver *service.AccountTrafficObserver, - settingService *service.SettingService, - codexGateway *service.OpenAIGatewayService, opencodeGoUsage *service.OpenCodeGoUsageService, claudeResetCredits *service.ClaudeResetCreditService, ) *AdminHandlers { - accountHandler.SetCodexGateway(codexGateway) - settingHandler.SetCodexGateway(codexGateway) accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) accountHandler.SetAccountTrafficObserver(accountTrafficObserver) @@ -184,12 +180,11 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui } // ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs. -func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService, encryptor service.SecretEncryptor) *admin.SettingHandler { +func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) h.SetNotificationEmailService(notificationEmailService) h.SetAliyunCaptchaService(aliyunCaptchaService) h.SetStepUpDeps(totpService, userService) - h.SetNativeCodexConfigEncryptor(encryptor) return h } diff --git a/backend/internal/nativeapi/account_create.go b/backend/internal/nativeapi/account_create.go deleted file mode 100644 index 6d11f5d38b62..000000000000 --- a/backend/internal/nativeapi/account_create.go +++ /dev/null @@ -1,135 +0,0 @@ -package nativeapi - -import ( - "bytes" - "encoding/json" - "errors" - "io" -) - -const AccountCreateSlot = "account.create.v1" - -type AccountCreateDefaultsV1 struct { - Concurrency int `json:"concurrency"` - Priority int `json:"priority"` - RateMultiplier float64 `json:"rate_multiplier"` - LoadFactor *int `json:"load_factor"` - ResponsesMode string `json:"responses_mode"` -} - -type AccountCreateCredentialUIV1 struct { - BaseURL string `json:"base_url"` - BaseURLReadonly bool `json:"base_url_readonly"` - APIKeyPlaceholder string `json:"api_key_placeholder"` - Hint map[string]string `json:"hint"` - BaseURLHint map[string]string `json:"base_url_hint,omitempty"` - AccountTypeHint map[string]string `json:"account_type_hint,omitempty"` - CatalogLabel map[string]string `json:"catalog_label,omitempty"` - CatalogHint map[string]string `json:"catalog_hint,omitempty"` -} - -// AccountCreateDefinitionV1 is bounded manifest data. Credentials, endpoint -// normalization, generated device identity and provenance remain host-owned. -type AccountCreateDefinitionV1 struct { - Version int `json:"version"` - Platform string `json:"platform"` - AccountType string `json:"account_type"` - CredentialProfile string `json:"credential_profile"` - CredentialUI AccountCreateCredentialUIV1 `json:"credential_ui"` - Defaults AccountCreateDefaultsV1 `json:"defaults"` - MinimumEffectiveGroups int `json:"minimum_effective_groups"` - UpstreamBillingProbe string `json:"upstream_billing_probe"` - ModelEditing string `json:"model_editing"` - CatalogSource string `json:"catalog_source"` - FieldBindings map[string]string `json:"field_bindings"` -} - -// ProviderCreateRequestV1 contains preconditions and non-secret declared inputs. -// It never selects an installation or transports ordinary account credentials. -type ProviderCreateRequestV1 struct { - ContributionID string `json:"contribution_id"` - ExpectedPackageSHA256 string `json:"expected_package_sha256"` - ExpectedDefinitionSHA256 string `json:"expected_definition_sha256"` - ExpectedRuntimeGeneration int64 `json:"expected_runtime_generation"` - Values map[string]string `json:"values"` - InheritDefaults []string `json:"inherit_defaults"` -} - -func decodeAccountCreateJSON(raw []byte, value any, allowLoadFactorNull bool, required ...string) error { - var shape map[string]any - if json.Unmarshal(raw, &shape) != nil || shape == nil { - return errors.New("invalid account create JSON") - } - for _, key := range required { - if _, exists := shape[key]; !exists { - return errors.New("missing required account create field") - } - } - var check func(any, string) bool - check = func(value any, path string) bool { - switch typed := value.(type) { - case nil: - return allowLoadFactorNull && path == "defaults.load_factor" - case map[string]any: - for key, item := range typed { - next := key - if path != "" { - next = path + "." + key - } - if !check(item, next) { - return false - } - } - case []any: - for _, item := range typed { - if !check(item, path+"[]") { - return false - } - } - } - return true - } - if !check(shape, "") { - return errors.New("invalid account create null value") - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(value); err != nil { - return err - } - if err := decoder.Decode(new(any)); err != io.EOF { - return errors.New("invalid account create JSON") - } - return nil -} - -func (definition *AccountCreateDefinitionV1) UnmarshalJSON(raw []byte) error { - type plain AccountCreateDefinitionV1 - var decoded plain - if err := decodeAccountCreateJSON(raw, &decoded, true, "version", "platform", "account_type", "credential_profile", "credential_ui", "defaults", "minimum_effective_groups", "upstream_billing_probe", "model_editing", "catalog_source", "field_bindings"); err != nil { - return err - } - var fields map[string]json.RawMessage - _ = json.Unmarshal(raw, &fields) - var defaults map[string]json.RawMessage - if json.Unmarshal(fields["defaults"], &defaults) != nil { - return errors.New("invalid account create defaults") - } - for _, key := range []string{"concurrency", "priority", "rate_multiplier", "load_factor", "responses_mode"} { - if _, exists := defaults[key]; !exists { - return errors.New("missing account create default") - } - } - *definition = AccountCreateDefinitionV1(decoded) - return nil -} - -func (request *ProviderCreateRequestV1) UnmarshalJSON(raw []byte) error { - type plain ProviderCreateRequestV1 - var decoded plain - if err := decodeAccountCreateJSON(raw, &decoded, false, "values", "inherit_defaults"); err != nil { - return errors.New("invalid provider create input") - } - *request = ProviderCreateRequestV1(decoded) - return nil -} diff --git a/backend/internal/nativeapi/account_edit.go b/backend/internal/nativeapi/account_edit.go deleted file mode 100644 index b07d1c93bdc1..000000000000 --- a/backend/internal/nativeapi/account_edit.go +++ /dev/null @@ -1,102 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "errors" -) - -const AccountEditSlot = "account.edit.v1" - -type AccountEditCredentialUIV1 struct { - BaseURL string `json:"base_url"` - BaseURLReadonly bool `json:"base_url_readonly"` - Hint map[string]string `json:"hint"` -} - -type AccountEditWireControlV1 struct { - Target string `json:"target"` - Values []string `json:"values"` - AllowClear bool `json:"allow_clear"` -} - -type AccountEditLabelsV1 struct { - ManagedCatalog map[string]string `json:"managed_catalog"` - ManagedAliases map[string]string `json:"managed_aliases"` - CustomMappings map[string]string `json:"custom_mappings"` -} - -// AccountEditDefinitionV1 declares finite policy. Native account state and -// credentials never cross the public extension boundary. -type AccountEditDefinitionV1 struct { - Version int `json:"version"` - Platform string `json:"platform"` - AccountType string `json:"account_type"` - CredentialProfile string `json:"credential_profile"` - CredentialUI AccountEditCredentialUIV1 `json:"credential_ui"` - CatalogSource string `json:"catalog_source"` - MappingPolicy string `json:"mapping_policy"` - CompactMappingEditable bool `json:"compact_mapping_editable"` - WireControls []AccountEditWireControlV1 `json:"wire_controls"` - UnlistedFields string `json:"unlisted_fields"` - Labels AccountEditLabelsV1 `json:"labels"` -} - -// A nil Value distinguishes mapping intent and clear from a mode set. Mode -// enums and registered targets are validated by the host, not executable code. -type AccountEditChangeV1 struct { - Op string `json:"op"` - Value *string `json:"value,omitempty"` -} - -type ProviderEditRequestV1 struct { - ContributionID string `json:"contribution_id"` - ExpectedPackageSHA256 string `json:"expected_package_sha256"` - ExpectedDefinitionSHA256 string `json:"expected_definition_sha256"` - ExpectedRuntimeGeneration int64 `json:"expected_runtime_generation"` - ExpectedStateSHA256 string `json:"expected_state_sha256"` - ExpectedCatalogNamespace string `json:"expected_catalog_namespace,omitempty"` - Changes map[string]AccountEditChangeV1 `json:"changes"` -} - -func (definition *AccountEditDefinitionV1) UnmarshalJSON(raw []byte) error { - type plain AccountEditDefinitionV1 - var decoded plain - if err := decodeAccountCreateJSON(raw, &decoded, false, "version", "platform", "account_type", "credential_profile", "credential_ui", "catalog_source", "mapping_policy", "compact_mapping_editable", "wire_controls", "unlisted_fields", "labels"); err != nil { - return errors.New("invalid account edit definition") - } - var fields map[string]json.RawMessage - _ = json.Unmarshal(raw, &fields) - for field, required := range map[string][]string{ - "credential_ui": {"base_url", "base_url_readonly", "hint"}, - "labels": {"managed_catalog", "managed_aliases", "custom_mappings"}, - } { - var members map[string]json.RawMessage - _ = json.Unmarshal(fields[field], &members) - for _, key := range required { - if _, present := members[key]; !present { - return errors.New("missing account edit definition member") - } - } - } - var controls []map[string]json.RawMessage - _ = json.Unmarshal(fields["wire_controls"], &controls) - for _, control := range controls { - for _, key := range []string{"target", "values", "allow_clear"} { - if _, present := control[key]; !present { - return errors.New("missing account edit control member") - } - } - } - *definition = AccountEditDefinitionV1(decoded) - return nil -} - -func (request *ProviderEditRequestV1) UnmarshalJSON(raw []byte) error { - type plain ProviderEditRequestV1 - var decoded plain - if err := decodeAccountCreateJSON(raw, &decoded, false, "expected_state_sha256", "changes"); err != nil { - return errors.New("invalid provider edit input") - } - *request = ProviderEditRequestV1(decoded) - return nil -} diff --git a/backend/internal/nativeapi/account_view.go b/backend/internal/nativeapi/account_view.go deleted file mode 100644 index c37f6f4d3032..000000000000 --- a/backend/internal/nativeapi/account_view.go +++ /dev/null @@ -1,212 +0,0 @@ -package nativeapi - -import ( - "bytes" - "encoding/json" - "errors" - "io" -) - -// AccountViewSlot and AccountConsoleSource are versioned, host-owned registries. -// A view declares data and references; it cannot select a URL, query language or -// private host component. -const ( - AccountViewSlot = "account.view.v1" - AccountConsoleSource = "accounts.console.v1" - AccountViewHeader = "X-Sub2API-Account-View" -) - -type AccountViewPredicate struct { - Platforms []string `json:"platforms,omitempty"` - Types []string `json:"types,omitempty"` - Statuses []string `json:"statuses,omitempty"` - Plans []string `json:"plans,omitempty"` - PrivacyMode string `json:"privacy_mode,omitempty"` -} - -type AccountViewPreset struct { - ID string `json:"id"` - Label map[string]string `json:"label"` - Query AccountViewPredicate `json:"query"` - Counter string `json:"counter"` -} - -type AccountViewLayout struct { - Kind string `json:"kind"` - SurfaceRef string `json:"surface_ref,omitempty"` -} - -type AccountViewTable struct { - Layouts []string `json:"layouts"` - ColumnRefs []string `json:"column_refs"` - ActionRefs []string `json:"action_refs"` -} - -type AccountViewNavigation struct { - Section string `json:"section"` - TargetView string `json:"target_view"` - Icon string `json:"icon"` - Order int `json:"order,omitempty"` -} - -type AccountViewLegacyAlias struct { - Match map[string]string `json:"match"` - Preset string `json:"preset"` - Priority int `json:"priority"` -} - -type AccountViewDefinitionV1 struct { - Version int `json:"version"` - Source string `json:"source"` - BaseQuery AccountViewPredicate `json:"base_query"` - DefaultPreset string `json:"default_preset"` - ExtendsCoreFilters bool `json:"extends_core_filters,omitempty"` - CoreFilterSurfaceRefs []string `json:"core_filter_surface_refs,omitempty"` - Presets []AccountViewPreset `json:"presets"` - Layout []AccountViewLayout `json:"layout"` - Table AccountViewTable `json:"table"` - Navigation *AccountViewNavigation `json:"navigation,omitempty"` - LegacyQueryAliases []AccountViewLegacyAlias `json:"legacy_query_aliases,omitempty"` -} - -type AccountResourceActionV1 struct { - Version int `json:"version"` - Resource string `json:"resource"` - AccountParameter string `json:"account_parameter"` - AccountSource string `json:"account_source"` - RowPredicate *AccountViewPredicate `json:"row_predicate,omitempty"` - Effect string `json:"effect"` -} - -// AccountViewIdentityV1 contains no search, row data or credentials. The host -// transports its JSON as unpadded base64url in AccountViewHeader. The plugin -// operation owner remains in the existing X-Sub2API-Plugin headers; it may be a -// different installation from this origin-view owner. -type AccountViewIdentityV1 struct { - Version int `json:"version"` - PluginID int64 `json:"plugin_id"` - PluginKey string `json:"plugin_key"` - PackageSHA256 string `json:"package_sha256"` - ViewID string `json:"view_id"` - PresetID string `json:"preset_id"` - ViewDefinitionDigest string `json:"view_definition_digest"` -} - -// AccountViewQueryV1 is the normalized user-query layer. Native HTTP -// pagination stays outside this captured selection query. Folder/proxy -// identifiers are decimal strings, with uncategorized/direct sentinels. -type AccountViewQueryV1 struct { - Platforms []string `json:"platforms,omitempty"` - Types []string `json:"types,omitempty"` - Statuses []string `json:"statuses,omitempty"` - Plans []string `json:"plans,omitempty"` - Proxies []string `json:"proxies,omitempty"` - Folders []string `json:"folders,omitempty"` - Tags []int64 `json:"tags,omitempty"` - AccountIDs []int64 `json:"account_ids,omitempty"` - GroupID int64 `json:"group_id,omitempty"` - PrivacyMode string `json:"privacy_mode,omitempty"` - Search string `json:"search,omitempty"` - SortBy string `json:"sort_by,omitempty"` - SortOrder string `json:"sort_order,omitempty"` -} - -// A host-captured view_context accompanies mutation/job payloads. Full Query is -// retained only in the existing encrypted job payload, not public job metadata. -type AccountViewContextV1 struct { - AccountViewIdentityV1 - Query AccountViewQueryV1 `json:"query"` -} - -type AccountViewRecoveryAck struct { - AccountID int64 `json:"account_id"` - Recovered bool `json:"recovered"` -} - -func decodeAccountViewJSON(raw []byte, value any) error { - var shape any - if json.Unmarshal(raw, &shape) != nil || accountViewContainsNull(shape) { - return errors.New("invalid account view JSON shape") - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(value); err != nil { - return err - } - if err := decoder.Decode(new(any)); err != io.EOF { - return errors.New("invalid account view JSON") - } - return nil -} - -func accountViewContainsNull(value any) bool { - switch typed := value.(type) { - case nil: - return true - case map[string]any: - for _, child := range typed { - if accountViewContainsNull(child) { - return true - } - } - case []any: - for _, child := range typed { - if accountViewContainsNull(child) { - return true - } - } - } - return false -} - -func (view *AccountViewDefinitionV1) UnmarshalJSON(raw []byte) error { - type plain AccountViewDefinitionV1 - var decoded plain - if err := decodeAccountViewJSON(raw, &decoded); err != nil { - return err - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return err - } - for _, key := range []string{"version", "source", "base_query", "default_preset", "presets", "layout", "table"} { - if len(fields[key]) == 0 || bytes.Equal(fields[key], []byte("null")) { - return errors.New("missing required account view field") - } - } - var presets []map[string]json.RawMessage - if err := json.Unmarshal(fields["presets"], &presets); err != nil { - return err - } - for _, preset := range presets { - if len(preset["query"]) == 0 || bytes.Equal(preset["query"], []byte("null")) { - return errors.New("missing account view preset query") - } - } - *view = AccountViewDefinitionV1(decoded) - return nil -} - -func (action *AccountResourceActionV1) UnmarshalJSON(raw []byte) error { - type plain AccountResourceActionV1 - var decoded plain - if err := decodeAccountViewJSON(raw, &decoded); err != nil { - return err - } - *action = AccountResourceActionV1(decoded) - return nil -} - -func (view *AccountViewContextV1) UnmarshalJSON(raw []byte) error { - type plain AccountViewContextV1 - var decoded plain - if err := decodeAccountViewJSON(raw, &decoded); err != nil { - return err - } - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil || len(fields["query"]) == 0 { - return errors.New("missing captured account view query") - } - *view = AccountViewContextV1(decoded) - return nil -} diff --git a/backend/internal/nativeapi/api.go b/backend/internal/nativeapi/api.go index 4949353640d9..bb1ae7bb1807 100644 --- a/backend/internal/nativeapi/api.go +++ b/backend/internal/nativeapi/api.go @@ -1,117 +1,18 @@ // Package nativeapi contains the shared payloads of the in-process domains. -// Stored JSON field names remain compatible with existing account data and ledgers. +// Stored JSON field names remain compatible with existing account data. package nativeapi import ( "context" "encoding/json" - "errors" ) const ( // Preserve the existing bound on internal policy plans and catalog payloads. - MaxPayloadBytes = 4 * 1024 * 1024 - Version = 1 - CapabilityProvider = "extensions.provider.v1" - CapabilityCatalog = "extensions.catalog.v1" - CapabilityRequest = "extensions.request.v1" - CapabilityAdmin = "extensions.admin.v1" - CapabilityUI = "extensions.ui.v1" - CapabilityCredentials = "extensions.credentials.v1" - CapabilityObservability = "extensions.observability.v1" - CapabilityRecovery = "extensions.recovery.v1" + MaxPayloadBytes = 4 * 1024 * 1024 + CapabilityAdmin = "extensions.admin.v1" ) -var capabilities = map[string]bool{ - CapabilityProvider: true, CapabilityCatalog: true, CapabilityRequest: true, - CapabilityAdmin: true, CapabilityUI: true, - CapabilityCredentials: true, - CapabilityObservability: true, - CapabilityRecovery: true, -} - -func IsCapability(id string) bool { return capabilities[id] } - -type Dependency struct { - Capability string `json:"capability"` - Platform string `json:"platform,omitempty"` - AccountType string `json:"account_type,omitempty"` -} - -type Contribution struct { - AccountEdit *AccountEditDefinitionV1 `json:"account_edit,omitempty"` - AccountCreate *AccountCreateDefinitionV1 `json:"account_create,omitempty"` - AccountView *AccountViewDefinitionV1 `json:"account_view,omitempty"` - ValueBindings map[string]string `json:"value_bindings,omitempty"` - ResourceAction *AccountResourceActionV1 `json:"resource_action,omitempty"` - AllAccounts bool `json:"all_accounts,omitempty"` - RetainedControls bool `json:"retained_controls,omitempty"` - Events []string `json:"events,omitempty"` - Capability string `json:"capability,omitempty"` - Assets []string `json:"assets,omitempty"` - ConfigFlag string `json:"config_flag,omitempty"` - Fields []FormField `json:"fields,omitempty"` - DisplayFields []DisplayField `json:"display_fields,omitempty"` - AccountFilter *AccountFilter `json:"account_filter,omitempty"` - ID string `json:"id"` - Slot string `json:"slot"` - Label map[string]string `json:"label"` - Action string `json:"action,omitempty"` - Entrypoint string `json:"entrypoint,omitempty"` - Permission string `json:"permission"` - Order int `json:"order,omitempty"` -} - -type DisplayValue struct { - Label map[string]string `json:"label"` - Tone string `json:"tone,omitempty"` -} - -// DisplayField only reads scalar values explicitly supplied by the host mount. -// It cannot evaluate expressions or traverse arbitrary account properties. -type DisplayField struct { - Key string `json:"key"` - Kind string `json:"kind"` - Prefix string `json:"prefix,omitempty"` - NewLine bool `json:"new_line,omitempty"` - Values map[string]DisplayValue `json:"values,omitempty"` -} - -type FormField struct { - Key string `json:"key"` - Kind string `json:"kind"` - Label map[string]string `json:"label"` - OptionsSource string `json:"options_source,omitempty"` - DefaultLabel map[string]string `json:"default_label,omitempty"` - DefaultSource string `json:"default_source,omitempty"` - Placeholder string `json:"placeholder,omitempty"` - Rows int `json:"rows,omitempty"` - MaxLength int `json:"max_length,omitempty"` - Hint map[string]string `json:"hint,omitempty"` - ResetLabel map[string]string `json:"reset_label,omitempty"` - LimitMessage map[string]string `json:"limit_message,omitempty"` -} - -type AccountFilter struct { - Platforms []string `json:"platforms,omitempty"` - Types []string `json:"types,omitempty"` - Statuses []string `json:"statuses,omitempty"` - ExcludeShadows bool `json:"exclude_shadows,omitempty"` -} - -var slots = map[string]bool{ - AccountEditSlot: true, - AccountCreateSlot: true, - AccountViewSlot: true, - "admin.page": true, "admin.settings": true, "account.actions": true, - "account.details": true, "account.columns": true, "account.test": true, "account.test.prompt": true, - "group.actions": true, "group.details": true, "navigation": true, - "theme": true, "usage.details": true, - "surface": true, -} - -func ValidSlot(slot string) bool { return slots[slot] } - // Invocation carries one declared capability and a domain operation. The host // validates the capability binding before crossing the process boundary. type Invocation struct { @@ -131,44 +32,6 @@ type Result struct { Message string `json:"message,omitempty"` } -type JobTarget struct { - AccountID int64 `json:"account_id"` - Payload json.RawMessage `json:"payload"` - Label string `json:"label,omitempty"` -} - -type StateRequest struct { - Namespace string `json:"namespace"` - Key string `json:"key"` - ExpectedRevision int64 `json:"expected_revision"` - Value json.RawMessage `json:"value,omitempty"` -} - -type StateResult struct { - Found bool `json:"found"` - Applied bool `json:"applied"` - Revision int64 `json:"revision"` - Value json.RawMessage `json:"value,omitempty"` -} - -// Handler is implemented by a domain plugin, never by a host forwarding shim. -type Handler interface { - Invoke(context.Context, Invocation) (Result, error) -} - type OperationInvoker interface { - InvokeOperation(context.Context, string, string, Invocation) (Result, error) -} - -type CachedOperationInvoker interface { - InvokeCachedOperation(context.Context, string, string, Invocation) (Result, error) -} - -func Encode(value any) (json.RawMessage, error) { return json.Marshal(value) } - -func Decode(raw json.RawMessage, into any) error { - if len(raw) == 0 { - return errors.New("empty extension payload") - } - return json.Unmarshal(raw, into) + InvokeOperation(context.Context, Invocation) (Result, error) } diff --git a/backend/internal/nativeapi/codex_identity.go b/backend/internal/nativeapi/codex_identity.go index db01fed7667a..c3d0c2707937 100644 --- a/backend/internal/nativeapi/codex_identity.go +++ b/backend/internal/nativeapi/codex_identity.go @@ -1,32 +1,13 @@ package nativeapi -// Profile fields are persisted by the host. Their generation and interpretation -// belong to the Codex domain process; no credential is part of this contract. +// Profile fields are persisted in accounts.extra. codexruntime/profile generates +// and interprets them; no credential is part of a profile. type CodexClientProfile struct { - Source string `json:"source,omitempty"` - Originator string `json:"originator,omitempty"` - ClientVersion string `json:"client_version,omitempty"` - Version int `json:"v"` - OSType string `json:"os_type"` - OSVersion string `json:"os_version"` - Arch string `json:"arch"` - Terminal string `json:"terminal"` - Sandbox string `json:"sandbox"` - GeneratedAt string `json:"generated_at,omitempty"` -} - -type CodexIdentityQuery struct { - Preset string `json:"preset,omitempty"` - Seed string `json:"seed,omitempty"` - Profile CodexClientProfile `json:"profile,omitempty"` - Version string `json:"version,omitempty"` - UserAgent string `json:"user_agent,omitempty"` -} - -type CodexIdentityResult struct { - DefaultFingerprintMode string `json:"default_fingerprint_mode,omitempty"` - Profile CodexClientProfile `json:"profile,omitempty"` - Valid bool `json:"valid"` - UserAgent string `json:"user_agent,omitempty"` - Sandbox string `json:"sandbox,omitempty"` + Version int `json:"v"` + OSType string `json:"os_type"` + OSVersion string `json:"os_version"` + Arch string `json:"arch"` + Terminal string `json:"terminal"` + Sandbox string `json:"sandbox"` + GeneratedAt string `json:"generated_at,omitempty"` } diff --git a/backend/internal/nativeapi/codex_runtime.go b/backend/internal/nativeapi/codex_runtime.go new file mode 100644 index 000000000000..8ec48e0cb396 --- /dev/null +++ b/backend/internal/nativeapi/codex_runtime.go @@ -0,0 +1,5 @@ +package nativeapi + +type CodexRuntimeConfig struct { + RequestZstd bool `json:"request_zstd"` +} diff --git a/backend/internal/nativeapi/codex_transport.go b/backend/internal/nativeapi/codex_transport.go deleted file mode 100644 index a2330cc50d96..000000000000 --- a/backend/internal/nativeapi/codex_transport.go +++ /dev/null @@ -1,15 +0,0 @@ -package nativeapi - -// Transport planning receives bounded request metadata, never request bodies. -type CodexTransportQuery struct { - Method string `json:"method"` - Path string `json:"path"` - ContentType string `json:"content_type"` - ContentEncoding string `json:"content_encoding"` - BodyPresent bool `json:"body_present"` -} - -type CodexTransportPlan struct { - Enabled bool `json:"enabled"` - Compress bool `json:"compress"` -} diff --git a/backend/internal/nativeapi/observability.go b/backend/internal/nativeapi/observability.go index 2dd681239cb8..1774fd3c1714 100644 --- a/backend/internal/nativeapi/observability.go +++ b/backend/internal/nativeapi/observability.go @@ -19,14 +19,3 @@ type AccountTrafficCounters struct { RequestsLast60s int `json:"requests_last_60s"` ObservedSinceMs int64 `json:"observed_since_ms"` } -type AccountTrafficSnapshot struct { - AccountID int64 `json:"account_id"` - Protocols map[string]AccountTrafficCounters `json:"protocols"` -} -type AccountTrafficDisplay struct { - Protocol string `json:"protocol"` - AccountTrafficCounters - Finished int64 `json:"finished"` - Unfinished int64 `json:"unfinished"` - CompletionRate *float64 `json:"completion_rate"` -} diff --git a/backend/internal/nativeapi/resources.go b/backend/internal/nativeapi/resources.go deleted file mode 100644 index 32b50f9f161d..000000000000 --- a/backend/internal/nativeapi/resources.go +++ /dev/null @@ -1,32 +0,0 @@ -package nativeapi - -// ResourceGrant authorizes one named host data operation. Network addresses and -// HTTP methods come from the host registry, never from a plugin manifest. -type ResourceGrant struct { - Name string `json:"name"` - Capability string `json:"capability"` - Permission string `json:"permission"` -} - -type ResourceDescriptor struct { - // These additional requirements are host policy, never manifest input. - RequiredCapabilities []string `json:"-"` - OwnerPluginKey string `json:"-"` - WholeAccountViewDomain bool `json:"-"` - Retained bool `json:"retained,omitempty"` - ResponseKind string `json:"response_kind,omitempty"` - AccountParam string `json:"-"` - AccountBodyField string `json:"-"` - AccountItemsField string `json:"-"` - AccountScopeField string `json:"-"` - AllAccounts bool `json:"-"` - // Fixed filter scope is registered by the host only for handlers whose - // selection predicate always enforces this platform and account type. - FilterPlatform string `json:"-"` - FilterAccountType string `json:"-"` - FilterField string `json:"-"` - ResourceGrant - Method string `json:"method"` - Path string `json:"path"` - Available bool `json:"available"` -} diff --git a/backend/internal/repository/account_job_repo.go b/backend/internal/repository/account_job_repo.go index 877426440978..7672a0987ec0 100644 --- a/backend/internal/repository/account_job_repo.go +++ b/backend/internal/repository/account_job_repo.go @@ -148,9 +148,7 @@ func (r *accountJobRepository) Create(ctx context.Context, params service.Create if err != nil { return nil, false, err } - oldOwner, _ := service.AccountJobPluginExecution(job.Metadata) - newOwner, _ := service.AccountJobPluginExecution(params.Metadata) - if job.RequestHash != params.RequestHash || oldOwner.ID != newOwner.ID || !service.AccountJobViewIdentityEqual(job.Metadata, params.Metadata) { + if job.RequestHash != params.RequestHash { return nil, false, service.ErrAccountJobIdempotencyConflict } if err = tx.Commit(); err != nil { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index a89f678304b9..72d07b6695bf 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -108,48 +108,6 @@ func stripCodexFingerprintSeedFromExtraUpdate(extra map[string]any) map[string]a return stripped } -// isRetiredCodexTicketExtraKey matches the account extra keys of the retired -// Codex route acquisition that migration 263 purged. -func isRetiredCodexTicketExtraKey(key string) bool { - return strings.HasPrefix(key, "codex_turn_ticket:") || - strings.HasPrefix(key, "codex_ticket_runtime:") || - key == "codex_harvest_proxy_url" -} - -// withoutRetiredCodexTicketExtra keeps account writes from storing the data -// migration 263 purged, so importing an account export taken before it cannot -// bring that data back. Like 263 it also drops the codexrip.codex-runtime entry -// of plugin_account_projections, and the map only once no other entry is left. -// extra is returned as is when it holds none of this. -func withoutRetiredCodexTicketExtra(extra map[string]any) map[string]any { - projections, _ := extra["plugin_account_projections"].(map[string]any) - _, projected := projections["codexrip.codex-runtime"] - stripped := make(map[string]any, len(extra)) - for key, value := range extra { - if !isRetiredCodexTicketExtraKey(key) { - stripped[key] = value - } - } - if !projected { - if len(stripped) == len(extra) { - return extra - } - return stripped - } - rest := make(map[string]any, len(projections)) - for key, value := range projections { - if key != "codexrip.codex-runtime" { - rest[key] = value - } - } - if len(rest) == 0 { - delete(stripped, "plugin_account_projections") - } else { - stripped["plugin_account_projections"] = rest - } - return stripped -} - // NewAccountRepository 创建账户仓储实例。 // 这是对外暴露的构造函数,返回接口类型以便于依赖注入。 func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AccountRepository { @@ -198,7 +156,7 @@ func createAccountRecord(ctx context.Context, client *dbent.Client, account *ser SetPlatform(account.Platform). SetType(account.Type). SetCredentials(normalizeJSONMap(account.Credentials)). - SetExtra(normalizeJSONMap(withoutRetiredCodexTicketExtra(account.Extra))). + SetExtra(normalizeJSONMap(account.Extra)). SetConcurrency(account.Concurrency). SetPriority(account.Priority). SetStatus(account.Status). @@ -686,7 +644,7 @@ func lockAndMergeAccountProbeExtra( extra -> 'ollama_cloud_usage_snapshot', extra -> 'model_context_overrides', extra -> 'upstream_model_metadata', - COALESCE(extra, '{}'::jsonb), + extra -> 'system_prompt', COALESCE( ( -- opencode_go 平台分支:新旧都是 opencode_go Go 订阅(account_mode @@ -736,7 +694,7 @@ func lockAndMergeAccountProbeExtra( currentOllamaSnapshot []byte currentContextOverrides []byte currentModelMetadata []byte - currentExtraJSON []byte + currentSystemPrompt []byte opencodeGroupIdentityUnchanged bool currentOpenCodeAutoRefresh []byte currentOpenCodeSnapshot []byte @@ -753,7 +711,7 @@ func lockAndMergeAccountProbeExtra( ¤tOllamaSnapshot, ¤tContextOverrides, ¤tModelMetadata, - ¤tExtraJSON, + ¤tSystemPrompt, &opencodeGroupIdentityUnchanged, ¤tOpenCodeAutoRefresh, ¤tOpenCodeSnapshot, @@ -770,23 +728,12 @@ func lockAndMergeAccountProbeExtra( if err != nil { return nil, err } - // extra 理论上恒为 JSON 对象,但历史数据若存成非对象(数组/标量),在此硬失败 - // 会让该账号的任何编辑都保存不了——而这条路径覆盖所有平台的账号更新。 - // 解析失败时降级为「无锁内现值可保留」继续完成编辑,不要把整个账号更新拖垮。 - var currentExtra map[string]any - if len(currentExtraJSON) > 0 { - if err := json.Unmarshal(currentExtraJSON, ¤tExtra); err != nil { - logger.LegacyPrintf("repository.account", - "[Account] current extra unmarshal failed, locked extra preservation skipped: id=%d err=%v", - account.ID, err) - currentExtra = nil - } - } - extra = withoutRetiredCodexTicketExtra(extra) // Preserve the system prompt binding read under the row lock. A stale // general account editor cannot overwrite the dedicated binding endpoint. delete(extra, service.AccountExtraSystemPromptKey) - if binding, exists := currentExtra[service.AccountExtraSystemPromptKey]; exists { + if binding, exists, err := decodeAccountExtraJSON(currentSystemPrompt); err != nil { + return nil, err + } else if exists { extra[service.AccountExtraSystemPromptKey] = binding } for _, key := range []string{ @@ -2920,7 +2867,6 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti } func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { - updates = withoutRetiredCodexTicketExtra(updates) updates = stripCodexFingerprintSeedFromExtraUpdate(updates) if len(updates) == 0 { return nil @@ -3276,7 +3222,6 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates return 0, nil } updates.Extra = stripCodexFingerprintSeedFromExtraUpdate(updates.Extra) - updates.Extra = withoutRetiredCodexTicketExtra(updates.Extra) setClauses := make([]string, 0, 8) args := make([]any, 0, 8) diff --git a/backend/internal/repository/account_repo_model_context_test.go b/backend/internal/repository/account_repo_model_context_test.go index 8087940bc94f..87e49c65043b 100644 --- a/backend/internal/repository/account_repo_model_context_test.go +++ b/backend/internal/repository/account_repo_model_context_test.go @@ -162,7 +162,7 @@ func TestLockAndMergeAccountProbeExtraUsesLockedModelContextValues(t *testing.T) WillReturnRows(sqlmock.NewRows([]string{ "identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", - "model_context_overrides", "upstream_model_metadata", "current_extra", + "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot", }).AddRow(identityUnchanged, false, true, nil, nil, nil, nil, nil, nil, []byte(`{"other-model":700000}`), []byte(`{"fresh":true}`), nil, false, nil, nil)) @@ -182,39 +182,47 @@ func TestLockAndMergeAccountProbeExtraUsesLockedModelContextValues(t *testing.T) } } -// This path serves every account update: a non-object stored extra must not -// fail the edit, only skip carrying locked values over. Data of the retired -// Codex route acquisition supplied by the editor or an import is never written. -func TestLockAndMergeAccountExtraDegradesOnUnparsableExtra(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) - t.Cleanup(func() { _ = client.Close() }) - mock.ExpectQuery(`(?s)SELECT.*FOR NO KEY UPDATE`). - WithArgs(int64(41), service.PlatformOpenAI, service.AccountTypeOAuth, `{"access_token":"test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "current_extra", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). - AddRow(true, false, true, nil, nil, nil, nil, nil, nil, nil, nil, []byte(`[1,2,3]`), false, nil, nil)) - account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}, Extra: map[string]any{"plugin_account_projections": map[string]any{"codexrip.codex-runtime": map[string]any{"forged": true}}, "codex_turn_ticket:gpt-6-astra": map[string]any{"state": "forged"}, "codex_harvest_proxy_url": "http://proxy.example.test:8080", "new_admin_setting": true}} - extra, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) - require.NoError(t, err, "unparsable extra must not fail the account update") - require.Equal(t, true, extra["new_admin_setting"]) - for _, key := range []string{"plugin_account_projections", "codex_turn_ticket:gpt-6-astra", "codex_harvest_proxy_url"} { - require.NotContains(t, extra, key) - } - require.NoError(t, mock.ExpectationsWereMet()) -} +// The system prompt binding is written only by its own endpoint: an account +// update keeps what is stored under the row lock, its absence included, +// whatever the editor submitted. +func TestLockAndMergeAccountProbeExtraKeepsLockedSystemPromptBinding(t *testing.T) { + for _, test := range []struct { + name string + locked []byte + want any + }{ + {name: "stored binding", locked: []byte(`{"mode":"off"}`), want: map[string]any{"mode": "off"}}, + {name: "no stored binding"}, + } { + t.Run(test.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + mock.ExpectQuery(`(?s)SELECT.*extra -> 'system_prompt'.*FOR NO KEY UPDATE`). + WithArgs(int64(41), service.PlatformOpenAI, service.AccountTypeOAuth, `{"access_token":"test"}`, nil). + WillReturnRows(sqlmock.NewRows([]string{ + "identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", + "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", + "model_context_overrides", "upstream_model_metadata", "system_prompt", + "opencode_group_unchanged", "opencode_auto", "opencode_snapshot", + }).AddRow(true, false, true, nil, nil, nil, nil, nil, nil, nil, nil, test.locked, false, nil, nil)) + account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}, + Extra: map[string]any{"new_admin_setting": true, service.AccountExtraSystemPromptKey: map[string]any{"mode": "custom", "prompt_id": "stale"}}} -// Like migration 263, only the codexrip.codex-runtime projection goes; another -// plugin's entry and a key that merely shares a name stay. -func TestWithoutRetiredCodexTicketExtraKeepsOtherEntries(t *testing.T) { - kept := map[string]any{"codex_turn_ticket_summary": "not the retired prefix", "plugin_account_projections": map[string]any{"acme.other-plugin": true}} - require.Equal(t, kept, withoutRetiredCodexTicketExtra(kept)) - require.Equal(t, kept, withoutRetiredCodexTicketExtra(map[string]any{ - "codex_turn_ticket_summary": "not the retired prefix", - "codex_ticket_runtime:gpt-6-astra": map[string]any{"phase": "ready"}, - "plugin_account_projections": map[string]any{"codexrip.codex-runtime": map[string]any{"identity": "owner"}, "acme.other-plugin": true}, - })) + extra, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) + + require.NoError(t, err) + require.Equal(t, true, extra["new_admin_setting"]) + if test.want == nil { + require.NotContains(t, extra, service.AccountExtraSystemPromptKey) + } else { + require.Equal(t, test.want, extra[service.AccountExtraSystemPromptKey]) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } } func TestShouldEnqueueSchedulerOutboxForExtraUpdatesModelContextKeys(t *testing.T) { @@ -242,7 +250,7 @@ func TestUpdateAccountModelContextPatchClearsOnlyAfterSuccessfulWrite(t *testing WillReturnRows(sqlmock.NewRows([]string{ "identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", - "model_context_overrides", "upstream_model_metadata", "current_extra", + "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot", }).AddRow(true, false, true, nil, nil, nil, nil, nil, nil, []byte(`{"other-model":700000}`), []byte(`{"fresh":true}`), nil, false, nil, nil)) diff --git a/backend/internal/repository/account_repo_opencode_go_usage_test.go b/backend/internal/repository/account_repo_opencode_go_usage_test.go index 2a41d84706eb..8205cc44b00f 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage_test.go +++ b/backend/internal/repository/account_repo_opencode_go_usage_test.go @@ -75,7 +75,7 @@ func openCodeGoMergeMockColumns() []string { "identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", - "model_context_overrides", "upstream_model_metadata", "current_extra", + "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot", } } diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go index 1f3d7f069e8a..63c7bf1a6d51 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go @@ -81,7 +81,7 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "current_extra", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil, nil, nil, nil, false, nil, nil)) account := &service.Account{ @@ -172,7 +172,7 @@ func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "current_extra", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil, nil, nil, nil, false, nil, nil)) account := &service.Account{ @@ -211,7 +211,7 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "current_extra", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, false, nil, nil)) account := &service.Account{ @@ -371,7 +371,7 @@ func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T) mock.ExpectBegin() mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "current_extra", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "model_context_overrides", "upstream_model_metadata", "system_prompt", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, nil, nil, nil, false, nil, nil)) mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/repository/codex_native_lease.go b/backend/internal/repository/codex_native_lease.go deleted file mode 100644 index 274e81af54b0..000000000000 --- a/backend/internal/repository/codex_native_lease.go +++ /dev/null @@ -1,154 +0,0 @@ -package repository - -import ( - "context" - "errors" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/stdlib" - "sync" - "time" -) - -const nativeCodexRuntimeLeaseProbeInterval = time.Second -const nativeCodexRuntimeLeaseProbeTimeout = time.Second - -type nativeCodexRuntimeLeaseSession interface { - Ping(context.Context) error - Close(context.Context) error -} - -type nativeCodexRuntimeSessionLease struct { - conn nativeCodexRuntimeLeaseSession - done chan struct{} - stopped chan struct{} - cancel context.CancelFunc - releaseOnce sync.Once - mu sync.RWMutex - releasing bool - err error -} - -func newNativeCodexRuntimeSessionLease(conn nativeCodexRuntimeLeaseSession, interval, timeout time.Duration) *nativeCodexRuntimeSessionLease { - ctx, cancel := context.WithCancel(context.Background()) - lease := &nativeCodexRuntimeSessionLease{conn: conn, done: make(chan struct{}), stopped: make(chan struct{}), cancel: cancel} - go lease.monitor(ctx, interval, timeout) - return lease -} - -func (l *nativeCodexRuntimeSessionLease) Done() <-chan struct{} { return l.done } -func (l *nativeCodexRuntimeSessionLease) Err() error { - l.mu.RLock() - defer l.mu.RUnlock() - return l.err -} -func (l *nativeCodexRuntimeSessionLease) Release() { - l.releaseOnce.Do(func() { - l.mu.Lock() - l.releasing = true - l.mu.Unlock() - l.cancel() - }) - <-l.stopped -} - -func (l *nativeCodexRuntimeSessionLease) monitor(ctx context.Context, interval, timeout time.Duration) { - var lost bool - defer func() { - l.cancel() - l.mu.Lock() - if lost && !l.releasing { - l.err = service.ErrNativeCodexRuntimeUnavailable - } - close(l.done) - l.mu.Unlock() - // This goroutine alone owns Ping/Close. Done is published before the - // bounded close so callers can revoke work without waiting for IO. - closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = l.conn.Close(closeCtx) - close(l.stopped) - }() - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - probeCtx, cancel := context.WithTimeout(ctx, timeout) - err := l.conn.Ping(probeCtx) - cancel() - if err != nil { - lost = true - return - } - } -} - -func (r *nativeCodexRepository) HoldNativeCodexRuntime(ctx context.Context, installation *service.NativeCodexMetadata) (service.NativeCodexRuntimeLease, error) { - if installation == nil || !service.NativeCodexBusinessIORequired(ctx) { - return nil, service.ErrNativeCodexRuntimeChanged - } - pooled, err := r.db.Conn(ctx) - if err != nil { - return nil, err - } - var config *pgx.ConnConfig - err = pooled.Raw(func(raw any) error { - conn, ok := raw.(*stdlib.Conn) - if !ok { - return errors.New("plugin runtime leases require PostgreSQL") - } - config = conn.Conn().Config().Copy() - return nil - }) - _ = pooled.Close() - if err != nil { - return nil, err - } - // A process-lifetime session must not occupy the business query pool: a - // small configured pool would deadlock during startup or package replacement. - if config.RuntimeParams == nil { - config.RuntimeParams = map[string]string{} - } - config.RuntimeParams["application_name"] = "sub2api-plugin-lease" - conn, err := pgx.ConnectConfig(ctx, config) - if err != nil { - return nil, err - } - var locked bool - err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock_shared(hashtextextended($1,0))`, nativeCodexRuntimeLockName(installation.ID)).Scan(&locked) - if err != nil || !locked { - _ = conn.Close(ctx) - if err == nil { - err = service.ErrNativeCodexRuntimeChanged - } - return nil, err - } - var generation int64 - var state, retired, configRecord string - err = conn.QueryRow(ctx, `SELECT runtime_generation,state FROM sub2api_plugin_installations WHERE id=$1 AND plugin_key=$2`, installation.ID, service.NativeCodexPluginKey).Scan(&generation, &state) - stale := err != nil || generation != installation.RuntimeGeneration || state != "disabled" - if !stale { - err = conn.QueryRow(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexRetirementSettingKey).Scan(&retired) - stale = err != nil || !nativeCodexRetired(retired) - } - if !stale { - err = conn.QueryRow(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexConfigSettingKey).Scan(&configRecord) - record, decodeErr := nativeCodexConfigRecord(configRecord) - stale = err != nil || decodeErr != nil || record.ConfigVersion != installation.ConfigVersion || record.ConfigSHA256 != installation.ConfigSHA256 - } - - if stale { - closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = conn.Close(closeCtx) - if err == nil { - err = service.ErrNativeCodexRuntimeChanged - } - return nil, err - } - return newNativeCodexRuntimeSessionLease(conn, nativeCodexRuntimeLeaseProbeInterval, nativeCodexRuntimeLeaseProbeTimeout), nil -} diff --git a/backend/internal/repository/codex_native_lease_monitor_test.go b/backend/internal/repository/codex_native_lease_monitor_test.go deleted file mode 100644 index 02b704fc60a9..000000000000 --- a/backend/internal/repository/codex_native_lease_monitor_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package repository - -import ( - "context" - "errors" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -type runtimeLeaseMonitorSession struct { - entered chan struct{} - enterOnce sync.Once - block bool - pingErr error - active atomic.Int32 - pings atomic.Int32 - closes atomic.Int32 - concurrent atomic.Bool -} - -func (s *runtimeLeaseMonitorSession) Ping(ctx context.Context) error { - s.pings.Add(1) - s.active.Add(1) - defer s.active.Add(-1) - s.enterOnce.Do(func() { close(s.entered) }) - if s.block { - <-ctx.Done() - return ctx.Err() - } - return s.pingErr -} - -func (s *runtimeLeaseMonitorSession) Close(context.Context) error { - s.closes.Add(1) - if s.active.Load() != 0 { - s.concurrent.Store(true) - } - return nil -} - -func TestNativeRuntimeLeaseNormalReleaseCancelsProbeWithoutConcurrentClose(t *testing.T) { - session := &runtimeLeaseMonitorSession{entered: make(chan struct{}), block: true} - lease := newNativeCodexRuntimeSessionLease(session, time.Millisecond, time.Second) - select { - case <-session.entered: - case <-time.After(time.Second): - t.Fatal("monitor did not start its probe") - } - var released sync.WaitGroup - for range 2 { - released.Add(1) - go func() { defer released.Done(); lease.Release() }() - } - finished := make(chan struct{}) - go func() { released.Wait(); close(finished) }() - select { - case <-finished: - case <-time.After(time.Second): - t.Fatal("Release did not cancel/join the in-flight probe") - } - require.NoError(t, lease.Err(), "deliberate cleanup is not session loss") - require.Equal(t, int32(1), session.pings.Load()) - require.Equal(t, int32(1), session.closes.Load()) - require.False(t, session.concurrent.Load(), "Ping and Close must have one connection owner") - select { - case <-lease.Done(): - default: - t.Fatal("normal release did not finish listeners") - } -} - -func TestNativeRuntimeLeaseLossListenerCanReleaseWithoutDeadlock(t *testing.T) { - session := &runtimeLeaseMonitorSession{entered: make(chan struct{}), pingErr: errors.New("synthetic lost session")} - lease := newNativeCodexRuntimeSessionLease(session, time.Millisecond, time.Second) - listenerDone := make(chan struct{}) - go func() { - <-lease.Done() - lease.Release() - close(listenerDone) - }() - select { - case <-listenerDone: - case <-time.After(time.Second): - t.Fatal("loss notification and caller Release formed a wait cycle") - } - require.ErrorIs(t, lease.Err(), service.ErrNativeCodexRuntimeUnavailable) - lease.Release() - require.Equal(t, int32(1), session.pings.Load(), "loss must not reconnect or re-acquire a lease") - require.Equal(t, int32(1), session.closes.Load()) - require.False(t, session.concurrent.Load()) -} - -func TestNativeRuntimeLeaseProbeTimeoutReportsLossWithoutRetry(t *testing.T) { - session := &runtimeLeaseMonitorSession{entered: make(chan struct{}), block: true} - lease := newNativeCodexRuntimeSessionLease(session, time.Millisecond, 5*time.Millisecond) - select { - case <-lease.Done(): - case <-time.After(time.Second): - t.Fatal("a stalled session probe did not reach its bounded timeout") - } - require.ErrorIs(t, lease.Err(), service.ErrNativeCodexRuntimeUnavailable) - lease.Release() - require.Equal(t, int32(1), session.pings.Load()) - require.Equal(t, int32(1), session.closes.Load()) - require.False(t, session.concurrent.Load()) -} diff --git a/backend/internal/repository/codex_native_runtime.go b/backend/internal/repository/codex_native_runtime.go deleted file mode 100644 index 7e5660e6ebb2..000000000000 --- a/backend/internal/repository/codex_native_runtime.go +++ /dev/null @@ -1,203 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - - "github.com/Wei-Shaw/sub2api/internal/service" -) - -type nativeCodexRepository struct { - db *sql.DB -} - -// The account repository only served the retired route-qualification -// projection writes; the parameter keeps the wire graph unchanged. -func NewNativeCodexRepository(db *sql.DB, _ service.AccountRepository) service.NativeCodexRepository { - return &nativeCodexRepository{db: db} -} - -func nativeCodexRuntimeLockName(id int64) string { - // Same lock as the retired plugin: old and native owners cannot overlap a cutover. - return fmt.Sprintf("sub2api-plugin-runtime:%d", id) -} - -func (r *nativeCodexRepository) ReadNativeCodexStoredConfig(ctx context.Context) (string, bool, error) { - var cipher string - err := r.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexSourceSettingKey).Scan(&cipher) - if err == nil { - return cipher, true, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return "", false, err - } - err = r.db.QueryRowContext(ctx, `SELECT config_encrypted FROM sub2api_plugin_installations WHERE plugin_key=$1`, service.NativeCodexPluginKey).Scan(&cipher) - if errors.Is(err, sql.ErrNoRows) { - return "", false, nil - } - return cipher, cipher != "", err -} - -type nativeCodexMetadataQuerier interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func nativeCodexRetired(raw string) bool { - var record struct { - Version int `json:"version"` - Completed bool `json:"completed"` - Plugins map[string]json.RawMessage `json:"plugins"` - } - if json.Unmarshal([]byte(raw), &record) != nil || record.Version != 1 || !record.Completed { - return false - } - plugin, ok := record.Plugins[service.NativeCodexPluginKey] - return ok && len(plugin) > 0 && string(plugin) != "null" -} - -func nativeCodexConfigRecord(raw string) (service.NativeCodexConfigRecord, error) { - var record service.NativeCodexConfigRecord - if json.Unmarshal([]byte(raw), &record) != nil || record.Version != 1 || record.ConfigVersion <= 0 || len(record.ConfigSHA256) != 64 { - return record, service.ErrNativeCodexRuntimeChanged - } - if _, err := hex.DecodeString(record.ConfigSHA256); err != nil { - return record, service.ErrNativeCodexRuntimeChanged - } - return record, nil -} - -func readNativeCodexMetadata(ctx context.Context, q nativeCodexMetadataQuerier, lock string) (*service.NativeCodexMetadata, error) { - metadata := &service.NativeCodexMetadata{} - var state, retired, config string - err := q.QueryRowContext(ctx, `SELECT id,runtime_generation,state FROM sub2api_plugin_installations WHERE plugin_key=$1`+lock, service.NativeCodexPluginKey).Scan(&metadata.ID, &metadata.RuntimeGeneration, &state) - if err != nil { - return nil, err - } - if state != "disabled" || metadata.ID <= 0 || metadata.RuntimeGeneration <= 0 { - return nil, service.ErrNativeCodexRuntimeChanged - } - if err = q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexRetirementSettingKey).Scan(&retired); err != nil { - return nil, err - } - if !nativeCodexRetired(retired) { - return nil, service.ErrNativeCodexRuntimeChanged - } - err = q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1`, service.NativeCodexConfigSettingKey).Scan(&config) - if errors.Is(err, sql.ErrNoRows) { - // A historical run can be read before the native config hash is first recorded. - return metadata, nil - } - if err != nil { - return nil, err - } - record, err := nativeCodexConfigRecord(config) - if err != nil { - return nil, err - } - metadata.ConfigVersion, metadata.ConfigSHA256 = record.ConfigVersion, record.ConfigSHA256 - return metadata, nil -} - -func (r *nativeCodexRepository) LoadNativeCodexMetadata(ctx context.Context) (*service.NativeCodexMetadata, error) { - return readNativeCodexMetadata(ctx, r.db, "") -} - -func (r *nativeCodexRepository) SyncNativeCodexConfig(ctx context.Context, hash string) (*service.NativeCodexMetadata, error) { - return r.syncNativeCodexConfig(ctx, hash, nil) -} -func (r *nativeCodexRepository) StoreNativeCodexConfig(ctx context.Context, cipher, hash string) (*service.NativeCodexMetadata, error) { - if cipher == "" { - return nil, service.ErrNativeCodexRuntimeChanged - } - return r.syncNativeCodexConfig(ctx, hash, &cipher) -} -func (r *nativeCodexRepository) syncNativeCodexConfig(ctx context.Context, hash string, cipher *string) (*service.NativeCodexMetadata, error) { - if len(hash) != 64 { - return nil, service.ErrNativeCodexRuntimeChanged - } - if _, err := hex.DecodeString(hash); err != nil { - return nil, service.ErrNativeCodexRuntimeChanged - } - var id int64 - if err := r.db.QueryRowContext(ctx, `SELECT id FROM sub2api_plugin_installations WHERE plugin_key=$1`, service.NativeCodexPluginKey).Scan(&id); err != nil { - return nil, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - var locked bool - if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock_shared(hashtextextended($1,0))`, nativeCodexRuntimeLockName(id)).Scan(&locked); err != nil { - return nil, err - } - if !locked { - return nil, service.ErrNativeCodexRuntimeChanged - } - metadata, err := readNativeCodexMetadata(ctx, tx, " FOR UPDATE") - if err != nil { - return nil, err - } - saveSource := func() error { - if cipher == nil { - return nil - } - _, saveErr := tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`, service.NativeCodexSourceSettingKey, *cipher) - return saveErr - } - if metadata.ConfigSHA256 == hash { - if err = saveSource(); err != nil { - return nil, err - } - return metadata, tx.Commit() - } - // Same-policy saves need not disturb shared business leases. A real policy - // change still requires the original exclusive cutover lock after drain. - if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`, nativeCodexRuntimeLockName(id)).Scan(&locked); err != nil { - return nil, err - } - if !locked { - return nil, service.ErrNativeCodexRuntimeChanged - } - if metadata.ConfigVersion > 0 { - if err = tx.QueryRowContext(ctx, `UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1,updated_at=NOW() WHERE id=$1 AND plugin_key=$2 AND state='disabled' AND runtime_generation=$3 RETURNING runtime_generation`, metadata.ID, service.NativeCodexPluginKey, metadata.RuntimeGeneration).Scan(&metadata.RuntimeGeneration); err != nil { - return nil, err - } - } - // The first hash records the generation already advanced by the retirement transaction. - metadata.ConfigVersion++ - metadata.ConfigSHA256 = hash - raw, err := json.Marshal(service.NativeCodexConfigRecord{Version: 1, ConfigVersion: metadata.ConfigVersion, ConfigSHA256: hash}) - if err != nil { - return nil, err - } - if _, err = tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`, service.NativeCodexConfigSettingKey, string(raw)); err != nil { - return nil, err - } - if err = saveSource(); err != nil { - return nil, err - } - return metadata, tx.Commit() -} - -func lockNativeCodexExecution(ctx context.Context, tx *sql.Tx, key string) error { - if key != service.NativeCodexPluginKey { - return service.ErrNativeCodexRuntimeChanged - } - wanted, ok := service.NativeCodexExecutionFromContext(ctx) - if !ok { - return service.ErrNativeCodexRuntimeChanged - } - current, err := readNativeCodexMetadata(ctx, tx, " FOR SHARE") - if err != nil { - return err - } - if *current != wanted { - return service.ErrNativeCodexRuntimeChanged - } - return nil -} diff --git a/backend/internal/repository/codex_native_runtime_test.go b/backend/internal/repository/codex_native_runtime_test.go deleted file mode 100644 index bebbc62dd7c2..000000000000 --- a/backend/internal/repository/codex_native_runtime_test.go +++ /dev/null @@ -1,87 +0,0 @@ -//go:build unit - -package repository - -import ( - "context" - "database/sql" - "errors" - "regexp" - "strings" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestNativeCodexConfigGenerationIsAtomicAndIdempotent(t *testing.T) { - for _, phase := range []string{"initial", "unchanged", "changed"} { - t.Run(phase, func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer func() { _ = db.Close() }() - r := &nativeCodexRepository{db: db} - hash := strings.Repeat("a", 64) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM sub2api_plugin_installations WHERE plugin_key=$1`)).WithArgs(service.NativeCodexPluginKey).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7)) - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT pg_try_advisory_xact_lock_shared(hashtextextended($1,0))`)).WithArgs("sub2api-plugin-runtime:7").WillReturnRows(sqlmock.NewRows([]string{"locked"}).AddRow(true)) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT id,runtime_generation,state FROM sub2api_plugin_installations WHERE plugin_key=$1 FOR UPDATE`)).WithArgs(service.NativeCodexPluginKey).WillReturnRows(sqlmock.NewRows([]string{"id", "generation", "state"}).AddRow(7, 12, "disabled")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT value FROM settings WHERE key=$1`)).WithArgs(service.NativeCodexRetirementSettingKey).WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"version":1,"completed":true,"plugins":{"codexrip.codex-runtime":{}}}`)) - read := mock.ExpectQuery(regexp.QuoteMeta(`SELECT value FROM settings WHERE key=$1`)).WithArgs(service.NativeCodexConfigSettingKey) - if phase == "initial" { - read.WillReturnError(sql.ErrNoRows) - } else { - oldHash := hash - if phase == "changed" { - oldHash = strings.Repeat("b", 64) - } - read.WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"version":1,"config_version":3,"config_sha256":"` + oldHash + `"}`)) - } - if phase != "unchanged" { - mock.ExpectQuery(regexp.QuoteMeta(`SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`)).WithArgs("sub2api-plugin-runtime:7").WillReturnRows(sqlmock.NewRows([]string{"locked"}).AddRow(true)) - } - if phase == "changed" { - mock.ExpectQuery(regexp.QuoteMeta(`UPDATE sub2api_plugin_installations SET runtime_generation=runtime_generation+1,updated_at=NOW() WHERE id=$1 AND plugin_key=$2 AND state='disabled' AND runtime_generation=$3 RETURNING runtime_generation`)).WithArgs(int64(7), service.NativeCodexPluginKey, int64(12)).WillReturnRows(sqlmock.NewRows([]string{"generation"}).AddRow(13)) - } - if phase != "unchanged" { - mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`)).WithArgs(service.NativeCodexConfigSettingKey, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) - } - mock.ExpectCommit() - metadata, err := r.SyncNativeCodexConfig(context.Background(), hash) - require.NoError(t, err) - expected := int64(12) - if phase == "changed" { - expected = 13 - } - require.Equal(t, expected, metadata.RuntimeGeneration) - require.Equal(t, hash, metadata.ConfigSHA256) - require.NoError(t, mock.ExpectationsWereMet()) - }) - } -} - -func TestNativeCodexStoredConfigDistinguishesMissingAndReadError(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer func() { _ = db.Close() }() - r := &nativeCodexRepository{db: db} - query := regexp.QuoteMeta(`SELECT config_encrypted FROM sub2api_plugin_installations WHERE plugin_key=$1`) - sourceQuery := regexp.QuoteMeta(`SELECT value FROM settings WHERE key=$1`) - mock.ExpectQuery(sourceQuery).WithArgs(service.NativeCodexSourceSettingKey).WillReturnError(sql.ErrNoRows) - mock.ExpectQuery(query).WithArgs(service.NativeCodexPluginKey).WillReturnError(sql.ErrNoRows) - _, present, err := r.ReadNativeCodexStoredConfig(context.Background()) - require.NoError(t, err) - require.False(t, present) - failure := errors.New("fixture storage unavailable") - mock.ExpectQuery(sourceQuery).WithArgs(service.NativeCodexSourceSettingKey).WillReturnError(failure) - _, _, err = r.ReadNativeCodexStoredConfig(context.Background()) - require.ErrorIs(t, err, failure, "a read failure cannot select the deployment defaults") - mock.ExpectQuery(sourceQuery).WithArgs(service.NativeCodexSourceSettingKey).WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("native-cipher-fixture")) - value, present, err := r.ReadNativeCodexStoredConfig(context.Background()) - require.NoError(t, err) - require.True(t, present) - require.Equal(t, "native-cipher-fixture", value) - - require.NoError(t, mock.ExpectationsWereMet()) -} diff --git a/backend/internal/repository/codex_native_state.go b/backend/internal/repository/codex_native_state.go deleted file mode 100644 index bf6e00c1ddbd..000000000000 --- a/backend/internal/repository/codex_native_state.go +++ /dev/null @@ -1,52 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func (r *nativeCodexRepository) ReadExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { - var out extensionv1.StateResult - err := r.db.QueryRowContext(ctx, `SELECT revision,value FROM sub2api_plugin_state WHERE plugin_key=$1 AND namespace=$2 AND state_key=$3`, plugin, req.Namespace, req.Key).Scan(&out.Revision, &out.Value) - if errors.Is(err, sql.ErrNoRows) { - return out, nil - } - out.Found = err == nil - return out, err -} - -func (r *nativeCodexRepository) CompareSwapExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { - var out extensionv1.StateResult - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return out, err - } - defer func() { _ = tx.Rollback() }() - if err := lockNativeCodexExecution(ctx, tx, plugin); err != nil { - return out, err - } - var row *sql.Row - if req.ExpectedRevision == 0 { - row = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_state(plugin_key,namespace,state_key,value) - VALUES($1,$2,$3,$4::jsonb) ON CONFLICT DO NOTHING RETURNING revision,value`, plugin, req.Namespace, req.Key, []byte(req.Value)) - } else { - row = tx.QueryRowContext(ctx, `UPDATE sub2api_plugin_state SET value=$4::jsonb,revision=revision+1,updated_at=NOW() - WHERE plugin_key=$1 AND namespace=$2 AND state_key=$3 AND revision=$5 RETURNING revision,value`, plugin, req.Namespace, req.Key, []byte(req.Value), req.ExpectedRevision) - } - err = row.Scan(&out.Revision, &out.Value) - if errors.Is(err, sql.ErrNoRows) { - _ = tx.Rollback() - return r.ReadExtensionState(ctx, plugin, req) - } - if err != nil { - return out, err - } - if err = tx.Commit(); err != nil { - return out, err - } - out.Found, out.Applied = err == nil, err == nil - return out, err -} diff --git a/backend/internal/repository/codex_quality_breaker.go b/backend/internal/repository/codex_quality_breaker.go deleted file mode 100644 index 545081652a1a..000000000000 --- a/backend/internal/repository/codex_quality_breaker.go +++ /dev/null @@ -1,18 +0,0 @@ -package repository - -import "context" - -// A retained marker/claim is an ordinary half-open breaker. Diagnostics wait -// for its ordinary owner to recover it, rather than claiming or deleting it. -func (c *gatewayCache) CodexQualityRuntimeBlocked(ctx context.Context, accountID int64, models []string) (bool, error) { - var keys []string - for _, model := range normalizeOpenAIRuntimeBreakerModels(models) { - base := openAIRuntimeBreakerBaseKey(accountID, model) - keys = append(keys, base+":block", base+":marker", base+":claim") - } - if len(keys) == 0 { - return false, nil - } - count, err := c.rdb.Exists(ctx, keys...).Result() - return count > 0, err -} diff --git a/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go b/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go new file mode 100644 index 000000000000..0579f15ef7ca --- /dev/null +++ b/backend/internal/repository/codex_runtime_plugin_purge_migration_integration_test.go @@ -0,0 +1,410 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const ( + codexRuntimePurgeMigration = "264_purge_codex_runtime_plugin_data.sql" + codexRuntimeZstdTrueHash = "7818a2a138b902a949b9fd77ba6bb62d8d77dcfbfa93ca1a16e778c609172304" + codexRuntimeZstdFalseHash = "1ee89db3b3c06e8da7f0524370502e503df48204fa16813ded97b7981b653aaa" +) + +// TestMain applied every migration, 264 included, to an empty database: the +// fresh-database run. Each case restores the tables 264 drops and the rows a +// database of the previous release can hold, applies 264 and checks what went +// and what stayed. +func TestMigration264PurgesCodexRuntimePluginData(t *testing.T) { + ctx := context.Background() + content, err := dbmigrations.FS.ReadFile(codexRuntimePurgeMigration) + require.NoError(t, err) + migration := string(content) + + type fixture struct { + tx *sql.Tx + t *testing.T + } + open := func(t *testing.T) fixture { return fixture{tx: testTx(t), t: t} } + exec := func(f fixture, query string, args ...any) { + f.t.Helper() + _, err := f.tx.ExecContext(ctx, query, args...) + require.NoError(f.t, err) + } + text := func(f fixture, query string, args ...any) string { + f.t.Helper() + var value string + require.NoError(f.t, f.tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + count := func(f fixture, query string, args ...any) int { + f.t.Helper() + var value int + require.NoError(f.t, f.tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + setting := func(f fixture, key string) (string, bool) { + f.t.Helper() + var value string + err := f.tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = $1`, key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", false + } + require.NoError(f.t, err) + return value, true + } + put := func(f fixture, key, value string) { + f.t.Helper() + exec(f, `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, value) + } + droppedTables := func(f fixture) int { + f.t.Helper() + return count(f, `SELECT count(*) FROM unnest(ARRAY['sub2api_plugin_state', 'sub2api_plugin_leases']) AS name WHERE to_regclass(name) IS NOT NULL`) + } + // The two tables of 246, which 264 drops. + restoreSchema := func(f fixture) { + f.t.Helper() + require.Zero(f.t, droppedTables(f)) + exec(f, `CREATE TABLE sub2api_plugin_state ( + plugin_key VARCHAR(160) NOT NULL, namespace VARCHAR(128) NOT NULL, state_key VARCHAR(256) NOT NULL, + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), value JSONB NOT NULL, next_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (plugin_key, namespace, state_key))`) + exec(f, `CREATE INDEX sub2api_plugin_state_due ON sub2api_plugin_state(plugin_key,namespace,next_at) WHERE next_at IS NOT NULL`) + exec(f, `CREATE TABLE sub2api_plugin_leases ( + plugin_key VARCHAR(160) NOT NULL, namespace VARCHAR(128) NOT NULL, lease_key VARCHAR(256) NOT NULL, + owner VARCHAR(256) NOT NULL, generation BIGINT NOT NULL DEFAULT 1 CHECK (generation > 0), + expires_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (plugin_key, namespace, lease_key))`) + require.Equal(f.t, 2, droppedTables(f)) + } + const codex = "codexrip.codex-runtime" + installation := func(f fixture, key, name, description, cipher string, artifact []byte) int64 { + f.t.Helper() + var id int64 + require.NoError(f.t, f.tx.QueryRowContext(ctx, ` +INSERT INTO sub2api_plugin_installations + (plugin_key, name, version, description, author, manifest, artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, config_encrypted, last_error, enabled_at, artifact_data, runtime_generation, package_sha256, update_policy) +VALUES ($1, $2, '0.2.13', $3, 'fixture author', $4::jsonb, $5, $6, $7, $8, $9, 'disabled', $10, $11, NOW(), $12, 9, $13, $14) +RETURNING id`, + key, name, description, `{"id":"`+key+`","capabilities":[{"id":"extensions.request.v1","platform":"openai","account_type":"oauth"}]}`, + "/data/plugins/"+key+".zip", "/data/plugins/"+key, "/data/plugins/"+key+"/plugin", strings.Repeat("b", 64), + "trusted", cipher, "fixture last error", artifact, strings.Repeat("c", 64), "bundled").Scan(&id)) + return id + } + installationRow := func(f fixture, id int64) string { + f.t.Helper() + return text(f, `SELECT row_to_json(row)::text FROM ( + SELECT plugin_key, name, version, description, author, manifest, artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, config_encrypted, last_error, enabled_at IS NOT NULL AS enabled, encode(artifact_data, 'hex') AS artifact, + runtime_generation, package_sha256, update_policy + FROM sub2api_plugin_installations WHERE id = $1) AS row`, id) + } + receipt := func(f fixture, plugins map[string]string) { + f.t.Helper() + entries := make([]string, 0, len(plugins)) + for key, entry := range plugins { + entries = append(entries, `"`+key+`":`+entry) + } + put(f, service.NativeFeatureRetirementSetting, `{"version":1,"completed":true,"retired_at":"2026-09-24T11:20:00.123456789Z","plugins":{`+strings.Join(entries, ",")+`}}`) + } + record := func(hash string) string { + return `{"version":1,"config_version":2,"config_sha256":"` + hash + `"}` + } + apply := func(f fixture) { f.t.Helper(); exec(f, migration) } + clean := func(f fixture) { + f.t.Helper() + require.Zero(f.t, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.codex-runtime', 'codexrip.image-tools', 'acme.other-plugin')`), "this fixture requires an isolated test database") + require.Zero(f.t, count(f, `SELECT count(*) FROM settings WHERE key IN ('deplugin_retired_plugins', 'codex_native_runtime_source', 'codex_native_runtime_config', 'codex_runtime_config')`)) + } + + t.Run("fresh database", func(t *testing.T) { + f := open(t) + clean(f) + require.Zero(t, droppedTables(f), "264 already dropped the state and lease tables") + apply(f) + clean(f) + require.Zero(t, droppedTables(f)) + }) + + t.Run("retired installation with a saved configuration", func(t *testing.T) { + f := open(t) + clean(f) + restoreSchema(f) + + codexID := installation(f, codex, "Codex 运行扩展", "fixture description", "fixture-codex-config-cipher", []byte{1, 2, 3}) + imageID := installation(f, "codexrip.image-tools", "图片工具", "sibling retired plugin", "fixture-image-config-cipher", []byte{4, 5, 6}) + otherID := installation(f, "acme.other-plugin", "Other", "third-party plugin", "fixture-other-config-cipher", []byte{7, 8, 9}) + for _, id := range []int64{codexID, imageID, otherID} { + exec(f, `INSERT INTO sub2api_plugin_bindings (plugin_id, capability, platform, account_type, enabled, rollout_percent) VALUES + ($1, 'extensions.request.v1', 'openai', 'oauth', false, 100), ($1, 'extensions.admin.v1', 'openai', 'oauth', false, 100)`, id) + exec(f, `INSERT INTO sub2api_plugin_updates (plugin_id, artifact_data, package_sha256, source_generation, update_policy) VALUES ($1, '\x0a0b'::bytea, $2, 9, 'bundled')`, id, strings.Repeat("d", 64)) + } + exec(f, `INSERT INTO sub2api_plugin_bootstrap (plugin_key, bundle_sha256, migration_profile, desired_enabled, completed, state_imported, user_removed) VALUES + ($1, 'fixture', 'codex-runtime-v1', true, true, true, true), ('codexrip.image-tools', 'fixture', 'image-tools-v1', true, true, true, true)`, codex) + + receipt(f, map[string]string{ + codex: `{"id":41,"key":"codexrip.codex-runtime","state":"enabled","runtime_generation":8,"bindings":[{"id":1,"capability":"extensions.request.v1","platform":"openai","account_type":"oauth","enabled":true,"rollout_percent":100}],"bootstrap":{"plugin_key":"codexrip.codex-runtime","bundle_sha256":"fixture"}}`, + "codexrip.image-tools": `{"id":42,"key":"codexrip.image-tools","state":"enabled","runtime_generation":3,"bindings":[]}`, + }) + put(f, "codex_native_runtime_source", "0W4ImxvW4Uv4FtA8OnXubVaSZLizP3M5zpFTKdIfruNAlfRUIUMxFF7hld+gJuPMHC4=") + put(f, "codex_native_runtime_config", record(codexRuntimeZstdFalseHash)) + put(f, "image_tools_config", `{"studio_enabled":false}`) + put(f, "openai_codex_client_version", "0.156.0") + + const private = "codex-routing-private" + for _, key := range []string{"quality-run.11111111-1111-4111-8111-111111111111", "validation.22222222-2222-4222-8222-222222222222", "spent.m264", "wire.41", "other.m264-unclassified"} { + exec(f, `INSERT INTO sub2api_plugin_state (plugin_key, namespace, state_key, value, next_at) VALUES ($1, $2, $3, '{"fixture":true}'::jsonb, NOW())`, codex, private, key) + } + exec(f, `INSERT INTO sub2api_plugin_state (plugin_key, namespace, state_key, value) VALUES ($1, 'another-namespace', 'm264', '{"fixture":true}'::jsonb)`, codex) + exec(f, `INSERT INTO sub2api_plugin_leases (plugin_key, namespace, lease_key, owner, expires_at) VALUES ($1, 'routing-account', 'm264', 'owner', NOW()), ('acme.other-plugin', 'routing-account', 'm264', 'owner', NOW())`, codex) + + account := func(name, platform, accountType, extra string, deleted bool) int64 { + t.Helper() + var id int64 + require.NoError(t, f.tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra, deleted_at) VALUES ($1, $2, $3, $4::jsonb, CASE WHEN $5 THEN NOW() END) RETURNING id`, + name, platform, accountType, extra, deleted).Scan(&id)) + return id + } + const selected = `{"v":2,"source":"reference_derived_windows_cli","originator":"codex_exec","client_version":"0.156.0","os_type":"Windows","os_version":"10.0.26220","arch":"x86_64","terminal":"dumb","sandbox":"windows_sandbox","generated_at":"2026-09-26T08:00:00Z"}` + const derived = `{"v":1,"os_type":"Mac OS","os_version":"15.6.1","arch":"arm64","terminal":"Apple_Terminal/455","sandbox":"seatbelt","generated_at":"2026-09-20T08:00:00Z"}` + selectedID := account("migration-264-selected-profile", "openai", "oauth", `{"openai_long_context_billing_enabled":true,"codex_fingerprint_mode":"device","codex_fingerprint_seed":"11111111-1111-4111-8111-111111111111","codex_client_identity":`+selected+`}`, false) + deletedID := account("migration-264-deleted-selected-profile", "openai", "oauth", `{"openai_long_context_billing_enabled":false,"codex_fingerprint_seed":"22222222-2222-4222-8222-222222222222","codex_client_identity":`+selected+`}`, true) + membersID := account("migration-264-extra-members", "openai", "oauth", `{"openai_long_context_billing_enabled":true,"codex_fingerprint_seed":"33333333-3333-4333-8333-333333333333","codex_client_identity":{"source":"","originator":"","client_version":"","v":1,"os_type":"Mac OS","os_version":"15.6.1","arch":"arm64","terminal":"Apple_Terminal/455","sandbox":"seatbelt","generated_at":"2026-09-20T08:00:00Z"}}`, false) + derivedID := account("migration-264-derived-explicit-off", "openai", "oauth", `{"openai_long_context_billing_enabled":false,"codex_fingerprint_mode":"off","codex_fingerprint_seed":"44444444-4444-4444-8444-444444444444","codex_client_identity":`+derived+`}`, false) + noModeID := account("migration-264-no-mode", "openai", "oauth", `{"openai_long_context_billing_enabled":true}`, false) + blankModeID := account("migration-264-setup-token-blank-mode", "openai", "setup-token", `{"openai_long_context_billing_enabled":false,"codex_fingerprint_mode":" "}`, false) + apiKeyID := account("migration-264-api-key", "openai", "apikey", `{"openai_long_context_billing_enabled":true}`, false) + claudeID := account("migration-264-other-platform", "anthropic", "oauth", `{}`, false) + arrayID := account("migration-264-array-extra", "anthropic", "oauth", `[1, 2, 3]`, false) + accountIDs := []int64{selectedID, deletedID, membersID, derivedID, noModeID, blankModeID, apiKeyID, claudeID, arrayID} + + ctid := func(table, column string, key any) string { + t.Helper() + return text(f, `SELECT ctid::text FROM `+table+` WHERE `+column+` = $1`, key) + } + untouchedAccounts := map[int64]string{} + for _, id := range []int64{derivedID, apiKeyID, claudeID, arrayID} { + untouchedAccounts[id] = ctid("accounts", "id", id) + } + untouchedSettings := map[string]string{} + for _, key := range []string{service.NativeFeatureRetirementSetting, "image_tools_config", "openai_codex_client_version"} { + untouchedSettings[key] = ctid("settings", "key", key) + } + imageBefore, otherBefore := installationRow(f, imageID), installationRow(f, otherID) + imageCTID, otherCTID := ctid("sub2api_plugin_installations", "id", imageID), ctid("sub2api_plugin_installations", "id", otherID) + outboxBefore := count(f, `SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs) + + apply(f) + + value, found := setting(f, "codex_runtime_config") + require.True(t, found) + require.Equal(t, `{"request_zstd":false}`, value) + var config struct { + RequestZstd bool `json:"request_zstd"` + } + require.NoError(t, service.DecodeSwitchSettings([]byte(value), &config, "request_zstd"), "the strict reader accepts the migrated row") + require.Zero(t, count(f, `SELECT count(*) FROM settings WHERE key IN ('codex_native_runtime_source', 'codex_native_runtime_config')`)) + for key, before := range untouchedSettings { + require.Equal(t, before, ctid("settings", "key", key), key) + } + + require.Zero(t, droppedTables(f)) + + require.JSONEq(t, `{"plugin_key":"codexrip.codex-runtime","name":"Codex 运行扩展","version":"0.2.13","description":"","author":"fixture author", + "manifest":{},"artifact_path":"","install_path":"","binary_path":"","binary_sha256":"","signature_status":"unsigned","state":"disabled", + "config_encrypted":"","last_error":"","enabled":true,"artifact":null,"runtime_generation":9,"package_sha256":"","update_policy":"pinned"}`, + installationRow(f, codexID)) + require.Equal(t, imageBefore, installationRow(f, imageID)) + require.Equal(t, otherBefore, installationRow(f, otherID)) + require.Equal(t, imageCTID, ctid("sub2api_plugin_installations", "id", imageID)) + require.Equal(t, otherCTID, ctid("sub2api_plugin_installations", "id", otherID)) + for table, column := range map[string]string{"sub2api_plugin_bindings": "plugin_id", "sub2api_plugin_updates": "plugin_id"} { + require.Zero(t, count(f, `SELECT count(*) FROM `+table+` WHERE `+column+` = $1`, codexID), table) + require.NotZero(t, count(f, `SELECT count(*) FROM `+table+` WHERE `+column+` = $1`, imageID), table) + require.NotZero(t, count(f, `SELECT count(*) FROM `+table+` WHERE `+column+` = $1`, otherID), table) + } + require.Zero(t, count(f, `SELECT count(*) FROM sub2api_plugin_bootstrap WHERE plugin_key = $1`, codex)) + require.Equal(t, 1, count(f, `SELECT count(*) FROM sub2api_plugin_bootstrap WHERE plugin_key = 'codexrip.image-tools'`)) + + require.JSONEq(t, `{"openai_long_context_billing_enabled":true,"codex_fingerprint_mode":"device","codex_fingerprint_seed":"11111111-1111-4111-8111-111111111111"}`, text(f, `SELECT extra::text FROM accounts WHERE id = $1`, selectedID)) + require.JSONEq(t, `{"openai_long_context_billing_enabled":false,"codex_fingerprint_seed":"22222222-2222-4222-8222-222222222222"}`, text(f, `SELECT extra::text FROM accounts WHERE id = $1`, deletedID)) + require.JSONEq(t, `{"openai_long_context_billing_enabled":true,"codex_fingerprint_mode":"device","codex_fingerprint_seed":"33333333-3333-4333-8333-333333333333","codex_client_identity":`+derived+`}`, text(f, `SELECT extra::text FROM accounts WHERE id = $1`, membersID)) + require.JSONEq(t, `{"openai_long_context_billing_enabled":true,"codex_fingerprint_mode":"device"}`, text(f, `SELECT extra::text FROM accounts WHERE id = $1`, noModeID)) + require.JSONEq(t, `{"openai_long_context_billing_enabled":false,"codex_fingerprint_mode":"device"}`, text(f, `SELECT extra::text FROM accounts WHERE id = $1`, blankModeID)) + for id, before := range untouchedAccounts { + require.Equal(t, before, ctid("accounts", "id", id), "account %d has nothing to change and is not rewritten", id) + } + require.Equal(t, outboxBefore, count(f, `SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs)) + + // The retired-plugins view still loads the receipt and both retired rows. + snapshot := &service.NativeRetirementSnapshot{} + stored, _ := setting(f, service.NativeFeatureRetirementSetting) + require.NoError(t, json.Unmarshal([]byte(stored), snapshot)) + require.True(t, snapshot.Completed) + require.Len(t, snapshot.Plugins, 2) + require.Len(t, snapshot.Plugins[codex].Bindings, 1) + rows, err := f.tx.QueryContext(ctx, pluginSelectSQL+` WHERE plugin_key LIKE 'codexrip.%' ORDER BY id`) + require.NoError(t, err) + var retired migration264RetiredRows + for rows.Next() { + plugin, scanErr := scanPlugin(rows) + require.NoError(t, scanErr) + retired = append(retired, plugin) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + bootstrap := &service.NativeFeatureBootstrap{Snapshot: snapshot} + bootstrap.SetRetiredPluginSource(retired) + view, err := bootstrap.RetiredPlugins(ctx) + require.NoError(t, err) + require.Len(t, view.Installations, 2) + require.Equal(t, codex, view.Installations[0].PluginKey) + require.Equal(t, "Codex 运行扩展", view.Installations[0].Name) + require.Empty(t, view.Installations[0].ConfigError) + require.Nil(t, view.Installations[0].Config) + require.Equal(t, "configuration decryptor is unavailable", view.Installations[1].ConfigError, "the sibling still holds its saved configuration") + + // A second run changes nothing. + before := map[string]string{ + "installation": ctid("sub2api_plugin_installations", "id", codexID), + "selected": ctid("accounts", "id", selectedID), + "members": ctid("accounts", "id", membersID), + "no mode": ctid("accounts", "id", noModeID), + "config": ctid("settings", "key", "codex_runtime_config"), + "receipt": ctid("settings", "key", service.NativeFeatureRetirementSetting), + } + apply(f) + require.Equal(t, before, map[string]string{ + "installation": ctid("sub2api_plugin_installations", "id", codexID), + "selected": ctid("accounts", "id", selectedID), + "members": ctid("accounts", "id", membersID), + "no mode": ctid("accounts", "id", noModeID), + "config": ctid("settings", "key", "codex_runtime_config"), + "receipt": ctid("settings", "key", service.NativeFeatureRetirementSetting), + }) + require.Zero(t, droppedTables(f)) + }) + + // The value the previous release applied is kept in every stored shape. + for _, test := range []struct { + name string + source bool // settings.codex_native_runtime_source exists + installed string // config_encrypted of a retired installation; "-" for no installation + hash string // recorded hash; "" for no record + existing string // codex_runtime_config before the migration + want string // codex_runtime_config after it; "" for no row + wantErr string + nativeCreated bool + otherState bool // sub2api_plugin_state holds a row of another plugin key + }{ + {name: "saved through the settings page as true", source: true, installed: "", hash: codexRuntimeZstdTrueHash, want: `{"request_zstd":true}`}, + {name: "saved only in the plugin era", installed: "fixture-cipher", hash: codexRuntimeZstdFalseHash, want: `{"request_zstd":false}`}, + {name: "nothing saved keeps following the deployment value", installed: "", hash: codexRuntimeZstdFalseHash}, + {name: "nothing saved and never a plugin", installed: "-", hash: codexRuntimeZstdTrueHash}, + {name: "a plain row already exists", source: true, installed: "", hash: codexRuntimeZstdFalseHash, existing: `{"request_zstd":true}`, want: `{"request_zstd":true}`}, + {name: "runtime-created installation", source: true, installed: "", hash: codexRuntimeZstdFalseHash, want: `{"request_zstd":false}`, nativeCreated: true}, + {name: "saved value with an unknown hash", source: true, installed: "", hash: strings.Repeat("a", 64), wantErr: "is neither known value"}, + {name: "saved value without a recorded hash", installed: "fixture-cipher", wantErr: "is neither known value"}, + {name: "installation that was never retired", installed: "unretired", hash: codexRuntimeZstdTrueHash, wantErr: "was never retired"}, + {name: "state rows of another plugin", installed: "", hash: codexRuntimeZstdTrueHash, otherState: true, wantErr: "rows of other plugins (acme.other-plugin)"}, + } { + t.Run(test.name, func(t *testing.T) { + f := open(t) + clean(f) + restoreSchema(f) + imageID := installation(f, "codexrip.image-tools", "图片工具", "sibling retired plugin", "fixture-image-config-cipher", []byte{4, 5, 6}) + plugins := map[string]string{"codexrip.image-tools": `{"id":42,"key":"codexrip.image-tools","state":"enabled","runtime_generation":3,"bindings":[]}`} + var codexID int64 + switch { + case test.installed == "-": + case test.nativeCreated: + require.NoError(t, f.tx.QueryRowContext(ctx, ` +INSERT INTO sub2api_plugin_installations (plugin_key,name,version,description,manifest,artifact_path,install_path,binary_path,binary_sha256,state,runtime_generation) +VALUES ($1,'Native Codex state anchor','0.0.0','Persistent native runtime generation; no plugin executable','{}'::jsonb,'','','','','disabled',2) RETURNING id`, codex).Scan(&codexID)) + plugins[codex] = `{"id":1,"key":"codexrip.codex-runtime","state":"disabled","runtime_generation":1,"bindings":null,"native_created":true}` + case test.installed == "unretired": + codexID = installation(f, codex, "Codex 运行扩展", "fixture", "", nil) + default: + codexID = installation(f, codex, "Codex 运行扩展", "fixture", test.installed, []byte{1, 2, 3}) + plugins[codex] = `{"id":41,"key":"codexrip.codex-runtime","state":"enabled","runtime_generation":8,"bindings":[]}` + } + receipt(f, plugins) + if test.source { + put(f, "codex_native_runtime_source", "VQmyjPJPUS2RwKWoHHjlVWTboI1GXzaC1W5IryauikWXMaktEin/jnbFOMIX5RQucg==") + } + if test.hash != "" { + put(f, "codex_native_runtime_config", record(test.hash)) + } + if test.existing != "" { + put(f, "codex_runtime_config", test.existing) + } + if test.otherState { + exec(f, `INSERT INTO sub2api_plugin_state (plugin_key, namespace, state_key, value) VALUES ('acme.other-plugin', 'm264', 'kept', '{"fixture":true}'::jsonb), ($1, 'm264', 'purged', '{"fixture":true}'::jsonb)`, codex) + } + + if test.wantErr != "" { + exec(f, `SAVEPOINT before_264`) + _, err := f.tx.ExecContext(ctx, migration) + require.ErrorContains(t, err, test.wantErr) + exec(f, `ROLLBACK TO SAVEPOINT before_264`) + require.Equal(t, 2, droppedTables(f), "a refused migration changes nothing") + if test.otherState { + require.Equal(t, 2, count(f, `SELECT count(*) FROM sub2api_plugin_state`)) + } + require.Equal(t, 1, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE id = $1`, codexID)) + _, found := setting(f, "codex_runtime_config") + require.False(t, found) + return + } + apply(f) + + value, found := setting(f, "codex_runtime_config") + require.Equal(t, test.want != "", found) + require.Equal(t, test.want, value) + require.Zero(t, count(f, `SELECT count(*) FROM settings WHERE key IN ('codex_native_runtime_source', 'codex_native_runtime_config')`)) + require.Zero(t, droppedTables(f)) + require.Equal(t, 1, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE id = $1 AND config_encrypted = 'fixture-image-config-cipher' AND artifact_data IS NOT NULL`, imageID)) + + stored, _ := setting(f, service.NativeFeatureRetirementSetting) + snapshot := &service.NativeRetirementSnapshot{} + require.NoError(t, json.Unmarshal([]byte(stored), snapshot)) + require.True(t, snapshot.Completed) + require.Equal(t, 1, snapshot.Version) + require.False(t, snapshot.RetiredAt.IsZero()) + require.Contains(t, snapshot.Plugins, "codexrip.image-tools") + switch { + case test.installed == "-": + require.NotContains(t, snapshot.Plugins, codex) + case test.nativeCreated: + require.NotContains(t, snapshot.Plugins, codex, "the runtime's own row leaves the receipt with it") + require.NotContains(t, stored, "native_created") + require.Zero(t, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key = $1`, codex)) + default: + require.Contains(t, snapshot.Plugins, codex) + require.Equal(t, 1, count(f, `SELECT count(*) FROM sub2api_plugin_installations WHERE id = $1 AND config_encrypted = '' AND artifact_data IS NULL AND manifest = '{}'::jsonb`, codexID)) + } + + apply(f) + again, found := setting(f, "codex_runtime_config") + require.Equal(t, test.want != "", found) + require.Equal(t, test.want, again) + }) + } +} + +type migration264RetiredRows []*service.PluginInstallation + +func (rows migration264RetiredRows) RetiredPluginInstallations(context.Context) ([]*service.PluginInstallation, error) { + return rows, nil +} diff --git a/backend/internal/repository/codex_ticket_purge_migration_integration_test.go b/backend/internal/repository/codex_ticket_purge_migration_integration_test.go deleted file mode 100644 index 5add297bd3e2..000000000000 --- a/backend/internal/repository/codex_ticket_purge_migration_integration_test.go +++ /dev/null @@ -1,246 +0,0 @@ -//go:build integration - -package repository - -import ( - "context" - "database/sql" - "strings" - "testing" - - dbmigrations "github.com/Wei-Shaw/sub2api/migrations" - "github.com/stretchr/testify/require" -) - -const codexTicketPurgeMigration = "263_purge_retired_codex_ticket_data.sql" - -// TestMain applied every migration, 263 included, to an empty database. The -// test restores the state the previous release left behind (the objects of -// 244/245 and representative dormant rows next to rows that must survive), -// applies 263, checks what was purged and what stayed, and applies it again. -func TestMigration263PurgesRetiredCodexTicketData(t *testing.T) { - tx := testTx(t) - ctx := context.Background() - exec := func(query string, args ...any) { - t.Helper() - _, err := tx.ExecContext(ctx, query, args...) - require.NoError(t, err) - } - text := func(query string, args ...any) string { - t.Helper() - var value string - require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) - return value - } - count := func(query string, args ...any) int { - t.Helper() - var value int - require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) - return value - } - migration := func(name string) string { - t.Helper() - content, err := dbmigrations.FS.ReadFile(name) - require.NoError(t, err) - return string(content) - } - - requireCodexTicketObjects(t, ctx, tx, false) - exec(migration("244_codex_ticket_lifecycle.sql")) - exec(migration("245_codex_ticket_account_status_independent.sql")) - requireCodexTicketObjects(t, ctx, tx, true) - - var userID int64 - require.NoError(t, tx.QueryRowContext(ctx, ` -INSERT INTO users (email, password_hash, role, status, balance, concurrency) -VALUES ('migration-263@example.com', 'hash', 'admin', 'active', 0, 1) -RETURNING id`).Scan(&userID)) - insertAccount := func(name, platform, extra string) int64 { - t.Helper() - var id int64 - require.NoError(t, tx.QueryRowContext(ctx, ` -INSERT INTO accounts (name, platform, type, extra) VALUES ($1, $2, 'oauth', $3::jsonb) RETURNING id`, - name, platform, extra).Scan(&id)) - return id - } - ticketID := insertAccount("migration-263-ticket", "openai", `{ - "openai_long_context_billing_enabled": true, - "codex_fingerprint_seed": "11111111-1111-4111-8111-111111111111", - "codex_turn_ticket:gpt-6-astra": {"state": "gAAAAA-dormant-ticket", "length": 292}, - "codex_ticket_runtime:gpt-6-astra": {"phase": "ready"}, - "codex_harvest_proxy_url": "http://user:fixture-proxy-secret@proxy.example.test:8080", - "codex_turn_ticket_summary": "kept: not the retired prefix", - "plugin_account_projections": {"codexrip.codex-runtime": {"identity": "owner", "scheduling": {"gpt-6-astra": {"effect": "deny"}}}} - }`) - sharedID := insertAccount("migration-263-shared-projection", "openai", `{ - "openai_long_context_billing_enabled": false, - "plugin_account_projections": {"codexrip.codex-runtime": {"identity": "owner"}, "acme.other-plugin": {"identity": "kept"}} - }`) - cleanID := insertAccount("migration-263-clean", "openai", `{ - "openai_long_context_billing_enabled": true, - "codex_fingerprint_seed": "22222222-2222-4222-8222-222222222222", - "plugin_account_projections": {"acme.other-plugin": {"identity": "kept"}} - }`) - arrayID := insertAccount("migration-263-array-extra", "anthropic", `[1, 2, 3]`) - accountIDs := []int64{ticketID, sharedID, cleanID, arrayID} - - exec(`INSERT INTO settings (key, value) VALUES - ('openai_codex_ticket_enabled', 'true'), - ('openai_codex_ticket_harvest_proxy_url', 'http://user:fixture-proxy-secret@proxy.example.test:8080'), - ('codex_native_runtime_source', 'encrypted-source-kept'), - ('codex_native_runtime_config', '{"version":1,"config_version":3,"config_sha256":"kept"}'), - ('openai_codex_client_version', '0.156.0') - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`) - exec(`INSERT INTO openai_codex_ticket_runtime (account_id, model, identity, phase) VALUES ($1, 'gpt-6-astra', 'owner', 'ready')`, ticketID) - exec(`INSERT INTO openai_codex_ticket_proxy_trust (proxy_key, certificates) VALUES ('migration-263', '["fixture-public-certificate"]')`) - - const codex, private = "codexrip.codex-runtime", "codex-routing-private" - insertState := func(plugin, namespace, key, value string) { - t.Helper() - exec(`INSERT INTO sub2api_plugin_state (plugin_key, namespace, state_key, value) VALUES ($1, $2, $3, $4::jsonb)`, - plugin, namespace, key, value) - } - purgedState := [][2]string{ - {"tickets", "m263.ticket"}, - {"routing-demand", "m263.ticket"}, - {"proxy-trust", "m263.proxy"}, - {private, "bundle.m263.live"}, - {private, "bundle.quality.m263.candidate"}, - {private, "clock.m263"}, - {private, "clock.quality.m263"}, - {private, "seen.m263"}, - {private, "validation-result.m263"}, - } - for _, row := range purgedState { - insertState(codex, row[0], row[1], `{"cookies":[{"name":"__cflb","value":"fixture-cookie-value"}]}`) - } - keptState := map[string]string{ - "quality-run.11111111-1111-4111-8111-111111111111": `{"run_id":"11111111-1111-4111-8111-111111111111","qualification":{"bundle":{"key":"bundle.quality.m263.live"}}}`, - "validation.22222222-2222-4222-8222-222222222222": `{"account_id":41,"used":2,"stages":{"gpt-6-astra:http:acquire":true}}`, - "spent.m263": `{"spent":true}`, - "wire.m263-observed": `{"user_agent":"codex_cli_rs/0.156.0","state_present":false}`, - "other.m263-unclassified": `{"kept":true}`, - } - for key, value := range keptState { - insertState(codex, private, key, value) - } - insertState(codex, private, "wire.m263-cookies", `{"user_agent":"codex_cli_rs/0.156.0","state":"opaque-state","cookie_versions":{"__cflb":"0123456789abcdef"},"cookies":[{"name":"__cflb","value":"fixture-cookie-value","routing":true}]}`) - insertState("acme.other-plugin", "tickets", "m263.ticket", `{"kept":true}`) - insertState("acme.other-plugin", private, "bundle.m263.live", `{"kept":true}`) - - insertLease := func(plugin, namespace, key string) { - t.Helper() - exec(`INSERT INTO sub2api_plugin_leases (plugin_key, namespace, lease_key, owner, expires_at) VALUES ($1, $2, $3, 'owner', NOW())`, - plugin, namespace, key) - } - insertLease(codex, "routing-account", "m263") - insertLease(codex, "tickets", "m263.ticket") - insertLease("acme.other-plugin", "routing-account", "m263") - - insertJob := func(kind string) int64 { - t.Helper() - var id int64 - require.NoError(t, tx.QueryRowContext(ctx, ` -INSERT INTO admin_account_jobs (created_by, kind, idempotency_key, request_hash, status, target_count, processed_count, failed_count) -VALUES ($1, $2, $3, $4, 'failed', 1, 1, 1) RETURNING id`, - userID, kind, "migration-263-"+kind, strings.Repeat("a", 64)).Scan(&id)) - exec(`INSERT INTO admin_account_job_items (job_id, ordinal, target_account_id, status, metadata) VALUES ($1, 1, $2, 'failed', '{"model_id":"gpt-6-astra"}')`, - id, ticketID) - return id - } - retiredJobs := []int64{insertJob("codex_ticket_harvest"), insertJob("codex_ticket_stop"), insertJob("extension_operation")} - keptJob := insertJob("account_bulk_update") - - ctid := func(table, column string, key any) string { - t.Helper() - return text(`SELECT ctid::text FROM `+table+` WHERE `+column+` = $1`, key) - } - stateCTID := func(key string) string { - t.Helper() - return text(`SELECT ctid::text || '/' || revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = $3`, codex, private, key) - } - untouchedAccounts := map[int64]string{cleanID: ctid("accounts", "id", cleanID), arrayID: ctid("accounts", "id", arrayID)} - untouchedState := map[string]string{} - for key := range keptState { - untouchedState[key] = stateCTID(key) - } - untouchedSettings := map[string]string{} - for _, key := range []string{"codex_native_runtime_source", "codex_native_runtime_config", "openai_codex_client_version"} { - untouchedSettings[key] = ctid("settings", "key", key) - } - outboxBefore := count(`SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs) - wireRevision := count(`SELECT revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private) - - exec(migration(codexTicketPurgeMigration)) - - requireCodexTicketObjects(t, ctx, tx, false) - require.Zero(t, count(`SELECT count(*) FROM settings WHERE key IN ('openai_codex_ticket_enabled', 'openai_codex_ticket_harvest_proxy_url')`)) - require.Equal(t, "encrypted-source-kept", text(`SELECT value FROM settings WHERE key = 'codex_native_runtime_source'`)) - for key, before := range untouchedSettings { - require.Equal(t, before, ctid("settings", "key", key), key) - } - - require.JSONEq(t, `{ - "openai_long_context_billing_enabled": true, - "codex_fingerprint_seed": "11111111-1111-4111-8111-111111111111", - "codex_turn_ticket_summary": "kept: not the retired prefix" - }`, text(`SELECT extra::text FROM accounts WHERE id = $1`, ticketID)) - require.JSONEq(t, `{ - "openai_long_context_billing_enabled": false, - "plugin_account_projections": {"acme.other-plugin": {"identity": "kept"}} - }`, text(`SELECT extra::text FROM accounts WHERE id = $1`, sharedID)) - for id, before := range untouchedAccounts { - require.Equal(t, before, ctid("accounts", "id", id), "account %d holds no retired key and is not rewritten", id) - } - require.Equal(t, outboxBefore, count(`SELECT count(*) FROM scheduler_outbox WHERE account_id = ANY($1)`, accountIDs)) - - for _, row := range purgedState { - require.Zero(t, count(`SELECT count(*) FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = $3`, codex, row[0], row[1]), row) - } - require.Zero(t, count(`SELECT count(*) FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace IN ('tickets', 'routing-demand', 'proxy-trust')`, codex)) - for key, before := range untouchedState { - require.Equal(t, before, stateCTID(key), key) - } - require.JSONEq(t, `{"user_agent":"codex_cli_rs/0.156.0","state":"opaque-state","cookie_versions":{"__cflb":"0123456789abcdef"}}`, - text(`SELECT value::text FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private)) - require.Equal(t, wireRevision+1, count(`SELECT revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private)) - require.Equal(t, 2, count(`SELECT count(*) FROM sub2api_plugin_state WHERE plugin_key = 'acme.other-plugin' AND state_key IN ('m263.ticket', 'bundle.m263.live')`)) - - require.Zero(t, count(`SELECT count(*) FROM sub2api_plugin_leases WHERE plugin_key = $1 AND namespace IN ('routing-account', 'tickets')`, codex)) - require.Equal(t, 1, count(`SELECT count(*) FROM sub2api_plugin_leases WHERE plugin_key = 'acme.other-plugin' AND lease_key = 'm263'`)) - - require.Zero(t, count(`SELECT count(*) FROM admin_account_jobs WHERE id = ANY($1)`, retiredJobs)) - require.Zero(t, count(`SELECT count(*) FROM admin_account_job_items WHERE job_id = ANY($1)`, retiredJobs)) - require.Equal(t, 1, count(`SELECT count(*) FROM admin_account_jobs WHERE id = $1`, keptJob)) - require.Equal(t, 1, count(`SELECT count(*) FROM admin_account_job_items WHERE job_id = $1`, keptJob)) - - // The triggers went before their tables, so account status/credential - // writes and setting writes no longer reach the dropped tables. - exec(`UPDATE accounts SET status = 'disabled', credentials = credentials || '{"chatgpt_account_id":"changed"}'::jsonb WHERE id = $1`, ticketID) - exec(`UPDATE settings SET value = '0.157.0' WHERE key = 'openai_codex_client_version'`) - - ticketCTID := ctid("accounts", "id", ticketID) - exec(migration(codexTicketPurgeMigration)) - requireCodexTicketObjects(t, ctx, tx, false) - require.Equal(t, ticketCTID, ctid("accounts", "id", ticketID), "a second run rewrites no account") - require.Equal(t, wireRevision+1, count(`SELECT revision FROM sub2api_plugin_state WHERE plugin_key = $1 AND namespace = $2 AND state_key = 'wire.m263-cookies'`, codex, private)) - require.Equal(t, 1, count(`SELECT count(*) FROM admin_account_jobs WHERE id = $1`, keptJob)) -} - -func requireCodexTicketObjects(t *testing.T, ctx context.Context, tx *sql.Tx, present bool) { - t.Helper() - var relations, triggers, functions int - require.NoError(t, tx.QueryRowContext(ctx, ` -SELECT - (SELECT count(*) FROM unnest(ARRAY['openai_codex_ticket_runtime', 'openai_codex_ticket_due_idx', - 'openai_codex_ticket_proxy_trust', 'openai_codex_ticket_proxy_generation']) AS name - WHERE to_regclass(name) IS NOT NULL), - (SELECT count(*) FROM pg_trigger WHERE tgname IN ('codex_ticket_account_claim_guard', 'codex_ticket_proxy_trust_guard')), - (SELECT count(*) FROM pg_proc WHERE proname IN ('invalidate_codex_ticket_account_claims', 'invalidate_codex_ticket_proxy_trust')) -`).Scan(&relations, &triggers, &functions)) - if present { - require.Equal(t, []int{4, 2, 2}, []int{relations, triggers, functions}) - return - } - require.Equal(t, []int{0, 0, 0}, []int{relations, triggers, functions}) -} diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 460f604f1a03..9cb458c9eb90 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -1540,8 +1540,8 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser { } // restoreCodexEventStreamContentType 为缺少 Content-Type 的 ChatGPT Codex /responses 流补回 -// text/event-stream。ChatGPT 边缘自 2026-09-23 起不再为这类 SSE 响应发送该头,而模型声明校验、 -// 工具名映射和图片流等消费方都按它识别流;请求声明接受 SSE 且上游 200 时响应体就是 SSE。 +// text/event-stream。ChatGPT 边缘自 2026-09-23 起不再为这类 SSE 响应发送该头,而工具名映射 +// 和图片流等消费方都按它识别流;请求声明接受 SSE 且上游 200 时响应体就是 SSE。 func restoreCodexEventStreamContentType(resp *http.Response) { if resp == nil || resp.StatusCode != http.StatusOK || resp.Header == nil || resp.Header.Get("Content-Type") != "" { return diff --git a/backend/internal/repository/native_feature_bootstrap.go b/backend/internal/repository/native_feature_bootstrap.go index 3fa361970ab6..28b57979b66b 100644 --- a/backend/internal/repository/native_feature_bootstrap.go +++ b/backend/internal/repository/native_feature_bootstrap.go @@ -21,7 +21,7 @@ func NewNativeFeatureBootstrapRepository(db *sql.DB) service.NativeFeatureBootst const nativeRetirementInstallationsSQL = `SELECT id,plugin_key,state,config_encrypted,runtime_generation,manifest FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.account-tools','codexrip.admin-observability','codexrip.cindy-provider', - 'codexrip.codex-runtime','codexrip.image-tools','codexrip.model-policy','codexrip.prompt-skills') + 'codexrip.image-tools','codexrip.model-policy','codexrip.prompt-skills') ORDER BY id FOR UPDATE` func (r *nativeFeatureBootstrapRepository) RetireNativeFeatures(ctx context.Context, convert func(service.NativeRetirementPlugin) (map[string]json.RawMessage, error)) (*service.NativeRetirementSnapshot, error) { @@ -72,8 +72,8 @@ func (r *nativeFeatureBootstrapRepository) RetireNativeFeatures(ctx context.Cont snapshot := &service.NativeRetirementSnapshot{Version: 1, Completed: true, RetiredAt: time.Now().UTC(), Plugins: make(map[string]service.NativeRetirementPlugin)} for _, plugin := range plugins { - // This is the same lock held by running legacy processes and native - // business IO. A live owner must finish before retirement can commit. + // This is the same lock held by running legacy processes. A live owner + // must finish before retirement can commit. var acquired bool if err = tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("sub2api-plugin-runtime:%d", plugin.ID)).Scan(&acquired); err != nil { return nil, err @@ -122,9 +122,7 @@ func (r *nativeFeatureBootstrapRepository) RetireNativeFeatures(ctx context.Cont if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bindings SET enabled=false,updated_at=NOW() WHERE plugin_id=$1 AND enabled`, plugin.ID); err != nil { return nil, err } - if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations - SET state='disabled',runtime_generation=runtime_generation+CASE WHEN plugin_key='codexrip.codex-runtime' THEN 1 ELSE 0 END,updated_at=NOW() - WHERE id=$1`, plugin.ID); err != nil { + if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET state='disabled',updated_at=NOW() WHERE id=$1`, plugin.ID); err != nil { return nil, err } if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET user_removed=true,updated_at=NOW() WHERE plugin_key=$1`, plugin.Key); err != nil { @@ -133,20 +131,6 @@ func (r *nativeFeatureBootstrapRepository) RetireNativeFeatures(ctx context.Cont plugin.ConfigEncrypted, plugin.Manifest = "", nil snapshot.Plugins[plugin.Key] = plugin } - if _, exists := snapshot.Plugins[service.NativeCodexPluginKey]; !exists { - // Fresh databases have no retired package. This row is only a disabled - // state anchor; it has no artifact, binary, signature, or active binding. - var id int64 - if err = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_installations - (plugin_key,name,version,description,manifest,artifact_path,install_path,binary_path,binary_sha256,state,runtime_generation) - VALUES($1,'Native Codex state anchor','0.0.0','Persistent native runtime generation; no plugin executable','{}'::jsonb,'','','','','disabled',1) - RETURNING id`, service.NativeCodexPluginKey).Scan(&id); err != nil { - return nil, err - } - snapshot.Plugins[service.NativeCodexPluginKey] = service.NativeRetirementPlugin{ - ID: id, Key: service.NativeCodexPluginKey, State: "disabled", RuntimeGeneration: 1, NativeCreated: true, - } - } encoded, err := json.Marshal(snapshot) if err != nil { return nil, err diff --git a/backend/internal/repository/native_feature_bootstrap_integration_test.go b/backend/internal/repository/native_feature_bootstrap_integration_test.go index f2bbc2215145..fa77d040c327 100644 --- a/backend/internal/repository/native_feature_bootstrap_integration_test.go +++ b/backend/internal/repository/native_feature_bootstrap_integration_test.go @@ -12,16 +12,15 @@ import ( "github.com/stretchr/testify/require" ) -func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(t *testing.T) { +func TestNativeFeatureRetirementIsAtomic(t *testing.T) { ctx := context.Background() var present int - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.codex-runtime','codexrip.image-tools')`).Scan(&present)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT count(*) FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.image-tools')`).Scan(&present)) require.Zero(t, present, "this fixture requires an isolated test database") t.Cleanup(func() { _, _ = integrationDB.ExecContext(ctx, `DELETE FROM settings WHERE key IN ('deplugin_retired_plugins','image_tools_config')`) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_state WHERE plugin_key='codexrip.codex-runtime' AND namespace='codex-routing-private' AND state_key='quality-run.native-retirement-fixture'`) _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.image-tools'`) - _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.codex-runtime','codexrip.image-tools')`) + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key IN ('codexrip.model-policy','codexrip.image-tools')`) }) insert := func(key string, generation int64) int64 { t.Helper() @@ -32,17 +31,13 @@ func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(t *testing.T) { return id } imageID := insert("codexrip.image-tools", 3) - codexID := insert(service.NativeCodexPluginKey, 9) + policyID := insert("codexrip.model-policy", 9) _, err := integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bindings(plugin_id,capability,platform,account_type,enabled,rollout_percent) VALUES($1,'extensions.request.v1','*','*',true,100)`, imageID) require.NoError(t, err) _, err = integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_bootstrap(plugin_key,bundle_sha256,migration_profile,desired_enabled,completed,state_imported) VALUES('codexrip.image-tools','fixture','image-tools-v1',true,true,true)`) require.NoError(t, err) _, err = integrationDB.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('image_tools_config','{"studio_enabled":false}',NOW())`) require.NoError(t, err) - _, err = integrationDB.ExecContext(ctx, `INSERT INTO sub2api_plugin_state(plugin_key,namespace,state_key,value) VALUES('codexrip.codex-runtime','codex-routing-private','quality-run.native-retirement-fixture','{"status":"closed","used_sends":40,"attempts":[{"id":"kept"}],"scope":{"account_id":7}}')`) - require.NoError(t, err) - var ledgerBefore string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value::text FROM sub2api_plugin_state WHERE plugin_key='codexrip.codex-runtime' AND namespace='codex-routing-private' AND state_key='quality-run.native-retirement-fixture'`).Scan(&ledgerBefore)) repo := NewNativeFeatureBootstrapRepository(integrationDB) convert := func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { if plugin.Key == "codexrip.image-tools" { @@ -51,7 +46,7 @@ func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(t *testing.T) { return nil, nil } _, err = repo.RetireNativeFeatures(ctx, func(plugin service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { - if plugin.Key == service.NativeCodexPluginKey { + if plugin.Key == "codexrip.model-policy" { return nil, errors.New("fixture conversion failure") } return convert(plugin) @@ -66,12 +61,12 @@ func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(t *testing.T) { first, err := repo.RetireNativeFeatures(ctx, convert) require.NoError(t, err) require.True(t, first.Completed) - require.Equal(t, "enabled", first.Plugins[service.NativeCodexPluginKey].State) + require.Equal(t, "enabled", first.Plugins["codexrip.model-policy"].State) var generation int64 var cipher, artifact string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT state,runtime_generation,config_encrypted,encode(artifact_data,'hex') FROM sub2api_plugin_installations WHERE id=$1`, codexID).Scan(&state, &generation, &cipher, &artifact)) + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT state,runtime_generation,config_encrypted,encode(artifact_data,'hex') FROM sub2api_plugin_installations WHERE id=$1`, policyID).Scan(&state, &generation, &cipher, &artifact)) require.Equal(t, "disabled", state) - require.EqualValues(t, 10, generation) + require.EqualValues(t, 9, generation) require.Equal(t, "original encrypted config", cipher) require.Equal(t, "010203", artifact) var setting string @@ -83,7 +78,7 @@ func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(t *testing.T) { require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key='codexrip.image-tools'`).Scan(&removed)) require.True(t, removed) second, err := repo.RetireNativeFeatures(ctx, func(service.NativeRetirementPlugin) (map[string]json.RawMessage, error) { - t.Fatal("a completed retirement must not repeat decryption, import, or generation changes") + t.Fatal("a completed retirement must not repeat decryption or import") return nil, nil }) require.NoError(t, err) @@ -92,9 +87,4 @@ func TestNativeFeatureRetirementIsAtomicAndPreservesLedger(t *testing.T) { secondJSON, err := json.Marshal(second.Plugins) require.NoError(t, err) require.JSONEq(t, string(firstJSON), string(secondJSON)) - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT runtime_generation FROM sub2api_plugin_installations WHERE id=$1`, codexID).Scan(&generation)) - require.EqualValues(t, 10, generation) - var ledgerAfter string - require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT value::text FROM sub2api_plugin_state WHERE plugin_key='codexrip.codex-runtime' AND namespace='codex-routing-private' AND state_key='quality-run.native-retirement-fixture'`).Scan(&ledgerAfter)) - require.Equal(t, ledgerBefore, ledgerAfter) } diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index bc81eb77e4b3..2455922ba286 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -913,11 +913,6 @@ func (c *schedulerCache) mgetChunked(ctx context.Context, keys []string) ([]any, return out, nil } -// buildSchedulerMetadataAccount builds the partial candidate projection used by -// the scheduler snapshot. It intentionally omits credential-bound material and -// the full credential identity. Gates that depend on those fields must run on -// the authoritative account returned by GetAccount/recheckSelectedOpenAIAccountFromDB, -// never on this projection. func buildSchedulerMetadataAccount(account service.Account) service.Account { return service.Account{ ID: account.ID, @@ -1010,9 +1005,8 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any { if len(credentials) == 0 { return nil } - // Candidate-list admission evaluates projection-safe routing and capability - // fields before hydrating the full account. Credential-bound identity fields - // are deliberately excluded; see buildSchedulerMetadataAccount. + // Candidate-list admission evaluates the account override before hydrating + // the full account. Preserve routing and capability fields used by provider gates. keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type", "account_scheduling_threshold", "base_url", "openai_capabilities"} filtered := make(map[string]any) for _, key := range keys { diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index b9f67444ca16..6d6512f346d1 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -89,7 +89,6 @@ var ProviderSet = wire.NewSet( NewDashboardAggregationRepository, NewSettingRepository, NewNativeFeatureBootstrapRepository, - NewNativeCodexRepository, NewOpsRepository, NewAuditLogRepository, NewPasskeyRepository, diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index eb91101f16ed..1b6cc0654d2b 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -165,10 +165,9 @@ var auditBodyOmittedRoutes = map[string]struct{}{ // auditVerbatimBodyRoutes store the request body exactly as received, up to // the capture limit, without key-level redaction: administrators review the -// full system prompt library and the Codex runtime settings they submitted. +// full system prompt library they submitted. var auditVerbatimBodyRoutes = map[string]struct{}{ - "PUT /api/v1/admin/system-prompts": {}, - "PUT /api/v1/admin/settings/codex-runtime": {}, + "PUT /api/v1/admin/system-prompts": {}, } // NewAuditLogMiddleware 创建审计中间件。 diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index e1134907c8f9..783e98fe0e4c 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -3,10 +3,8 @@ package middleware import ( "bytes" "context" - "io" "net/http" "net/http/httptest" - "strings" "sync" "testing" "time" @@ -208,41 +206,6 @@ func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *test require.Contains(t, auditBodyOmittedRoutes, route) } -// Codex runtime saves are audited as submitted, including retired settings a -// stale client may still send (the save itself drops them). -func TestCodexRuntimeSettingsRouteStoresAuditBodyVerbatim(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, route := range []struct{ method, path string }{ - {http.MethodPut, "/api/v1/admin/settings/codex-runtime"}, - } { - t.Run(route.method+" "+route.path, func(t *testing.T) { - repository := &auditCaptureRepository{} - auditService := service.NewAuditLogService(repository, nil) - auditService.Start() - router := gin.New() - router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService))) - body := `{"proxy_url":"proxy.test:8080:user:audit-canary-secret@other.test:9090","proxy_selection_id":"selected"}` - router.Handle(route.method, route.path, func(c *gin.Context) { - raw, err := io.ReadAll(c.Request.Body) - require.NoError(t, err) - require.Equal(t, body, string(raw)) - c.Status(http.StatusOK) - }) - request := httptest.NewRequest(route.method, route.path, strings.NewReader(body)) - request.Header.Set("Content-Type", "application/json") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, request) - auditService.Stop() - require.Equal(t, http.StatusOK, recorder.Code) - repository.mu.Lock() - logs := append([]*service.AuditLog(nil), repository.logs...) - repository.mu.Unlock() - require.Len(t, logs, 1) - require.Equal(t, body, logs[0].RequestBody) - }) - } -} - // Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键 // "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。 func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) { diff --git a/backend/internal/server/routes/account_job_routes_test.go b/backend/internal/server/routes/account_job_routes_test.go index e6d7bcddd370..07569b55ac42 100644 --- a/backend/internal/server/routes/account_job_routes_test.go +++ b/backend/internal/server/routes/account_job_routes_test.go @@ -2,8 +2,6 @@ package routes import ( "net/http" - "net/http/httptest" - "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/handler" @@ -42,33 +40,3 @@ func TestAccountJobRoutesExposeImportPreviewAndJobContract(t *testing.T) { require.True(t, ok, expected) } } - -func TestCodexQualityRoutesHaveNoRouteRenewal(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - handlers := &handler.Handlers{Admin: &handler.AdminHandlers{ - Account: adminhandler.NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), - AccountJob: adminhandler.NewAccountJobHandler(nil), - }} - adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) { c.Next() }) - auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }) - stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }) - RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil) - - registered := make(map[string]struct{}) - for _, route := range router.Routes() { - registered[route.Method+" "+route.Path] = struct{}{} - } - for _, expected := range []string{ - http.MethodPost + " /api/v1/admin/accounts/:id/codex-quality-runs", - http.MethodGet + " /api/v1/admin/accounts/:id/codex-quality-runs/:run_id", - http.MethodPost + " /api/v1/admin/accounts/:id/codex-quality-runs/:run_id/close", - } { - _, ok := registered[expected] - require.True(t, ok, expected) - } - require.NotContains(t, registered, http.MethodPost+" /api/v1/admin/accounts/:id/codex-quality-runs/:run_id/renew-route") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/7/codex-quality-runs/138bf94a-1bea-4aeb-9e7a-378e9c394bc9/renew-route", strings.NewReader(`{}`))) - require.Equal(t, http.StatusNotFound, recorder.Code) -} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c7adecf0f4f2..9c549806d077 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -433,13 +433,6 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/batch-test", h.Admin.Account.BatchTest) - accounts.GET("/:id/codex-fingerprint", h.Admin.Account.CodexFingerprint) - // Administrator-owned, account-pinned quality diagnostics. Each operation - // rechecks the run binding against the authoritative account row. - accounts.POST("/:id/codex-quality-runs", h.Admin.Account.CreateCodexQualityRun) - accounts.GET("/:id/codex-quality-runs/:run_id", h.Admin.Account.ReadCodexQualityRun) - accounts.POST("/:id/codex-quality-runs/:run_id/close", h.Admin.Account.CloseCodexQualityRun) - accounts.PUT("/:id/codex-fingerprint/profile", h.Admin.Account.SelectCodexProfile) accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) accounts.POST("/:id/refresh", h.Admin.Account.Refresh) accounts.POST("/:id/apply-oauth-credentials", h.Admin.Account.ApplyOAuthCredentials) @@ -627,8 +620,8 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpA adminSettings.PUT("/image-tools", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateImageToolsSettings) adminSettings.GET("/observability", h.Admin.Setting.GetObservabilitySettings) adminSettings.PUT("/observability", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateObservabilitySettings) - adminSettings.GET("/codex-runtime", h.Admin.Setting.GetNativeCodexConfiguration) - adminSettings.PUT("/codex-runtime", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateNativeCodexConfiguration) + adminSettings.GET("/codex-runtime", h.Admin.Setting.GetCodexRuntimeSettings) + adminSettings.PUT("/codex-runtime", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateCodexRuntimeSettings) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) adminSettings.GET("/email-templates", h.Admin.Setting.ListEmailTemplates) diff --git a/backend/internal/service/account_job.go b/backend/internal/service/account_job.go index 51e8f2c2b5b1..e309c05e28bb 100644 --- a/backend/internal/service/account_job.go +++ b/backend/internal/service/account_job.go @@ -192,13 +192,6 @@ func (s *AccountJobService) Submit(ctx context.Context, createdBy int64, kind, i if err := ValidateAccountJobMetadata(metadata); err != nil { return nil, false, err } - if _, err := AccountJobPluginExecution(metadata); err != nil { - return nil, false, err - } - if err := validateRecordedAccountJobPayload(metadata, payload); err != nil { - return nil, false, err - } - for index := range items { if items[index].Ordinal <= 0 { items[index].Ordinal = index + 1 @@ -210,14 +203,13 @@ func (s *AccountJobService) Submit(ctx context.Context, createdBy int64, kind, i } hash := sha256.Sum256(payload) requestHash := hex.EncodeToString(hash[:]) - existing, err := s.findMatchingSubmission(ctx, createdBy, kind, idempotencyKey, requestHash, metadata) + existing, err := s.findMatchingSubmission(ctx, createdBy, kind, idempotencyKey, requestHash) if err == nil { return existing, true, nil } if !errors.Is(err, ErrAccountJobNotFound) { return nil, false, err } - ciphertext, err := s.encryptor.Encrypt(string(payload)) if err != nil { return nil, false, err @@ -231,39 +223,30 @@ func (s *AccountJobService) Submit(ctx context.Context, createdBy int64, kind, i } // ReplaySubmission checks a previously submitted request before a caller resolves -// mutable targets. It never creates a job, touches its payload lifetime, or binds -// a plugin: a plugin owner must already be authorized in the caller's context. +// mutable targets. It never creates a job or touches its payload lifetime. func (s *AccountJobService) ReplaySubmission(ctx context.Context, createdBy int64, kind, idempotencyKey string, payload json.RawMessage) (*AccountJob, bool, error) { idempotencyKey = strings.TrimSpace(idempotencyKey) kind = strings.TrimSpace(kind) - if idempotencyKey == "" || len(idempotencyKey) > 255 { return nil, false, ErrAccountJobIdempotencyRequired } if createdBy <= 0 || !validAccountJobKind(kind) || !json.Valid(payload) { return nil, false, errors.New("invalid account job submission") } - var metadata json.RawMessage - if err := validateRecordedAccountJobPayload(metadata, payload); err != nil { - return nil, false, err - } - hash := sha256.Sum256(payload) - existing, err := s.findMatchingSubmission(ctx, createdBy, kind, idempotencyKey, hex.EncodeToString(hash[:]), metadata) + existing, err := s.findMatchingSubmission(ctx, createdBy, kind, idempotencyKey, hex.EncodeToString(hash[:])) if errors.Is(err, ErrAccountJobNotFound) { return nil, false, nil } return existing, err == nil, err } -func (s *AccountJobService) findMatchingSubmission(ctx context.Context, createdBy int64, kind, idempotencyKey, requestHash string, metadata json.RawMessage) (*AccountJob, error) { +func (s *AccountJobService) findMatchingSubmission(ctx context.Context, createdBy int64, kind, idempotencyKey, requestHash string) (*AccountJob, error) { existing, err := s.repo.FindIdempotent(ctx, createdBy, kind, idempotencyKey) if err != nil { return nil, err } - oldOwner, _ := AccountJobPluginExecution(existing.Metadata) - newOwner, _ := AccountJobPluginExecution(metadata) - if existing.RequestHash != requestHash || oldOwner.ID != newOwner.ID || !AccountJobViewIdentityEqual(existing.Metadata, metadata) { + if existing.RequestHash != requestHash { return nil, ErrAccountJobIdempotencyConflict } return existing, nil @@ -299,12 +282,6 @@ func (s *AccountJobService) decorateRetryEligibility(ctx context.Context, job *A if !AccountJobHasRetryableFailures(job) { return } - if !validAccountJobKind(job.Kind) { - // A kind this version has no executor for (a row written by another - // version) still lists, but it cannot be retried. - job.RetryUnavailableReason = "kind_unsupported" - return - } cipher, expires, err := s.repo.Payload(ctx, job.ID) if err != nil || cipher == "" || !time.Now().UTC().Before(expires) { job.RetryUnavailableReason = "payload_expired" @@ -363,51 +340,29 @@ func (s *AccountJobService) RetryFailed(ctx context.Context, jobID, createdBy in if err != nil { return nil, false, err } - if old == nil || len(seeds) == 0 || !validAccountJobKind(old.Kind) { + if old == nil || len(seeds) == 0 { return nil, false, ErrAccountJobNotRetryable } - if cipher == "" || time.Now().UTC().After(expires) { return nil, false, ErrAccountJobPayloadExpired } - plaintext, err := s.encryptor.Decrypt(cipher) - if err != nil { + if _, err = s.encryptor.Decrypt(cipher); err != nil { return nil, false, ErrAccountJobPayloadExpired } - ctx, releaseView, err := bindRecordedAccountJobView(ctx, old.Metadata, json.RawMessage(plaintext), true) - if err != nil { - return nil, false, err - } - defer releaseView() hashInput, _ := json.Marshal(struct { RetryOfJobID int64 `json:"retry_of_job_id"` }{RetryOfJobID: old.ID}) hash := sha256.Sum256(hashInput) requestHash := hex.EncodeToString(hash[:]) if existing, findErr := s.repo.FindIdempotent(ctx, createdBy, old.Kind, idempotencyKey); findErr == nil { - oldOwner, _ := AccountJobPluginExecution(old.Metadata) - retryOwner, _ := AccountJobPluginExecution(existing.Metadata) - if existing.RequestHash != requestHash || oldOwner.ID != retryOwner.ID || !AccountJobViewIdentityEqual(old.Metadata, existing.Metadata) { + if existing.RequestHash != requestHash { return nil, false, ErrAccountJobIdempotencyConflict } return existing, true, nil } else if !errors.Is(findErr, ErrAccountJobNotFound) { return nil, false, findErr } - fields := map[string]json.RawMessage{} - if len(old.Metadata) > 0 && json.Unmarshal(old.Metadata, &fields) != nil { - return nil, false, ErrAccountJobInvalidMetadata - } - if fields == nil { - fields = map[string]json.RawMessage{} - } - fields["retry_of_job_id"], _ = json.Marshal(old.ID) - fields["failed_item_count"], _ = json.Marshal(len(seeds)) - fields["target_count"], _ = json.Marshal(len(seeds)) - // The retired source identity remains historical evidence. Retry preserves - // the original encrypted request, expiry and failed target/action records. - metadata, _ := json.Marshal(fields) - + metadata, _ := json.Marshal(map[string]any{"retry_of_job_id": old.ID, "failed_item_count": len(seeds), "target_count": len(seeds)}) return s.repo.Create(ctx, CreateAccountJobParams{ CreatedBy: createdBy, Kind: old.Kind, IdempotencyKey: idempotencyKey, RequestHash: requestHash, PayloadCipher: cipher, PayloadExpires: expires, diff --git a/backend/internal/service/account_job_legacy.go b/backend/internal/service/account_job_legacy.go deleted file mode 100644 index b41ea140a42f..000000000000 --- a/backend/internal/service/account_job_legacy.go +++ /dev/null @@ -1,268 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "regexp" - "slices" - "strconv" - "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// These identities decode existing encrypted jobs. They never select or start -// an installation, stamp a new request, or authorize a different account set. -type AccountJobLegacyOwner struct { - ID int64 - Generation int64 -} - -var ( - ErrAccountJobPluginUnavailable = errors.New("stored account job operation is unavailable") - ErrAccountViewInvalid = ErrAccountJobInvalidMetadata - ErrAccountViewUnavailable = ErrAccountJobInvalidMetadata - ErrAccountViewScope = ErrAccountJobInvalidMetadata - ErrAccountViewUnsupportedOperation = ErrAccountJobInvalidMetadata - accountViewIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) - accountViewDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) - legacyPluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) -) - -func AccountJobPluginExecution(metadata json.RawMessage) (AccountJobLegacyOwner, error) { - var owner struct { - ID int64 `json:"plugin_id"` - Generation int64 `json:"plugin_generation"` - } - if len(metadata) > 0 && json.Unmarshal(metadata, &owner) != nil { - return AccountJobLegacyOwner{}, ErrAccountJobInvalidMetadata - } - if owner.ID < 0 || owner.Generation < 0 || (owner.ID == 0 && owner.Generation != 0) { - return AccountJobLegacyOwner{}, ErrAccountJobInvalidMetadata - } - return AccountJobLegacyOwner{ID: owner.ID, Generation: owner.Generation}, nil -} - -type AccountJobViewMetadata struct { - extensionv1.AccountViewIdentityV1 - RuntimeGeneration int64 `json:"runtime_generation"` - PolicyRevision int64 `json:"policy_revision"` - NormalizedQueryDigest string `json:"normalized_query_digest"` -} - -func ValidateAccountViewIdentity(identity extensionv1.AccountViewIdentityV1) error { - if identity.Version != 1 || identity.PluginID <= 0 || !legacyPluginIDPattern.MatchString(identity.PluginKey) || len(identity.PluginKey) > 160 || !accountViewIDPattern.MatchString(identity.ViewID) || !accountViewIDPattern.MatchString(identity.PresetID) || !accountViewDigestPattern.MatchString(identity.PackageSHA256) || !accountViewDigestPattern.MatchString(identity.ViewDefinitionDigest) { - return ErrAccountViewInvalid - } - return nil -} - -func boundedAccountViewStrings(values []string, limit int) error { - if len(values) > limit { - return ErrAccountViewInvalid - } - seen := make(map[string]bool, len(values)) - for _, value := range values { - if value == "" || len(value) > 64 || strings.TrimSpace(value) != value || seen[value] || strings.ContainsAny(value, "\x00\r\n") { - return ErrAccountViewInvalid - } - seen[value] = true - } - return nil -} - -func NormalizeAccountViewQuery(query extensionv1.AccountViewQueryV1) (extensionv1.AccountViewQueryV1, error) { - for _, values := range [][]string{query.Platforms, query.Types, query.Statuses, query.Plans} { - if err := boundedAccountViewStrings(values, 32); err != nil { - return query, err - } - } - if len(query.Search) > 100 || len(query.PrivacyMode) > 64 || query.GroupID < AccountListGroupUngrouped { - return query, ErrAccountViewInvalid - } - query.Search = strings.TrimSpace(query.Search) - if query.SortBy != "" && !slices.Contains([]string{"id", "name", "platform", "type", "status", "schedulable", "priority", "concurrency", "rate_multiplier", "upstream_billing_rate", "last_used_at", "created_at", "updated_at", "expires_at"}, query.SortBy) { - return query, ErrAccountViewInvalid - } - if query.SortOrder != "" && query.SortOrder != "asc" && query.SortOrder != "desc" { - return query, ErrAccountViewInvalid - } - for _, values := range [][]int64{query.Tags, query.AccountIDs} { - if len(values) > 3200 { - return query, ErrAccountViewInvalid - } - for _, id := range values { - if id <= 0 { - return query, ErrAccountViewInvalid - } - } - } - for sentinel, values := range map[string][]string{"direct": query.Proxies, "uncategorized": query.Folders} { - if len(values) > 3200 { - return query, ErrAccountViewInvalid - } - for _, value := range values { - if value == sentinel { - continue - } - id, err := strconv.ParseInt(value, 10, 64) - if err != nil || id <= 0 || strconv.FormatInt(id, 10) != value { - return query, ErrAccountViewInvalid - } - } - } - normalizeStrings := func(values []string) []string { - out := slices.Clone(values) - slices.Sort(out) - return slices.Compact(out) - } - normalizeIDs := func(values []int64) []int64 { - out := slices.Clone(values) - slices.Sort(out) - return slices.Compact(out) - } - query.Platforms, query.Types, query.Statuses, query.Plans = normalizeStrings(query.Platforms), normalizeStrings(query.Types), normalizeStrings(query.Statuses), normalizeStrings(query.Plans) - query.Proxies, query.Folders = normalizeStrings(query.Proxies), normalizeStrings(query.Folders) - query.Tags, query.AccountIDs = normalizeIDs(query.Tags), normalizeIDs(query.AccountIDs) - return query, nil -} - -func AccountJobViewExecution(metadata json.RawMessage) (*AccountJobViewMetadata, error) { - var envelope struct { - View json.RawMessage `json:"account_view"` - } - if len(metadata) > 0 && json.Unmarshal(metadata, &envelope) != nil { - return nil, ErrAccountJobInvalidMetadata - } - if len(envelope.View) == 0 || string(envelope.View) == "null" { - return nil, nil - } - var view AccountJobViewMetadata - decoder := json.NewDecoder(bytes.NewReader(envelope.View)) - decoder.DisallowUnknownFields() - if decoder.Decode(&view) != nil || ValidateAccountViewIdentity(view.AccountViewIdentityV1) != nil || view.RuntimeGeneration <= 0 || view.PolicyRevision < 0 || !accountViewDigestPattern.MatchString(view.NormalizedQueryDigest) { - return nil, ErrAccountJobInvalidMetadata - } - return &view, nil -} - -func accountViewQueryDigest(query extensionv1.AccountViewQueryV1) string { - raw, _ := json.Marshal(query) - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -func AccountJobViewIdentityEqual(left, right json.RawMessage) bool { - a, aErr := AccountJobViewExecution(left) - b, bErr := AccountJobViewExecution(right) - if aErr != nil || bErr != nil { - return false - } - if a == nil || b == nil { - return a == nil && b == nil - } - return a.AccountViewIdentityV1 == b.AccountViewIdentityV1 && a.NormalizedQueryDigest == b.NormalizedQueryDigest -} - -func accountJobPayloadView(payload json.RawMessage) (*extensionv1.AccountViewContextV1, error) { - var envelope struct { - View json.RawMessage `json:"view_context"` - } - if json.Unmarshal(payload, &envelope) != nil { - return nil, ErrAccountJobInvalidMetadata - } - if len(envelope.View) == 0 || string(envelope.View) == "null" { - return nil, nil - } - var view extensionv1.AccountViewContextV1 - decoder := json.NewDecoder(bytes.NewReader(envelope.View)) - decoder.DisallowUnknownFields() - if decoder.Decode(&view) != nil { - return nil, ErrAccountJobInvalidMetadata - } - query, err := NormalizeAccountViewQuery(view.Query) - if err != nil { - return nil, err - } - view.Query = query - return &view, nil -} - -func accountViewJobTargets(kind string, payload json.RawMessage, targets []*int64) ([]int64, error) { - var ids []int64 - for _, target := range targets { - if target == nil { - return nil, ErrAccountViewScope - } - ids = append(ids, *target) - } - for _, id := range ids { - if id <= 0 { - return nil, ErrAccountViewScope - } - } - if len(ids) == 0 { - return nil, ErrAccountViewScope - } - return ids, nil -} - -// ValidateRecordedAccountJob verifies original view metadata against its saved -// encrypted query. The query is compared only; execution uses persisted IDs. -func ValidateRecordedAccountJob(job *AccountJob, payload json.RawMessage) error { - if job == nil || !json.Valid(payload) { - return ErrAccountJobInvalidMetadata - } - if _, err := AccountJobPluginExecution(job.Metadata); err != nil { - return err - } - return validateRecordedAccountJobPayload(job.Metadata, payload) -} - -func validateRecordedAccountJobPayload(metadata, payload json.RawMessage) error { - stored, err := AccountJobViewExecution(metadata) - if err != nil { - return err - } - if stored == nil { - var envelope struct { - View json.RawMessage `json:"view_context"` - } - if json.Unmarshal(payload, &envelope) == nil && len(envelope.View) > 0 && string(envelope.View) != "null" { - return ErrAccountJobInvalidMetadata - } - return nil - } - request, err := accountJobPayloadView(payload) - if err != nil || request == nil || stored.AccountViewIdentityV1 != request.AccountViewIdentityV1 || stored.NormalizedQueryDigest != accountViewQueryDigest(request.Query) { - return ErrAccountJobInvalidMetadata - } - return nil -} - -func bindRecordedAccountJobView(ctx context.Context, metadata, payload json.RawMessage, _ bool) (context.Context, func(), error) { - if err := validateRecordedAccountJobPayload(metadata, payload); err != nil { - return nil, nil, err - } - return ctx, func() {}, nil -} - -func ValidateRecordedAccountJobTargets(job *AccountJob, payload json.RawMessage, items []AccountJobItem) error { - if err := ValidateRecordedAccountJob(job, payload); err != nil { - return err - } - stored, _ := AccountJobViewExecution(job.Metadata) - if stored == nil { - return nil - } - targets := make([]*int64, 0, len(items)) - for _, item := range items { - targets = append(targets, item.TargetAccountID) - } - _, err := accountViewJobTargets(job.Kind, payload, targets) - return err -} diff --git a/backend/internal/service/account_job_legacy_test.go b/backend/internal/service/account_job_legacy_test.go deleted file mode 100644 index 371f6e5374db..000000000000 --- a/backend/internal/service/account_job_legacy_test.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build unit - -package service - -import ( - "encoding/json" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/stretchr/testify/require" -) - -func TestAccountJobLegacyViewComparesSavedDigestWithoutQueryingTargets(t *testing.T) { - identity := extensionv1.AccountViewIdentityV1{Version: 1, PluginID: 7, PluginKey: "codexrip.account-tools", ViewID: "legacy-accounts", PresetID: "openai", PackageSHA256: strings.Repeat("a", 64), ViewDefinitionDigest: strings.Repeat("b", 64)} - query := extensionv1.AccountViewQueryV1{Search: "saved-private-query", AccountIDs: []int64{2, 7}} - record := AccountJobViewMetadata{AccountViewIdentityV1: identity, RuntimeGeneration: 3, PolicyRevision: 4, NormalizedQueryDigest: accountViewQueryDigest(query)} - metadata, err := json.Marshal(map[string]any{"plugin_id": 7, "plugin_generation": 3, "account_view": record}) - require.NoError(t, err) - payload, err := json.Marshal(map[string]any{"account_ids": []int64{2, 7}, "view_context": extensionv1.AccountViewContextV1{AccountViewIdentityV1: identity, Query: query}}) - require.NoError(t, err) - job := &AccountJob{ID: 1, Kind: AccountJobKindBulkUpdate, Metadata: metadata} - require.NoError(t, ValidateRecordedAccountJob(job, payload)) - require.NotContains(t, string(metadata), query.Search) - changed := json.RawMessage(strings.Replace(string(payload), query.Search, "different-query", 1)) - require.ErrorIs(t, ValidateRecordedAccountJob(job, changed), ErrAccountJobInvalidMetadata) - target := int64(2) - require.NoError(t, ValidateRecordedAccountJobTargets(job, payload, []AccountJobItem{{TargetAccountID: &target}})) - require.Error(t, ValidateRecordedAccountJobTargets(job, payload, []AccountJobItem{{}})) -} diff --git a/backend/internal/service/account_job_test.go b/backend/internal/service/account_job_test.go index 3de4bcde2c61..ab83e46f623a 100644 --- a/backend/internal/service/account_job_test.go +++ b/backend/internal/service/account_job_test.go @@ -262,34 +262,6 @@ type accountJobTestExecutor struct { cleanupCalls int } -// A row of a kind this version has no executor for (written by another -// version) stays readable, but nothing can submit or retry that kind. -func TestAccountJobRetiredKindsStayReadableButCannotRun(t *testing.T) { - for _, kind := range []string{"kind_from_another_version"} { - t.Run(kind, func(t *testing.T) { - repo := newAccountJobTestRepo() - jobs := NewAccountJobService(repo, accountJobTestCipher{}) - _, _, err := jobs.Submit(context.Background(), 9, kind, "new", json.RawMessage(`{}`), nil, []AccountJobItemSeed{{Ordinal: 1}}) - require.Error(t, err) - old, _, err := repo.Create(context.Background(), CreateAccountJobParams{CreatedBy: 9, Kind: kind, IdempotencyKey: "old", - PayloadCipher: "cipher:{}", PayloadExpires: time.Now().Add(time.Hour), Items: []AccountJobItemSeed{{Ordinal: 1}}, Attempt: 1}) - require.NoError(t, err) - repo.jobs[old.ID].Status = AccountJobStatusFailed - repo.jobs[old.ID].FailedCount = 1 - repo.items[old.ID][0].Status = AccountJobItemStatusFailed - - stored, err := jobs.Get(context.Background(), old.ID) - require.NoError(t, err) - require.Equal(t, kind, stored.Kind) - require.False(t, stored.RetryEligible) - require.Equal(t, "kind_unsupported", stored.RetryUnavailableReason) - _, _, err = jobs.RetryFailed(context.Background(), old.ID, 9, "retry") - require.ErrorIs(t, err, ErrAccountJobNotRetryable) - require.Len(t, repo.jobs, 1) - }) - } -} - func (e *accountJobTestExecutor) PrepareAccountJob(ctx context.Context, _ *AccountJob, _ json.RawMessage) (context.Context, func(), error) { e.prepareCalls++ return ctx, func() { e.cleanupCalls++ }, nil diff --git a/backend/internal/service/account_test_prompt_test.go b/backend/internal/service/account_test_prompt_test.go index 1905bb881c4d..8d7ce8aa5130 100644 --- a/backend/internal/service/account_test_prompt_test.go +++ b/backend/internal/service/account_test_prompt_test.go @@ -9,7 +9,6 @@ import ( "strings" "testing" - "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -99,35 +98,25 @@ func TestAccountTestPromptAdaptiveAndOpenCodeFinalRequests(t *testing.T) { } // An OAuth account test sends the mapped model over the ordinary transport -// and reports the final wire headers once, without Authorization. No Cookie -// or turn-state material reaches the wire, and dormant route-qualification -// extra keys change nothing. +// and reports the final wire headers once, without Authorization. func TestAccountTestPromptOAuthMappedModelReportsFinalWire(t *testing.T) { - cfg := &config.Config{Gateway: config.GatewayConfig{OpenAICodexRequestZstd: true}} - a := codexOAuthTestAccount(41) + a := &Account{ID: 41, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "tok", "chatgpt_account_id": "acc-1"}} a.Status = StatusActive a.Credentials["access_token"] = "account-test-access-token-canary" a.Credentials["model_mapping"] = map[string]any{"alias": "gpt-5.6-sol"} - a.Credentials["header_override_enabled"] = true - a.Credentials["header_overrides"] = map[string]any{openAICodexTurnStateHeader: "header-override-state"} - a.Extra = map[string]any{"codex_turn_ticket:gpt-5.6-sol": map[string]any{"state": "dormant-state"}, "plugin_account_projections": map[string]any{NativeCodexPluginKey: map[string]any{"scheduling": map[string]any{"gpt-5.6-sol": map[string]any{"effect": "deny"}}}}} - upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.6-sol\"}}\n\n")}} - gateway := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, accountRepo: &codexAccountRepositoryFixture{account: a}, nativeCodexRuntime: nativeCodexTestRuntime(t)} - svc := &AccountTestService{cfg: cfg, httpUpstream: upstream, openAIGatewayService: gateway} + upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n")}} + svc := &AccountTestService{httpUpstream: upstream} c, rec := newTestContext() prompt := "你的知识库库截止日期是什么时间,直接回复不要联网" - c.Request = c.Request.WithContext(withCodexTransportFixture(c.Request.Context(), true)) + enableCodexRequestZstd(t) require.NoError(t, svc.testOpenAIAccountConnection(c, a, "alias", prompt, "")) require.Len(t, upstream.requests, 1) req := upstream.requests[0] - require.Empty(t, req.Header.Get("Cookie")) - require.Empty(t, req.Header.Get(openAICodexTurnStateHeader), "turn-state material never reaches the wire") raw, err := io.ReadAll(req.Body) require.NoError(t, err) body := zstdDecodeForTest(t, raw) require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(body, "model").String()) require.Equal(t, prompt, gjson.GetBytes(body, "input.0.content.0.text").String()) - require.NotContains(t, rec.Body.String(), "dormant-state") // The reported wire headers leave out Authorization: the access token went // on the wire but never appears in the test output. require.Equal(t, "Bearer account-test-access-token-canary", req.Header.Get("Authorization")) @@ -145,5 +134,4 @@ func TestAccountTestPromptOAuthMappedModelReportsFinalWire(t *testing.T) { for name := range wireHeaders { require.False(t, strings.EqualFold(name, "Authorization"), "Authorization is never reported") } - require.NotContains(t, rec.Body.String(), "Codex ticket") } diff --git a/backend/internal/service/account_test_reasoning_wire_test.go b/backend/internal/service/account_test_reasoning_wire_test.go index 0d18ca35a910..57f83f394783 100644 --- a/backend/internal/service/account_test_reasoning_wire_test.go +++ b/backend/internal/service/account_test_reasoning_wire_test.go @@ -8,7 +8,6 @@ import ( "net/http" "testing" - "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -25,9 +24,9 @@ func TestAccountTestReasoningSurvivesMappingAndWireCompression(t *testing.T) { a := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"access_token": "test-access", "model_mapping": map[string]any{"friendly": "gpt-6-astra"}}} upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n")}} - svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: a}, httpUpstream: upstream, cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexRequestZstd: true}}} + svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: a}, httpUpstream: upstream} c, rec := newTestContext() - c.Request = c.Request.WithContext(withCodexTransportFixture(c.Request.Context(), true)) + enableCodexRequestZstd(t) require.NoError(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "ultra"})) require.Len(t, upstream.requests, 1) raw, err := io.ReadAll(upstream.requests[0].Body) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 18698c95c642..f5738eeb6884 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -970,37 +970,29 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 credentialAccount.ApplyHeaderOverrides(req.Header) - if isOAuth && !account.IsCredentialShadow() && s.openAIGatewayService != nil { - req = withCodexExpectedModel(req, upstreamTestModelID) - } // Get proxy URL proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - var codexWireObserved bool if isOAuth { - base := resolveCodexIdentitySnapshotContext(ctx, account, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) - req = req.WithContext(withCodexWireObserver(req.Context(), func(wire *http.Request) { - codexWireObserved = true - snapshot := base.withWire("http", wire.Header) - s.sendEvent(c, TestEvent{Type: "status", Text: snapshot.summary(), Data: snapshot}) - s.sendEvent(c, TestEvent{Type: "status", Text: "Codex wire headers", Data: accountTestWireHeaders(wire.Header)}) - })) - } - - resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) - if isOAuth && !codexWireObserved { + // Report the request this test puts on the wire before it is sent. A + // plugin round trip takes the plaintext request; every other send goes + // out as the transport copy prepared here, which the sender passes on. transport := "http" if s.pluginManager != nil && s.pluginManager.ShouldRouteOpenAIOAuth(account) { transport = "plugin" + } else if req, err = prepareOpenAICodexWireRequest(req, account); err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } base := resolveCodexIdentitySnapshotContext(ctx, account, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) snapshot := base.withWire(transport, req.Header) s.sendEvent(c, TestEvent{Type: "status", Text: snapshot.summary(), Data: snapshot}) s.sendEvent(c, TestEvent{Type: "status", Text: "Codex wire headers", Data: accountTestWireHeaders(req.Header)}) } + + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } @@ -3038,7 +3030,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co proxyURL = account.Proxy.URL() } // 复用压缩准备,但保留图像探针原来的直接 Do 路由;不顺带启用插件或 TLS 回退。 - wire, err := prepareCodexTransport(req, credentialAccount) + wire, err := prepareOpenAICodexWireRequest(req, credentialAccount) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error())) } diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index a034c6c701e4..a07967f923df 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -50,14 +50,13 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi svc := &AccountTestService{ accountRepo: repo, httpUpstream: upstream, - // 原生 v2 探测走普通 /responses 线,与真实转发同一 zstd 压缩边界。 - cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexRequestZstd: true}}, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", bytes.NewReader(nil)) - c.Request = c.Request.WithContext(withCodexTransportFixture(c.Request.Context(), true)) + // 原生 v2 探测走普通 /responses 线,与真实转发同一 zstd 压缩边界。 + enableCodexRequestZstd(t) err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) require.NoError(t, err) diff --git a/backend/internal/service/account_test_service_openai_image_test.go b/backend/internal/service/account_test_service_openai_image_test.go index 1ddde0811135..8ad5c20fb058 100644 --- a/backend/internal/service/account_test_service_openai_image_test.go +++ b/backend/internal/service/account_test_service_openai_image_test.go @@ -42,7 +42,6 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes svc := &AccountTestService{ httpUpstream: upstream, pluginManager: manager, - cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexRequestZstd: true}}, } account := &Account{ ID: 53, @@ -55,7 +54,8 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes }, } - err := svc.testOpenAIImageOAuth(c, withCodexTransportFixture(context.Background(), true), account, "gpt-image-1", "draw a cat") + enableCodexRequestZstd(t) + err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-1", "draw a cat") require.NoError(t, err) require.NotNil(t, upstream.lastReq) require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 9831b441d489..295416c22440 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -29,8 +29,6 @@ type queuedHTTPUpstream struct { tlsFlags []bool } -// OAuth Codex account tests share business traffic's entry point Do. API-key -// probes use DoWithTLS. func (u *queuedHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { return u.serve(req, nil) } @@ -199,7 +197,7 @@ func TestAccountTestService_EmptyModelKeepsOpenAIDefault(t *testing.T) { func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() - ctx.Request = ctx.Request.WithContext(withCodexTransportFixture(ctx.Request.Context(), true)) + enableCodexRequestZstd(t) resp := newJSONResponse(http.StatusOK, "") resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"OK"} @@ -216,11 +214,7 @@ data: {"type":"response.completed"} repo := &openAIAccountTestRepo{} upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} - svc := &AccountTestService{ - accountRepo: repo, - httpUpstream: upstream, - cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexRequestZstd: true}}, - } + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} account := &Account{ ID: 89, Platform: PlatformOpenAI, @@ -275,28 +269,6 @@ data: {"type":"response.completed"} require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(body, "model").String()) } -func TestAccountTestService_OpenAIOAuthGuardsDeclaredModel(t *testing.T) { - gin.SetMode(gin.TestMode) - for declared, matches := range map[string]bool{"gpt-5.4": true, "gpt-5.5": false} { - ctx, recorder := newTestContext() - resp := newJSONResponse(http.StatusOK, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\n"+ - "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\""+declared+"\"}}\n\n") - upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} - svc := &AccountTestService{httpUpstream: upstream, openAIGatewayService: &OpenAIGatewayService{cfg: &config.Config{}}} - account := &Account{ID: 91, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "test-token"}} - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") - require.Len(t, upstream.requests, 1) - if matches { - require.NoError(t, err) - require.Contains(t, recorder.Body.String(), "test_complete") - continue - } - // Like business traffic, the test fails when the reply declares another model. - require.ErrorContains(t, err, ErrCodexModelMismatch.Error()) - require.NotContains(t, recorder.Body.String(), `"success":true`) - } -} - func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 7f111e2e239c..25c397d9ff80 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -939,7 +939,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco } // 与推理面同一 zstd 压缩边界:探针同样是发往 /backend-api/codex/responses 的 OAuth POST; // 本服务拿不到网关配置,开关取进程级快照。 - wire, err := prepareCodexTransport(req, account) + wire, err := prepareOpenAICodexWireRequest(req, account) if err != nil { return nil, fmt.Errorf("openai codex probe request failed: %w", err) } diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index ef0347718f1f..fbaaa8ef7997 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -456,7 +456,6 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat // Grok media eligibility helpers live in account_grok_media_eligibility.go. func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) { - accountExtra = maps.Clone(accountExtra) // Probe/session state is system-managed. New accounts always start with automatic refresh disabled. delete(accountExtra, UpstreamBillingProbeEnabledExtraKey) delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey) @@ -778,7 +777,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U normalizedExtra[key] = v } } - normalizedExtra = maps.Clone(normalizedExtra) // System prompt bindings are written only by the binding endpoint. // Ordinary account edits preserve them, including their absence. delete(normalizedExtra, AccountExtraSystemPromptKey) @@ -1024,8 +1022,6 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd if err := ValidateOpenAIReasoningPolicyExtra(updates); err != nil { return err } - updates = maps.Clone(updates) - updates = sanitizedCodexFingerprintExtraUpdates(updates) updates = stripOpenAIAutoResetCreditManagedExtra(updates, true) delete(updates, UpstreamBillingProbeEnabledExtraKey) @@ -1063,7 +1059,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp return nil, err } // Managed probe/session state may only enter through dedicated typed endpoints. - input.Extra = maps.Clone(input.Extra) input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra) input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true) delete(input.Extra, UpstreamBillingProbeEnabledExtraKey) diff --git a/backend/internal/service/codex_client_reference.json b/backend/internal/service/codex_client_reference.json deleted file mode 100644 index 05801ee2d313..000000000000 --- a/backend/internal/service/codex_client_reference.json +++ /dev/null @@ -1,223 +0,0 @@ -{ - "source_kind": "official_binary_capture", - "capture_method": "local_loopback_synthetic_auth", - "captured_at": "2026-09-23T03:07:57.7987060Z", - "binary_sha256": "eabc698ea3b1c430623af0cae510c3459165ed453d2a5d2dac14513cf1b25269", - "client_version": "0.156.0", - "user_agent": "codex_exec/0.156.0 (Windows 10.0.26220; x86_64) dumb (codex_exec; 0.156.0)", - "originator": "codex_exec", - "http_accept_encoding_present": false, - "transport_scope": "responses_https_and_wss_loopback", - "tls_alignment": "captured_reference_only", - "actual_model_requests": 0, - "responses": [ - { - "method": "GET", - "path": "/backend-api/codex/responses", - "transport": { - "scheme": "https", - "tls_version": "TLSv1.3", - "cipher": "TLS_AES_256_GCM_SHA384", - "negotiated_alpn": null, - "client_hello": { - "legacy_version": 771, - "cipher_suites": [ - 4866, - 4865, - 4867, - 49196, - 49195, - 52393, - 49200, - 49199, - 52392, - 255 - ], - "extensions": [ - 23, - 11, - 5, - 45, - 10, - 51, - 35, - 43, - 0, - 13 - ], - "groups": [ - 4588, - 29, - 23, - 24 - ], - "point_formats": [ - 0 - ], - "signature_algorithms": [ - 1283, - 1027, - 1539, - 2055, - 2054, - 2053, - 2052, - 1537, - 1281, - 1025, - 2308, - 2309, - 2310 - ], - "supported_versions": [ - 772, - 771 - ], - "key_share_groups": [ - 4588, - 29 - ], - "alpn": [], - "ja3": "771,4866-4865-4867-49196-49195-52393-49200-49199-52392-255,23-11-5-45-10-51-35-43-0-13,4588-29-23-24,0", - "ja3_md5": "d6a68deca88b2e61c3d20a52c2aaf629" - } - }, - "headers": { - "upgrade": "websocket", - "sec-websocket-version": "13", - "user-agent": "codex_exec/0.156.0 (Windows 10.0.26220; x86_64) dumb (codex_exec; 0.156.0)", - "originator": "codex_exec", - "openai-beta": "responses_websockets=2026-02-06", - "sec-websocket-extensions": "permessage-deflate; client_max_window_bits" - } - }, - { - "method": "POST", - "path": "/backend-api/codex/responses", - "http_version": "HTTP/1.1", - "transport": { - "scheme": "https", - "tls_version": "TLSv1.3", - "cipher": "TLS_AES_256_GCM_SHA384", - "negotiated_alpn": "http/1.1", - "client_hello": { - "legacy_version": 771, - "cipher_suites": [ - 4866, - 4865, - 4867, - 49196, - 49195, - 52393, - 49200, - 49199, - 52392, - 255 - ], - "extensions": [ - 5, - 16, - 45, - 13, - 43, - 23, - 11, - 0, - 51, - 10, - 35 - ], - "groups": [ - 4588, - 29, - 23, - 24 - ], - "point_formats": [ - 0 - ], - "signature_algorithms": [ - 1283, - 1027, - 1539, - 2055, - 2054, - 2053, - 2052, - 1537, - 1281, - 1025, - 2308, - 2309, - 2310 - ], - "supported_versions": [ - 772, - 771 - ], - "key_share_groups": [ - 4588, - 29 - ], - "alpn": [ - "h2", - "http/1.1" - ], - "ja3": "771,4866-4865-4867-49196-49195-52393-49200-49199-52392-255,5-16-45-13-43-23-11-0-51-10-35,4588-29-23-24,0", - "ja3_md5": "3baae1d525347c65689cb2ed01b519e3" - } - }, - "header_names": [ - "x-codex-beta-features", - "x-codex-window-id", - "x-codex-turn-metadata", - "x-openai-internal-codex-responses-lite", - "x-client-request-id", - "session-id", - "thread-id", - "accept", - "content-type", - "authorization", - "chatgpt-account-id", - "originator", - "user-agent", - "host", - "content-length" - ], - "headers": { - "accept": "text/event-stream", - "content-type": "application/json", - "originator": "codex_exec", - "user-agent": "codex_exec/0.156.0 (Windows 10.0.26220; x86_64) dumb (codex_exec; 0.156.0)" - }, - "body_bytes": 77573, - "body_keys": [ - "client_metadata", - "include", - "input", - "model", - "parallel_tool_calls", - "prompt_cache_key", - "reasoning", - "store", - "stream", - "text", - "tool_choice" - ], - "client_metadata_keys": [ - "root_turn_id", - "session_id", - "thread_id", - "turn_id", - "x-codex-installation-id", - "x-codex-turn-metadata", - "x-codex-window-id" - ] - } - ], - "limitations": [ - "Captured reference is one Windows codex exec build.", - "Production Go TLS is a different implementation; no production JA3 or strict match is asserted.", - "The captured extension ordering may vary; preserve its actual sample order." - ] -} diff --git a/backend/internal/service/codex_credential_owner.go b/backend/internal/service/codex_credential_owner.go deleted file mode 100644 index 88be3567c27d..000000000000 --- a/backend/internal/service/codex_credential_owner.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "crypto/sha256" - "encoding/hex" - "strings" -) - -// CodexCredentialOwnerIdentity identifies the principal behind an account's -// Codex credential. Token refresh changes access/refresh tokens, not the -// owner; a reauthorization to another principal changes it. Turn-state -// provenance, WS bridge ownership, continuation keys and quota estimates are -// bound to this value, so its formula must stay stable. -func CodexCredentialOwnerIdentity(a *Account) string { - if a == nil { - return "" - } - parts := []string{a.Platform, a.Type, a.GetCredential("chatgpt_account_id"), a.GetCredential("chatgpt_user_id"), a.GetCredential("organization_id")} - if parts[2] == "" && parts[3] == "" { - parts = append(parts, a.GetCredential("email")) - } - sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) - return hex.EncodeToString(sum[:]) -} - -// isCodexCredentialOwner reports an OpenAI OAuth-like account that owns its -// credential. Credential shadows are excluded and keep their own forwarding -// policy. -func isCodexCredentialOwner(account *Account) bool { - return account != nil && account.IsOpenAIOAuthLike() && !account.IsShadow() -} diff --git a/backend/internal/service/codex_credential_owner_test.go b/backend/internal/service/codex_credential_owner_test.go deleted file mode 100644 index 05fca802730e..000000000000 --- a/backend/internal/service/codex_credential_owner_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package service - -import "testing" - -// The owner hash is stored in turn-state provenance, WS bridge ownership, -// continuation keys and quota estimates; these literals pin its formula. -func TestCodexCredentialOwnerIdentityIsStable(t *testing.T) { - for want, account := range map[string]*Account{ - // sha256("openai\x00oauth\x00acc-1\x00user-1\x00org-1") - "ca774e5584e7eba4c7f6866dcb6b5768461c31443d5057edf0428d80c020799e": {Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Credentials: map[string]any{"access_token": "rotates", "chatgpt_account_id": "acc-1", "chatgpt_user_id": "user-1", "organization_id": "org-1", "email": "ignored@example.com"}}, - // Without account and user ids the email joins: sha256("openai\x00setup-token\x00\x00\x00\x00owner@example.com") - "b51584109ca9f089aa8eebb7c122ddbefcf3767642ce66dd9a99eb62881a5a57": {Platform: PlatformOpenAI, Type: AccountTypeSetupToken, - Credentials: map[string]any{"email": "owner@example.com"}}, - } { - if got := CodexCredentialOwnerIdentity(account); got != want { - t.Fatalf("CodexCredentialOwnerIdentity(%s) = %s, want %s", account.Type, got, want) - } - } - if CodexCredentialOwnerIdentity(nil) != "" { - t.Fatal("a missing account has an owner identity") - } -} diff --git a/backend/internal/service/codex_identity_observation.go b/backend/internal/service/codex_identity_observation.go deleted file mode 100644 index fcc159f0685f..000000000000 --- a/backend/internal/service/codex_identity_observation.go +++ /dev/null @@ -1,87 +0,0 @@ -package service - -import ( - "crypto/sha256" - "encoding/hex" - "io" - "net/http" - "strings" - - "github.com/tidwall/gjson" -) - -type codexIdentityBodyKey struct{} - -// Raw values are shown to administrators next to the stable digests. -type CodexIdentityFieldObservation struct { - HeaderValue string `json:"header_value,omitempty"` - BodyValue string `json:"body_value,omitempty"` - HeaderDigest string `json:"header_digest,omitempty"` - BodyDigest string `json:"body_digest,omitempty"` - Consistency string `json:"consistency"` -} - -type codexIdentityBodyObservation struct { - Inspected bool - Fields map[string]string -} - -func inspectCodexIdentityBody(request *http.Request) codexIdentityBodyObservation { - result := codexIdentityBodyObservation{Fields: map[string]string{}} - if request == nil || request.GetBody == nil || request.Header.Get("Content-Encoding") != "" { - return result - } - body, err := request.GetBody() - if err != nil { - return result - } - defer func() { _ = body.Close() }() - raw, err := io.ReadAll(io.LimitReader(body, codexBodyReadLimit+1)) - if err != nil || len(raw) > codexBodyReadLimit || !gjson.ValidBytes(raw) { - return result - } - result.Inspected = true - paths := map[string]string{"installation": "client_metadata.x-codex-installation-id", "session": "client_metadata.session_id", "thread": "client_metadata.thread_id", "turn": "client_metadata.turn_id", "window": "client_metadata.x-codex-window-id"} - for field, path := range paths { - value := strings.TrimSpace(gjson.GetBytes(raw, path).String()) - if len(value) <= 512 { - result.Fields[field] = value - } - } - return result -} - -func codexIdentityFieldObservations(headers http.Header, body codexIdentityBodyObservation) map[string]CodexIdentityFieldObservation { - metadata := headers.Get(openAIWSTurnMetadataHeader) - values := map[string]string{"installation": gjson.Get(metadata, "installation_id").String(), "session": extractClientSessionID(headers), "thread": headers.Get("thread-id"), "turn": gjson.Get(metadata, "turn_id").String(), "window": headers.Get("x-codex-window-id")} - result := make(map[string]CodexIdentityFieldObservation, len(values)) - for key, raw := range values { - header := strings.TrimSpace(raw) - value := body.Fields[key] - observation := CodexIdentityFieldObservation{Consistency: "uninspected", HeaderValue: header, BodyValue: value} - if header != "" { - observation.HeaderDigest = codexIdentityDigest(header)[:16] - } - if value != "" { - observation.BodyDigest = codexIdentityDigest(value)[:16] - } - if body.Inspected { - switch { - case header == "" || value == "": - observation.Consistency = "missing" - case header == value: - observation.Consistency = "match" - default: - observation.Consistency = "mismatch" - } - } - result[key] = observation - } - return result -} - -// codexIdentityDigest is the hex SHA-256 of an identity value. -func codexIdentityDigest(value string) string { - sum := sha256.Sum256([]byte(value)) - return hex.EncodeToString(sum[:]) -} diff --git a/backend/internal/service/codex_identity_observation_test.go b/backend/internal/service/codex_identity_observation_test.go deleted file mode 100644 index ef226a808a6d..000000000000 --- a/backend/internal/service/codex_identity_observation_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package service - -import ( - "github.com/stretchr/testify/require" - "io" - "net/http" - "strings" - "testing" -) - -func TestCodexIdentityObservationsAreBoundedAndRedacted(t *testing.T) { - request, err := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"client_metadata":{"session_id":"same-session","thread_id":"body-thread","turn_id":"turn-only"},"input":"secret-prompt"}`)) - require.NoError(t, err) - request.Header.Set("session-id", "same-session") - request.Header.Set("thread-id", "different-thread") - before := request.Body - observation := inspectCodexIdentityBody(request) - fields := codexIdentityFieldObservations(request.Header, observation) - require.Equal(t, before, request.Body) - remaining, readErr := io.ReadAll(request.Body) - require.NoError(t, readErr) - require.Contains(t, string(remaining), "secret-prompt", "inspection must not consume the send body") - require.Equal(t, "match", fields["session"].Consistency) - require.Equal(t, "mismatch", fields["thread"].Consistency) - require.Equal(t, "missing", fields["turn"].Consistency) - require.Len(t, fields["session"].HeaderDigest, 16) - require.NotEqual(t, "same-session", fields["session"].HeaderDigest) - // Raw identifiers are shown next to the digests. - require.Equal(t, "same-session", fields["session"].HeaderValue) - require.Equal(t, "different-thread", fields["thread"].HeaderValue) - require.Equal(t, "body-thread", fields["thread"].BodyValue) - request.GetBody = nil - fields = codexIdentityFieldObservations(request.Header, inspectCodexIdentityBody(request)) - require.Equal(t, "uninspected", fields["session"].Consistency) - oversized, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"input":"`+strings.Repeat("x", codexBodyReadLimit)+`"}`)) - require.False(t, inspectCodexIdentityBody(oversized).Inspected) -} diff --git a/backend/internal/service/codex_model_guard.go b/backend/internal/service/codex_model_guard.go deleted file mode 100644 index 4f82e13ddd1c..000000000000 --- a/backend/internal/service/codex_model_guard.go +++ /dev/null @@ -1,509 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "sync" - "unicode/utf8" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -// codexBodyReadLimit bounds an inspected request body and, unless a reader -// sets its own limit, one SSE event. -const codexBodyReadLimit = 128 << 10 - -// codexUpstreamBodyLimit bounds the upstream error body or failing event kept -// with a failure. -const codexUpstreamBodyLimit = 8 << 10 - -// ErrCodexModelMismatch fails a response that declares another model than the -// request named. -var ErrCodexModelMismatch = errors.New("upstream Codex response model does not match the requested model") - -type codexExpectedModelKey struct{} - -// withCodexExpectedModel names the model the response to request must declare -// (see guardCodexResponseModel). -func withCodexExpectedModel(request *http.Request, model string) *http.Request { - if request == nil { - return nil - } - return request.WithContext(context.WithValue(request.Context(), codexExpectedModelKey{}, strings.TrimSpace(model))) -} - -// codexRequestBodyModel is the model a Responses request body names. -func codexRequestBodyModel(body []byte) string { - return strings.TrimSpace(gjson.GetBytes(body, "model").String()) -} - -type codexDownstreamContextKey struct{} - -// Keep cancellation evidence without reconnecting it to the upstream context: -// existing callers intentionally detach that context to drain usage on cancel. -func withCodexDownstreamContext(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(), codexDownstreamContextKey{}, c.Request.Context())) -} - -// guardCodexResponseModel holds an OAuth Codex /responses reply to the model -// its request named (withCodexExpectedModel): a 200 response that declares -// another model fails with ErrCodexModelMismatch. API-key aliases, /compact and -// requests without an expected model are untouched. -func (s *OpenAIGatewayService) guardCodexResponseModel(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(codexExpectedModelKey{}).(string) - if model == "" { - return - } - response.Body = s.newCodexModelGuardBody(request, response, model) -} - -func (s *OpenAIGatewayService) newCodexModelGuardBody(request *http.Request, response *http.Response, model string) *codexModelGuardBody { - completionContext := request.Context() - if downstream, ok := request.Context().Value(codexDownstreamContextKey{}).(context.Context); ok { - completionContext = downstream - } - body := &codexModelGuardBody{ - 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: codexResponseCompletion{RequestedModel: model, maxEventBytes: s.codexModelGuardEventLimit()}, - } - body.completion.headers(response.Header) - return body -} - -func (s *OpenAIGatewayService) codexModelGuardEventLimit() int { - maxEventBytes := defaultMaxLineSize - if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { - maxEventBytes = s.cfg.Gateway.MaxLineSize - } - return maxEventBytes -} - -type codexModelGuardBody struct { - io.ReadCloser - sse bool - detectSSE bool - ctx context.Context - maxJSONBytes int64 - rejectMismatch bool - completion codexResponseCompletion - json []byte - once sync.Once - mu sync.Mutex - done bool - finish func(codexResponseCompletion) -} - -func (body *codexModelGuardBody) Read(p []byte) (int, error) { - body.mu.Lock() - rejected := body.rejectMismatch && body.completion.Mismatch - body.mu.Unlock() - if rejected { - body.complete() - return 0, ErrCodexModelMismatch - } - n, err := body.ReadCloser.Read(p) - body.mu.Lock() - if n > 0 && !body.done { - if body.sse { - body.completion.feed(p[:n]) - } else { - 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("incomplete") - body.json = nil - } - } - } - if errors.Is(err, context.Canceled) { - body.completion.fail("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, ErrCodexModelMismatch - } - return n, err -} - -func (body *codexModelGuardBody) complete() { - body.once.Do(func() { - body.mu.Lock() - if !body.sse && !body.completion.Failed { - body.completion.json(body.json) - } - if body.ctx != nil && errors.Is(body.ctx.Err(), context.Canceled) { - body.completion.fail("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 *codexModelGuardBody) Close() error { - err := body.ReadCloser.Close() - body.complete() - return err -} - -// Readers select their existing stream/JSON read limits. The upstream error -// object, the failing event (bounded) and the local reason of a failure are -// kept with the completion. -type codexResponseCompletion struct { - Model string - RequestedModel string - Completed bool - Failed bool - Mismatch bool - FailureCode string - FailureDetail string - UpstreamError codexUpstreamError - UpstreamEvent []byte - Body []byte - maxEventBytes int - pending []byte - scanFrom int -} - -type codexUpstreamError struct { - Type, Code, Message, Param string -} - -func (e codexUpstreamError) empty() bool { - return e.Type == "" && e.Code == "" && e.Message == "" && e.Param == "" -} - -func codexJSONText(value gjson.Result) string { - switch { - case !value.Exists() || value.Type == gjson.Null: - return "" - case value.Type == gjson.String: - return value.String() - } - return value.Raw -} - -func codexUpstreamErrorObject(value gjson.Result) codexUpstreamError { - if value.Type == gjson.String { - return codexUpstreamError{Message: value.String()} - } - if !value.IsObject() { - return codexUpstreamError{} - } - return codexUpstreamError{Type: codexJSONText(value.Get("type")), Code: codexJSONText(value.Get("code")), Message: codexJSONText(value.Get("message")), Param: codexJSONText(value.Get("param"))} -} - -// codexUpstreamErrorFromBody reads the upstream's own error object from a JSON -// error body ({"error":...}, {"response":{"error":...}} or {"detail":...}). -func codexUpstreamErrorFromBody(body []byte) codexUpstreamError { - if !gjson.ValidBytes(body) { - return codexUpstreamError{} - } - root := gjson.ParseBytes(body) - for _, path := range []string{"error", "response.error", "detail"} { - if found := codexUpstreamErrorObject(root.Get(path)); !found.empty() { - return found - } - } - return codexUpstreamError{} -} - -// boundedCodexUpstreamBody keeps at most codexUpstreamBodyLimit bytes -// without splitting a UTF-8 sequence. -func boundedCodexUpstreamBody(raw []byte) []byte { - limit := codexUpstreamBodyLimit - if len(raw) > limit { - cut := limit - for cut > 0 && !utf8.RuneStart(raw[cut]) { - cut-- - } - raw = raw[:cut] - } - return append([]byte(nil), raw...) -} - -func (o *codexResponseCompletion) fail(code string) { - o.Failed = true - if o.FailureCode == "" { - o.FailureCode = code - } -} - -func (o *codexResponseCompletion) failWithDetail(code, detail string) { - if o.FailureDetail == "" && !o.Failed { - o.FailureDetail = detail - } - o.fail(code) -} - -// recordUpstream keeps the first upstream failure: its error object and a -// summary of the failing event (see codexUpstreamEventSummary). -func (o *codexResponseCompletion) recordUpstream(found codexUpstreamError, event []byte) { - if o.UpstreamEvent != nil || !o.UpstreamError.empty() { - return - } - o.UpstreamError = found - o.UpstreamEvent = codexUpstreamEventSummary(event) -} - -// codexUpstreamEventSummary keeps what identifies an upstream failure: the -// event type, its error fields (top level, error, detail) and the response id, -// status, model, incomplete_details and error. The response instructions, -// tools, input and output of a business request are never copied. -func codexUpstreamEventSummary(raw []byte) []byte { - if !gjson.ValidBytes(raw) { - return boundedCodexUpstreamBody(raw) - } - root := gjson.ParseBytes(raw) - summary := map[string]any{} - for _, key := range []string{"type", "code", "message", "param", "error", "detail"} { - if value := root.Get(key); value.Exists() && value.Type != gjson.Null { - summary[key] = json.RawMessage(value.Raw) - } - } - if response := root.Get("response"); response.IsObject() { - kept := map[string]json.RawMessage{} - for _, key := range []string{"id", "status", "model", "incomplete_details", "error"} { - if value := response.Get(key); value.Exists() && value.Type != gjson.Null { - kept[key] = json.RawMessage(value.Raw) - } - } - summary["response"] = kept - } - encoded, err := json.Marshal(summary) - if err != nil { - return nil - } - return boundedCodexUpstreamBody(encoded) -} - -func (o *codexResponseCompletion) observeModel(model string) { - model = strings.TrimSpace(model) - if model == "" { - return - } - if len(model) > 256 { - o.failWithDetail("incomplete", fmt.Sprintf("response model declaration is %d bytes (limit 256): %s", len(model), model)) - return - } - if (o.Model != "" && o.Model != model) || (o.RequestedModel != "" && o.RequestedModel != model) { - o.Mismatch = true - } - o.Model = model -} - -func (o *codexResponseCompletion) 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 codexUpstreamFailureCode(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 "upstream_capacity" - case "cyber_policy", "bio_policy", "misalignment_policy_violation", "content_policy_violation": - return "upstream_policy" - } - } - } - return "upstream_error" -} - -func (o *codexResponseCompletion) json(raw []byte) { - o.Body = boundedCodexUpstreamBody(raw) - o.jsonEvent(raw, "") - if !o.Completed && o.UpstreamError.empty() && o.UpstreamEvent == nil { - // Non-streaming error bodies may carry {"detail":...} instead of an - // error object; keep that upstream text too. - if found := codexUpstreamErrorFromBody(raw); !found.empty() { - o.recordUpstream(found, raw) - } - } -} - -func (o *codexResponseCompletion) jsonEvent(raw []byte, eventType string) { - var event struct { - 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"` - 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) { - o.recordUpstream(codexUpstreamErrorObject(gjson.ParseBytes(event.Error)), raw) - o.fail(codexUpstreamFailureCode(event.Error)) - return - } - if event.Response != nil && badError(event.Response.Error) { - o.recordUpstream(codexUpstreamErrorObject(gjson.ParseBytes(event.Response.Error)), raw) - o.fail(codexUpstreamFailureCode(event.Response.Error)) - return - } - switch event.Type { - case "response.cancelled", "response.canceled": - o.recordUpstream(codexUpstreamError{Type: event.Type}, raw) - o.fail("cancelled") - return - case "response.incomplete": - o.recordUpstream(codexUpstreamError{Type: "incomplete", Code: codexJSONText(gjson.GetBytes(raw, "response.incomplete_details.reason"))}, raw) - o.fail("incomplete") - return - case "error", "response.failed": - // A streamed error event carries code/message/param at its top level. - root := gjson.ParseBytes(raw) - o.recordUpstream(codexUpstreamError{Code: codexJSONText(root.Get("code")), Message: codexJSONText(root.Get("message")), Param: codexJSONText(root.Get("param"))}, raw) - o.fail("upstream_error") - return - } - terminal := event.Type == "response.completed" && event.Response != nil - if terminal { - if event.Response.Status != "completed" { - o.recordUpstream(codexUpstreamError{Type: "status", Code: event.Response.Status}, raw) - o.failWithDetail("incomplete", fmt.Sprintf("response.completed carried status %q", event.Response.Status)) - return - } - } else if event.Object == "response" && event.Status == "completed" { - terminal = true - } else { - return - } - if terminal && o.Model != "" { - o.Completed = true - } else { - o.failWithDetail("incomplete", "terminal response did not declare a model") - } -} - -func (o *codexResponseCompletion) feed(data []byte) { - if o.Failed { - return - } - limit := o.maxEventBytes - if limit <= 0 { - limit = codexBodyReadLimit - } - o.pending = append(o.pending, data...) - 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.failWithDetail("incomplete", fmt.Sprintf("SSE event of %d bytes exceeds the %d-byte limit", i, limit)) - break - } - 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.jsonEvent(bytes.Join(lines, []byte{'\n'}), eventType) - o.pending = o.pending[next+1:] - i = -1 - if o.Failed { - break - } - } - o.scanFrom = max(0, len(o.pending)-2) - if len(o.pending) > limit { - o.failWithDetail("incomplete", fmt.Sprintf("unterminated SSE event of %d bytes exceeds the %d-byte limit", len(o.pending), limit)) - } - if o.Failed { - o.pending = nil - } -} diff --git a/backend/internal/service/codex_model_guard_test.go b/backend/internal/service/codex_model_guard_test.go deleted file mode 100644 index ab45d7d46293..000000000000 --- a/backend/internal/service/codex_model_guard_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package service - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/stretchr/testify/require" -) - -// A guarded response only completes with a delimited terminal event that -// declares the requested model. -func TestCodexModelGuardRequiresOriginalModelAndDelimitedTerminal(t *testing.T) { - svc := &OpenAIGatewayService{cfg: &config.Config{}} - read := func(contentType, body string) codexResponseCompletion { - header := http.Header{} - if contentType != "" { - header.Set("Content-Type", contentType) - } - response := &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(body))} - guard := svc.newCodexModelGuardBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") - _, _ = io.ReadAll(guard) - require.NoError(t, guard.Close()) - return guard.completion - } - declared := func(completion codexResponseCompletion) bool { - return completion.Completed && !completion.Failed && !completion.Mismatch && completion.Model == "gpt-6-astra" - } - for _, test := range []struct { - name, body string - ok bool - }{ - {"complete", "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\"}}\n\n", true}, - {"model_mismatch", "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-luna\"}}\n\n", false}, - {"missing_model", "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n", false}, - {"truncated", "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\"}}", false}, - {"done_only", "data: [DONE]\n\n", false}, - {"error", "data: {\"type\":\"error\",\"error\":{\"code\":\"server_is_overloaded\"}}\n\n", false}, - } { - t.Run(test.name, func(t *testing.T) { - require.Equal(t, test.ok, declared(read("text/event-stream", test.body))) - }) - } - stream := `event: response.completed -data: {"type":"response.completed","response":{"status":"completed","model":"gpt-6-astra"}} - -` - require.True(t, declared(read("", stream)), "ChatGPT streams arrive without Content-Type") -} diff --git a/backend/internal/service/codex_native_compatibility_test.go b/backend/internal/service/codex_native_compatibility_test.go deleted file mode 100644 index b5dfb1cc96dc..000000000000 --- a/backend/internal/service/codex_native_compatibility_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/stretchr/testify/require" -) - -type nativeSameConfigRepository struct { - *nativeCodexMemoryStore - metadata *NativeCodexMetadata - stored int - failure error -} - -func (r *nativeSameConfigRepository) LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) { - m := *r.metadata - return &m, nil -} -func (r *nativeSameConfigRepository) StoreNativeCodexConfig(_ context.Context, _ string, hash string) (*NativeCodexMetadata, error) { - if hash != r.metadata.ConfigSHA256 { - return nil, ErrNativeCodexRuntimeChanged - } - r.stored++ - return r.metadata, r.failure -} - -type nativeConfigEncryptorFixture struct{} - -func (nativeConfigEncryptorFixture) Encrypt(value string) (string, error) { - return "fixture-cipher:" + value, nil -} -func (nativeConfigEncryptorFixture) Decrypt(value string) (string, error) { return value, nil } - -// nativeStoredConfigRepository serves one saved configuration and records the -// hash and cipher the runtime writes back. -type nativeStoredConfigRepository struct { - *nativeCodexMemoryStore - stored string - metadata *NativeCodexMetadata -} - -func (r *nativeStoredConfigRepository) ReadNativeCodexStoredConfig(context.Context) (string, bool, error) { - return r.stored, true, nil -} -func (r *nativeStoredConfigRepository) LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) { - m := *r.metadata - return &m, nil -} -func (r *nativeStoredConfigRepository) SyncNativeCodexConfig(_ context.Context, hash string) (*NativeCodexMetadata, error) { - if hash != r.metadata.ConfigSHA256 { - r.metadata = &NativeCodexMetadata{ID: r.metadata.ID, RuntimeGeneration: r.metadata.RuntimeGeneration + 1, ConfigVersion: r.metadata.ConfigVersion + 1, ConfigSHA256: hash} - } - m := *r.metadata - return &m, nil -} -func (r *nativeStoredConfigRepository) StoreNativeCodexConfig(ctx context.Context, cipher, hash string) (*NativeCodexMetadata, error) { - r.stored = cipher - return r.SyncNativeCodexConfig(ctx, hash) -} - -func TestNativeCodexStoredRetiredSettingsStillBoot(t *testing.T) { - previous := nativeCodexPolicyRuntime.Load() - t.Cleanup(func() { nativeCodexPolicyRuntime.Store(previous) }) - ctx := context.Background() - // A configuration saved before the route-qualification feature was retired - // holds every one of its settings next to request_zstd. - stored := `{"routing_schema":2,"enabled":true,"fail_closed":true,"proxy_url":"http://user:fixture-secret@proxy.test:8080","proxy_protocol":"https","proxy_selection_id":"selection","models":["gpt-6-astra","gpt-6-sol","gpt-6-luna","gpt-5.6-sol"],"request_zstd":false}` - repo := &nativeStoredConfigRepository{nativeCodexMemoryStore: &nativeCodexMemoryStore{values: map[string]extensionv1.StateResult{}}, stored: stored, metadata: nativeCodexTestMetadata()} - gateway := &OpenAIGatewayService{} - runtime := ProvideNativeCodexRuntime(gateway, repo, nil, nativeConfigEncryptorFixture{}, &config.Config{}, nil, nil) - require.NoError(t, runtime.Start(ctx), "the server exits when the runtime cannot start") - t.Cleanup(func() { _ = runtime.Stop(context.Background()) }) - raw, metadata, err := gateway.NativeCodexConfiguration(ctx) - require.NoError(t, err) - require.Equal(t, `{"request_zstd":false}`, string(raw)) - sum := sha256.Sum256(raw) - require.Equal(t, hex.EncodeToString(sum[:]), metadata.ConfigSHA256) - require.Equal(t, stored, repo.stored, "booting never rewrites the saved configuration") - plan := func() extensionv1.CodexTransportPlan { - result, invokeErr := runtime.Invoke(ctx, PlatformOpenAI, AccountTypeOAuth, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "codex.transport.plan", - Payload: json.RawMessage(`{"method":"POST","path":"/backend-api/codex/responses","content_type":"application/json","body_present":true}`)}) - require.NoError(t, invokeErr) - var value extensionv1.CodexTransportPlan - require.NoError(t, json.Unmarshal(result.Payload, &value)) - return value - } - require.False(t, plan().Compress, "the saved request_zstd stays in effect") - // An older admin page may still send the retired settings: they are dropped - // and never stored again. Any other unknown setting is still rejected. - require.NoError(t, runtime.UpdateConfig(ctx, json.RawMessage(`{"enabled":false,"fail_closed":true,"models":["gpt-6-astra"],"proxy_url":"","request_zstd":true}`), nativeConfigEncryptorFixture{})) - require.Equal(t, `fixture-cipher:{"request_zstd":true}`, repo.stored) - require.True(t, plan().Compress) - _, err = NormalizeNativeCodexConfig(ctx, json.RawMessage(`{"request_zstd":true,"harvest":true}`)) - require.ErrorContains(t, err, "unknown codex runtime setting: harvest") -} - -func TestNativeCodexSameConfigSaveKeepsActiveEpoch(t *testing.T) { - raw, err := NormalizeNativeCodexConfig(context.Background(), json.RawMessage(`{"enabled":false}`)) - require.NoError(t, err) - sum := sha256.Sum256(raw) - metadata := nativeCodexTestMetadata() - metadata.ConfigSHA256 = hex.EncodeToString(sum[:]) - repo := &nativeSameConfigRepository{nativeCodexMemoryStore: &nativeCodexMemoryStore{}, metadata: metadata} - epoch, cancel := context.WithCancel(context.Background()) - defer cancel() - snapshot := &nativeCodexSnapshot{metadata: metadata, raw: raw, ctx: epoch, cancel: cancel} - runtime := &NativeCodexRuntime{repo: repo, started: true} - runtime.snapshot.Store(snapshot) - require.NoError(t, runtime.UpdateConfig(context.Background(), raw, nativeConfigEncryptorFixture{})) - require.NoError(t, epoch.Err()) - require.Same(t, snapshot, runtime.current()) - require.Equal(t, 1, repo.stored) - repo.failure = errors.New("fixture write failed") - require.ErrorIs(t, runtime.UpdateConfig(context.Background(), raw, nativeConfigEncryptorFixture{}), repo.failure) - require.NoError(t, epoch.Err()) - require.Same(t, snapshot, runtime.current()) -} diff --git a/backend/internal/service/codex_native_contract.go b/backend/internal/service/codex_native_contract.go deleted file mode 100644 index af8c6527bd64..000000000000 --- a/backend/internal/service/codex_native_contract.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -const NativeCodexPluginKey = "codexrip.codex-runtime" -const NativeCodexConfigSettingKey = "codex_native_runtime_config" -const NativeCodexSourceSettingKey = "codex_native_runtime_source" -const NativeCodexRetirementSettingKey = "deplugin_retired_plugins" - -var ErrNativeCodexRuntimeChanged = errors.New("native Codex runtime metadata changed") -var ErrNativeCodexRuntimeUnavailable = errors.New("native Codex runtime unavailable") - -// The disabled legacy row is a persistence anchor, never a running plugin. -type NativeCodexMetadata struct { - ID int64 - RuntimeGeneration int64 - ConfigVersion int64 - ConfigSHA256 string -} - -type NativeCodexConfigRecord struct { - Version int `json:"version"` - ConfigVersion int64 `json:"config_version"` - ConfigSHA256 string `json:"config_sha256"` -} - -type NativeCodexStateStore interface { - ReadExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) - CompareSwapExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) -} - -type NativeCodexRuntimeLease interface { - Done() <-chan struct{} - Err() error - Release() -} - -type NativeCodexRepository interface { - NativeCodexStateStore - ReadNativeCodexStoredConfig(context.Context) (string, bool, error) - LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) - SyncNativeCodexConfig(context.Context, string) (*NativeCodexMetadata, error) - StoreNativeCodexConfig(context.Context, string, string) (*NativeCodexMetadata, error) - HoldNativeCodexRuntime(context.Context, *NativeCodexMetadata) (NativeCodexRuntimeLease, error) -} - -type NativeCodexConfigFactory func(context.Context) (json.RawMessage, error) - -type nativeCodexExecutionKey struct{} -type nativeCodexBusinessIOKey struct{} - -func WithNativeCodexExecution(ctx context.Context, metadata *NativeCodexMetadata) context.Context { - if metadata == nil { - return ctx - } - return context.WithValue(ctx, nativeCodexExecutionKey{}, *metadata) -} - -func NativeCodexExecutionFromContext(ctx context.Context) (NativeCodexMetadata, bool) { - metadata, ok := ctx.Value(nativeCodexExecutionKey{}).(NativeCodexMetadata) - return metadata, ok -} - -func WithNativeCodexBusinessIO(ctx context.Context) context.Context { - return context.WithValue(ctx, nativeCodexBusinessIOKey{}, true) -} - -func NativeCodexBusinessIORequired(ctx context.Context) bool { - required, _ := ctx.Value(nativeCodexBusinessIOKey{}).(bool) - return required -} diff --git a/backend/internal/service/codex_native_fixture_test.go b/backend/internal/service/codex_native_fixture_test.go deleted file mode 100644 index 73d6d9029017..000000000000 --- a/backend/internal/service/codex_native_fixture_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "sync" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// nativeCodexMemoryStore is an in-memory native Codex state store. -type nativeCodexMemoryStore struct { - PluginRepository - mu sync.Mutex - values map[string]extensionv1.StateResult -} - -func (store *nativeCodexMemoryStore) ReadExtensionState(_ context.Context, _ string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { - store.mu.Lock() - defer store.mu.Unlock() - return store.values[req.Namespace+":"+req.Key], nil -} - -func (store *nativeCodexMemoryStore) CompareSwapExtensionState(_ context.Context, _ string, req extensionv1.StateRequest) (extensionv1.StateResult, error) { - store.mu.Lock() - defer store.mu.Unlock() - if store.values == nil { - store.values = map[string]extensionv1.StateResult{} - } - key := req.Namespace + ":" + req.Key - old := store.values[key] - old.Applied = false - if old.Revision == req.ExpectedRevision { - old = extensionv1.StateResult{Found: true, Applied: true, Revision: old.Revision + 1, Value: append(json.RawMessage(nil), req.Value...)} - store.values[key] = old - } - return old, nil -} - -type codexAccountRepositoryFixture struct { - AccountRepository - account *Account -} - -func (r *codexAccountRepositoryFixture) GetByID(context.Context, int64) (*Account, error) { - return r.account, nil -} - -func codexOAuthTestAccount(id int64) *Account { - return &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "tok", "chatgpt_account_id": "acc-1"}} -} - -type nativeCodexTestLease struct { - done chan struct{} - once sync.Once -} - -func (l *nativeCodexTestLease) Done() <-chan struct{} { return l.done } -func (*nativeCodexTestLease) Err() error { return nil } -func (l *nativeCodexTestLease) Release() { l.once.Do(func() { close(l.done) }) } - -func (*nativeCodexMemoryStore) ReadNativeCodexStoredConfig(context.Context) (string, bool, error) { - return "", false, nil -} -func (*nativeCodexMemoryStore) LoadNativeCodexMetadata(context.Context) (*NativeCodexMetadata, error) { - return nativeCodexTestMetadata(), nil -} -func (*nativeCodexMemoryStore) StoreNativeCodexConfig(context.Context, string, string) (*NativeCodexMetadata, error) { - return nativeCodexTestMetadata(), nil -} -func (*nativeCodexMemoryStore) SyncNativeCodexConfig(context.Context, string) (*NativeCodexMetadata, error) { - return nativeCodexTestMetadata(), nil -} -func (*nativeCodexMemoryStore) HoldNativeCodexRuntime(ctx context.Context, m *NativeCodexMetadata) (NativeCodexRuntimeLease, error) { - if !NativeCodexBusinessIORequired(ctx) || m == nil { - return nil, ErrNativeCodexRuntimeChanged - } - return &nativeCodexTestLease{done: make(chan struct{})}, nil -} -func nativeCodexTestMetadata() *NativeCodexMetadata { - sum := sha256.Sum256([]byte("fixture-config")) - return &NativeCodexMetadata{ID: 1, RuntimeGeneration: 1, ConfigVersion: 1, ConfigSHA256: hex.EncodeToString(sum[:])} -} - -// nativeCodexModuleFixture answers every runtime invocation with an empty -// result. -type nativeCodexModuleFixture struct{} - -func (nativeCodexModuleFixture) Invoke(context.Context, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, nil -} -func (nativeCodexModuleFixture) ApplyConfig(context.Context, json.RawMessage) error { return nil } -func (nativeCodexModuleFixture) Start(context.Context) error { return nil } -func (nativeCodexModuleFixture) Stop(context.Context) error { return nil } - -type nativeCodexTestAccountRepository struct{ AccountRepository } - -func (nativeCodexTestAccountRepository) GetByID(_ context.Context, id int64) (*Account, error) { - return codexOAuthTestAccount(id), nil -} - -// nativeCodexTestRuntime is a loaded native runtime with in-memory state. -func nativeCodexTestRuntime(t *testing.T) *NativeCodexRuntime { - t.Helper() - store := &nativeCodexMemoryStore{values: map[string]extensionv1.StateResult{}} - r := &NativeCodexRuntime{repo: store, gateway: &OpenAIGatewayService{accountRepo: nativeCodexTestAccountRepository{}}} - epoch, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - metadata := nativeCodexTestMetadata() - host := &nativeCodexHost{metadata: metadata, repo: store, epoch: epoch} - r.snapshot.Store(&nativeCodexSnapshot{metadata: metadata, host: host, module: nativeCodexModuleFixture{}, ctx: epoch, cancel: cancel}) - return r -} diff --git a/backend/internal/service/codex_native_host.go b/backend/internal/service/codex_native_host.go deleted file mode 100644 index f8671dfb2eb3..000000000000 --- a/backend/internal/service/codex_native_host.go +++ /dev/null @@ -1,113 +0,0 @@ -package service - -import ( - "context" - "sync" -) - -// nativeCodexHost fences runtime invocations to one configuration epoch: bind -// holds the persistent runtime lease for a call, drain waits for the epoch's -// calls before a configuration change. -type nativeCodexHost struct { - leaseMu sync.Mutex - leaseWork sync.WaitGroup - closing bool - metadata *NativeCodexMetadata - repo NativeCodexRepository - epoch context.Context -} -type nativeCodexLeaseBindingKey struct{} -type nativeCodexPolicySignalsKey struct{} - -func nativeCodexPolicySignals(ctx context.Context) []context.Context { - signals, _ := ctx.Value(nativeCodexPolicySignalsKey{}).([]context.Context) - return signals -} - -type nativeCodexLeaseBinding struct { - metadata NativeCodexMetadata - ctx context.Context -} - -func (h *nativeCodexHost) bind(parent context.Context) (context.Context, context.CancelFunc, error) { - if h == nil || h.metadata == nil || h.repo == nil || h.epoch == nil || h.epoch.Err() != nil { - return nil, nil, ErrNativeCodexRuntimeUnavailable - } - if wanted, ok := NativeCodexExecutionFromContext(parent); ok && wanted != *h.metadata { - return nil, nil, ErrNativeCodexRuntimeChanged - } - if existing, ok := parent.Value(nativeCodexLeaseBindingKey{}).(nativeCodexLeaseBinding); ok && existing.metadata == *h.metadata && existing.ctx.Err() == nil { - ctx, cancel := context.WithCancel(parent) - return ctx, cancel, nil - } - h.leaseMu.Lock() - if h.closing { - h.leaseMu.Unlock() - return nil, nil, ErrNativeCodexRuntimeUnavailable - } - h.leaseWork.Add(1) - h.leaseMu.Unlock() - bound, cancel := context.WithCancel(WithNativeCodexExecution(parent, h.metadata)) - signal, stopSignal := context.WithCancel(context.Background()) - stopPropagation := context.AfterFunc(signal, cancel) - stopEpoch := context.AfterFunc(h.epoch, stopSignal) - signals := append(append([]context.Context(nil), nativeCodexPolicySignals(parent)...), signal) - bound = context.WithValue(bound, nativeCodexPolicySignalsKey{}, signals) - if h.epoch.Err() != nil { - stopSignal() - cancel() - } - lease, err := h.repo.HoldNativeCodexRuntime(WithNativeCodexBusinessIO(bound), h.metadata) - if err != nil { - stopEpoch() - stopPropagation() - stopSignal() - cancel() - h.leaseWork.Done() - return nil, nil, err - } - if lease == nil || lease.Done() == nil { - stopEpoch() - stopPropagation() - stopSignal() - cancel() - if lease != nil { - lease.Release() - } - h.leaseWork.Done() - return nil, nil, ErrNativeCodexRuntimeUnavailable - } - go func() { - <-lease.Done() - if lease.Err() != nil { - stopSignal() - } - }() - binding := nativeCodexLeaseBinding{metadata: *h.metadata, ctx: bound} - bound = context.WithValue(bound, nativeCodexLeaseBindingKey{}, binding) - var once sync.Once - return bound, func() { - once.Do(func() { - stopEpoch() - stopPropagation() - stopSignal() - cancel() - lease.Release() - h.leaseWork.Done() - }) - }, nil -} - -func (h *nativeCodexHost) drain(ctx context.Context) error { - h.leaseMu.Lock() - h.closing = true - h.leaseMu.Unlock() - done := make(chan struct{}) - go func() { h.leaseWork.Wait(); close(done) }() - select { - case <-done: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} diff --git a/backend/internal/service/codex_native_provider.go b/backend/internal/service/codex_native_provider.go deleted file mode 100644 index 3bd93822d355..000000000000 --- a/backend/internal/service/codex_native_provider.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - - "github.com/Wei-Shaw/sub2api/internal/config" -) - -// The setting repository, bootstrap snapshot and quota activity only served -// the retired route-qualification feature; the parameters keep the wire graph -// unchanged. -func ProvideNativeCodexRuntime(gateway *OpenAIGatewayService, repo NativeCodexRepository, _ SettingRepository, encryptor SecretEncryptor, cfg *config.Config, _ *NativeFeatureBootstrap, _ *QuotaActivityService) *NativeCodexRuntime { - factory := func(ctx context.Context) (json.RawMessage, error) { - cipher, present, err := repo.ReadNativeCodexStoredConfig(ctx) - if err != nil { - return nil, err - } - var raw json.RawMessage - if present { - plain, decryptErr := encryptor.Decrypt(cipher) - if decryptErr != nil { - return nil, errors.New("cannot decrypt saved Codex configuration") - } - raw = json.RawMessage(plain) - } else { - raw, err = json.Marshal(map[string]any{"request_zstd": cfg.Gateway.OpenAICodexRequestZstd}) - if err != nil { - return nil, err - } - } - // A saved configuration may still hold retired settings; normalization drops them. - return NormalizeNativeCodexConfig(ctx, raw) - } - return NewNativeCodexRuntime(gateway, repo, factory) -} - -func (s *OpenAIGatewayService) NativeCodexConfiguration(ctx context.Context) (json.RawMessage, NativeCodexMetadata, error) { - if s == nil || s.nativeCodexRuntime == nil || ctx == nil || ctx.Err() != nil { - return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable - } - runtime := s.nativeCodexRuntime - // Keep the receipt in the same lifecycle epoch throughout the persistent read. - runtime.mu.Lock() - defer runtime.mu.Unlock() - snapshot := runtime.snapshot.Load() - if !runtime.started || runtime.repo == nil || snapshot == nil || snapshot.metadata == nil || len(snapshot.raw) == 0 || - snapshot.ctx == nil || snapshot.ctx.Err() != nil || ctx.Err() != nil { - return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable - } - metadata := *snapshot.metadata - if metadata.ID <= 0 || metadata.ConfigVersion <= 0 || metadata.RuntimeGeneration <= 0 { - return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable - } - raw := append(json.RawMessage(nil), snapshot.raw...) - sum := sha256.Sum256(raw) - if metadata.ConfigSHA256 != hex.EncodeToString(sum[:]) { - return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable - } - current, err := runtime.repo.LoadNativeCodexMetadata(ctx) - if err != nil || current == nil || *current != metadata || ctx.Err() != nil || snapshot.ctx.Err() != nil { - return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable - } - return raw, metadata, nil -} - -func (s *OpenAIGatewayService) UpdateNativeCodexConfiguration(ctx context.Context, raw json.RawMessage, encryptor SecretEncryptor) error { - if s == nil || s.nativeCodexRuntime == nil { - return ErrNativeCodexRuntimeUnavailable - } - return s.nativeCodexRuntime.UpdateConfig(ctx, raw, encryptor) -} diff --git a/backend/internal/service/codex_native_provider_receipt_test.go b/backend/internal/service/codex_native_provider_receipt_test.go deleted file mode 100644 index 30cba2ea6b48..000000000000 --- a/backend/internal/service/codex_native_provider_receipt_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -type nativeCodexReceiptRepository struct { - // Any config write, stored-source read or business lease is unexpected. - NativeCodexRepository - metadata NativeCodexMetadata - loads int - load func(context.Context) (*NativeCodexMetadata, error) -} - -func (r *nativeCodexReceiptRepository) LoadNativeCodexMetadata(ctx context.Context) (*NativeCodexMetadata, error) { - r.loads++ - if r.load != nil { - return r.load(ctx) - } - metadata := r.metadata - return &metadata, nil -} - -type nativeCodexReceiptModule struct{ nativeCodexModule } - -func (nativeCodexReceiptModule) Stop(context.Context) error { return nil } - -func nativeCodexReceiptFixture(t *testing.T) (*OpenAIGatewayService, *nativeCodexSnapshot, *nativeCodexReceiptRepository) { - t.Helper() - // Preserve field order, whitespace and the trailing newline in the receipt. - raw := json.RawMessage("{\n \"models\": [\"gpt-6-astra\"],\n \"enabled\": false\n}\n") - sum := sha256.Sum256(raw) - metadata := NativeCodexMetadata{ - ID: 17, ConfigVersion: 9_007_199_254_740_993, RuntimeGeneration: 9_223_372_036_854_775_807, - ConfigSHA256: hex.EncodeToString(sum[:]), - } - repo := &nativeCodexReceiptRepository{metadata: metadata} - epoch, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - snapshot := &nativeCodexSnapshot{ - metadata: &metadata, raw: raw, ctx: epoch, cancel: cancel, - module: nativeCodexReceiptModule{}, host: &nativeCodexHost{}, - } - runtime := &NativeCodexRuntime{ - root: context.Background(), started: true, repo: repo, - factory: func(context.Context) (json.RawMessage, error) { - t.Fatal("a receipt read must not rebuild configuration") - return nil, ErrNativeCodexRuntimeUnavailable - }, - } - runtime.snapshot.Store(snapshot) - return &OpenAIGatewayService{nativeCodexRuntime: runtime}, snapshot, repo -} - -func requireNativeCodexReceiptUnavailable(t *testing.T, raw json.RawMessage, metadata NativeCodexMetadata, err error) { - t.Helper() - require.Equal(t, ErrNativeCodexRuntimeUnavailable, err) - require.Nil(t, raw) - require.Zero(t, metadata) -} - -func TestNativeCodexConfigurationReceiptCopiesExactSnapshot(t *testing.T) { - service, snapshot, repo := nativeCodexReceiptFixture(t) - wantRaw := append(json.RawMessage(nil), snapshot.raw...) - wantMetadata := *snapshot.metadata - raw, metadata, err := service.NativeCodexConfiguration(context.Background()) - require.NoError(t, err) - require.Equal(t, wantRaw, raw) - require.Equal(t, wantMetadata, metadata) - require.Equal(t, 1, repo.loads) - sum := sha256.Sum256(raw) - require.Equal(t, hex.EncodeToString(sum[:]), metadata.ConfigSHA256) - - raw[0] = '!' - metadata.ID = 99 - metadata.ConfigVersion = 1 - metadata.RuntimeGeneration = 1 - metadata.ConfigSHA256 = strings.Repeat("0", 64) - require.NotEqual(t, wantRaw, raw) - require.NotEqual(t, wantMetadata, metadata) - require.Equal(t, wantRaw, snapshot.raw) - require.Equal(t, wantMetadata, *snapshot.metadata) - require.Equal(t, wantMetadata, repo.metadata) - secondRaw, secondMetadata, err := service.NativeCodexConfiguration(context.Background()) - require.NoError(t, err) - require.Equal(t, wantRaw, secondRaw) - require.Equal(t, wantMetadata, secondMetadata) - require.Equal(t, 2, repo.loads) -} - -func TestNativeCodexConfigurationReceiptRejectsInactiveRuntime(t *testing.T) { - for _, name := range []string{ - "nil service", "missing runtime", "not started", "stopped", "missing repository", - "missing snapshot", "missing metadata", "missing epoch", "canceled epoch", "nil request context", "canceled request", - } { - t.Run(name, func(t *testing.T) { - service, snapshot, repo := nativeCodexReceiptFixture(t) - ctx := context.Background() - switch name { - case "nil service": - service = nil - case "missing runtime": - service.nativeCodexRuntime = nil - case "not started": - service.nativeCodexRuntime.started = false - case "stopped": - require.NoError(t, service.nativeCodexRuntime.Stop(ctx)) - require.Same(t, snapshot, service.nativeCodexRuntime.current()) - case "missing repository": - service.nativeCodexRuntime.repo = nil - case "missing snapshot": - service.nativeCodexRuntime.snapshot.Store(nil) - case "missing metadata": - snapshot.metadata = nil - case "missing epoch": - snapshot.ctx = nil - case "canceled epoch": - snapshot.cancel() - case "nil request context": - ctx = nil - case "canceled request": - var cancel context.CancelFunc - ctx, cancel = context.WithCancel(ctx) - cancel() - } - raw, metadata, err := service.NativeCodexConfiguration(ctx) - requireNativeCodexReceiptUnavailable(t, raw, metadata, err) - require.Zero(t, repo.loads) - }) - } -} - -func TestNativeCodexConfigurationReceiptRejectsInvalidSnapshot(t *testing.T) { - tests := []struct { - name string - change func(*nativeCodexSnapshot) - }{ - {"zero ID", func(s *nativeCodexSnapshot) { s.metadata.ID = 0 }}, - {"negative ID", func(s *nativeCodexSnapshot) { s.metadata.ID = -1 }}, - {"zero version", func(s *nativeCodexSnapshot) { s.metadata.ConfigVersion = 0 }}, - {"negative version", func(s *nativeCodexSnapshot) { s.metadata.ConfigVersion = -1 }}, - {"zero generation", func(s *nativeCodexSnapshot) { s.metadata.RuntimeGeneration = 0 }}, - {"negative generation", func(s *nativeCodexSnapshot) { s.metadata.RuntimeGeneration = -1 }}, - {"missing hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = "" }}, - {"uppercase hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = strings.ToUpper(s.metadata.ConfigSHA256) }}, - {"invalid hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = strings.Repeat("x", 64) }}, - {"wrong hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = strings.Repeat("0", 64) }}, - {"changed raw bytes", func(s *nativeCodexSnapshot) { s.raw = append(s.raw, '\n') }}, - {"empty raw", func(s *nativeCodexSnapshot) { - s.raw = nil - sum := sha256.Sum256(nil) - s.metadata.ConfigSHA256 = hex.EncodeToString(sum[:]) - }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - service, snapshot, repo := nativeCodexReceiptFixture(t) - tt.change(snapshot) - // Matching persistence must not make an invalid snapshot acceptable. - repo.metadata = *snapshot.metadata - raw, metadata, err := service.NativeCodexConfiguration(context.Background()) - requireNativeCodexReceiptUnavailable(t, raw, metadata, err) - require.Zero(t, repo.loads) - }) - } -} - -func TestNativeCodexConfigurationReceiptRejectsUnavailableFence(t *testing.T) { - for _, name := range []string{"read failure", "missing metadata", "ID changed", "version changed", "generation changed", "hash changed"} { - t.Run(name, func(t *testing.T) { - service, _, repo := nativeCodexReceiptFixture(t) - switch name { - case "read failure": - repo.load = func(context.Context) (*NativeCodexMetadata, error) { - return &repo.metadata, errors.New("private repository failure detail") - } - case "missing metadata": - repo.load = func(context.Context) (*NativeCodexMetadata, error) { return nil, nil } - case "ID changed": - repo.metadata.ID++ - case "version changed": - repo.metadata.ConfigVersion++ - case "generation changed": - repo.metadata.RuntimeGeneration-- - case "hash changed": - repo.metadata.ConfigSHA256 = strings.Repeat("0", 64) - } - raw, metadata, err := service.NativeCodexConfiguration(context.Background()) - requireNativeCodexReceiptUnavailable(t, raw, metadata, err) - require.Equal(t, 1, repo.loads) - }) - } -} - -func TestNativeCodexConfigurationReceiptRejectsCancellationDuringRead(t *testing.T) { - for _, name := range []string{"request", "epoch"} { - t.Run(name, func(t *testing.T) { - service, snapshot, repo := nativeCodexReceiptFixture(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - repo.load = func(loadCtx context.Context) (*NativeCodexMetadata, error) { - require.Same(t, ctx, loadCtx) - if name == "request" { - cancel() - } else { - snapshot.cancel() - } - // A reader may return a row despite cancellation; no receipt may escape. - metadata := repo.metadata - return &metadata, nil - } - raw, metadata, err := service.NativeCodexConfiguration(ctx) - requireNativeCodexReceiptUnavailable(t, raw, metadata, err) - require.Equal(t, 1, repo.loads) - }) - } -} diff --git a/backend/internal/service/codex_native_runtime.go b/backend/internal/service/codex_native_runtime.go deleted file mode 100644 index 902e8ed60dff..000000000000 --- a/backend/internal/service/codex_native_runtime.go +++ /dev/null @@ -1,280 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "sync" - "sync/atomic" - - "github.com/Wei-Shaw/sub2api/internal/codexruntime/core" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -var ErrNativeCodexPolicyDisabled = errors.New("native Codex policy is disabled for this account") - -type nativeCodexModule interface { - Invoke(context.Context, extensionv1.Invocation) (extensionv1.Result, error) - ApplyConfig(context.Context, json.RawMessage) error - Start(context.Context) error - Stop(context.Context) error -} - -type nativeCodexSnapshot struct { - metadata *NativeCodexMetadata - raw json.RawMessage - module nativeCodexModule - host *nativeCodexHost - ctx context.Context - cancel context.CancelFunc -} - -type NativeCodexRuntime struct { - mu sync.Mutex - root context.Context - started bool - gateway *OpenAIGatewayService - repo NativeCodexRepository - factory NativeCodexConfigFactory - snapshot atomic.Pointer[nativeCodexSnapshot] -} - -var nativeCodexPolicyRuntime atomic.Pointer[NativeCodexRuntime] - -func NewNativeCodexRuntime(gateway *OpenAIGatewayService, repo NativeCodexRepository, factory NativeCodexConfigFactory) *NativeCodexRuntime { - r := &NativeCodexRuntime{gateway: gateway, repo: repo, factory: factory} - if gateway != nil { - gateway.SetNativeCodexRuntime(r) - } - nativeCodexPolicyRuntime.Store(r) - return r -} - -func (s *OpenAIGatewayService) SetNativeCodexRuntime(runtime *NativeCodexRuntime) { - s.nativeCodexRuntime = runtime -} - -// NormalizeNativeCodexConfig returns the normalized runtime configuration. -// Settings of the retired route-qualification feature are dropped, so a stored -// configuration written before its removal still loads. -func NormalizeNativeCodexConfig(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { - return core.NewModule().ValidateConfig(ctx, raw) -} - -func (r *NativeCodexRuntime) Start(ctx context.Context) error { - r.mu.Lock() - defer r.mu.Unlock() - if r.started { - return nil - } - r.root = ctx - if err := r.reloadLocked(ctx); err != nil { - return err - } - r.started = true - return nil -} - -func (r *NativeCodexRuntime) Reload(ctx context.Context) error { - r.mu.Lock() - defer r.mu.Unlock() - if !r.started { - return ErrNativeCodexRuntimeUnavailable - } - return r.reloadLocked(ctx) -} - -// UpdateConfig is the sole native settings write path: validate/encrypt first, -// drain the old epoch, then persist source/hash/generation in one transaction. -func (r *NativeCodexRuntime) UpdateConfig(ctx context.Context, raw json.RawMessage, encryptor SecretEncryptor) error { - raw, err := NormalizeNativeCodexConfig(ctx, raw) - if err != nil { - return err - } - if encryptor == nil { - return ErrNativeCodexRuntimeUnavailable - } - cipher, err := encryptor.Encrypt(string(raw)) - if err != nil { - return err - } - sum := sha256.Sum256(raw) - hash := hex.EncodeToString(sum[:]) - r.mu.Lock() - defer r.mu.Unlock() - if !r.started || r.repo == nil { - return ErrNativeCodexRuntimeUnavailable - } - previous := r.snapshot.Load() - if previous != nil && previous.ctx.Err() == nil && previous.metadata.ConfigSHA256 == hash { - current, loadErr := r.repo.LoadNativeCodexMetadata(ctx) - if loadErr != nil { - return loadErr - } - if *current == *previous.metadata { - _, saveErr := r.repo.StoreNativeCodexConfig(ctx, cipher, hash) - return saveErr - } - } - if err = r.stopSnapshot(ctx, previous); err != nil { - return err - } - metadata, err := r.repo.StoreNativeCodexConfig(ctx, cipher, hash) - if err != nil { - r.restoreSnapshot(ctx, previous) - return err - } - return r.publishLocked(ctx, raw, metadata) -} - -func (r *NativeCodexRuntime) stopSnapshot(ctx context.Context, snapshot *nativeCodexSnapshot) error { - if snapshot == nil { - return nil - } - snapshot.cancel() - if err := snapshot.module.Stop(ctx); err != nil { - return err - } - return snapshot.host.drain(ctx) -} - -func (r *NativeCodexRuntime) restoreSnapshot(ctx context.Context, previous *nativeCodexSnapshot) { - if previous != nil { - if current, err := r.repo.LoadNativeCodexMetadata(ctx); err == nil && *current == *previous.metadata { - _ = r.publishLocked(ctx, previous.raw, current) - } - } -} - -func (r *NativeCodexRuntime) reloadLocked(ctx context.Context) error { - if r.repo == nil || r.factory == nil { - return ErrNativeCodexRuntimeUnavailable - } - raw, err := r.factory(ctx) - if err != nil { - return err - } - raw, err = NormalizeNativeCodexConfig(ctx, raw) - if err != nil { - return err - } - sum := sha256.Sum256(raw) - hash := hex.EncodeToString(sum[:]) - previous := r.snapshot.Load() - if previous != nil && previous.ctx.Err() == nil && previous.metadata.ConfigSHA256 == hash { - current, loadErr := r.repo.LoadNativeCodexMetadata(ctx) - if loadErr != nil { - return loadErr - } - if *current == *previous.metadata { - return nil - } - } - if previous != nil { - if err = r.stopSnapshot(ctx, previous); err != nil { - return err - } - } - metadata, err := r.repo.SyncNativeCodexConfig(ctx, hash) - if err != nil { - // A failed transaction may resume the old policy only if its persistent fence is unchanged. - r.restoreSnapshot(ctx, previous) - return err - } - return r.publishLocked(ctx, raw, metadata) -} - -func (r *NativeCodexRuntime) publishLocked(ctx context.Context, raw json.RawMessage, metadata *NativeCodexMetadata) error { - epoch, cancel := context.WithCancel(r.root) - module := core.NewModule() - host := &nativeCodexHost{metadata: metadata, repo: r.repo, epoch: epoch} - if err := module.ApplyConfig(ctx, raw); err != nil { - cancel() - return err - } - snapshot := &nativeCodexSnapshot{metadata: metadata, raw: append(json.RawMessage(nil), raw...), module: module, host: host, ctx: epoch, cancel: cancel} - r.snapshot.Store(snapshot) - if err := module.Start(epoch); err != nil { - cancel() - return err - } - return nil -} - -func (r *NativeCodexRuntime) Stop(ctx context.Context) error { - r.mu.Lock() - defer r.mu.Unlock() - r.started = false - if snapshot := r.snapshot.Load(); snapshot != nil { - return r.stopSnapshot(ctx, snapshot) - } - return nil -} - -func (r *NativeCodexRuntime) current() *nativeCodexSnapshot { - if r == nil { - return nil - } - return r.snapshot.Load() -} - -func (r *NativeCodexRuntime) metadata() *NativeCodexMetadata { - if snapshot := r.current(); snapshot != nil { - return snapshot.metadata - } - return nil -} - -func nativeCodexScope(platform, kind string, in extensionv1.Invocation) bool { - if platform != "" && platform != PlatformOpenAI { - return false - } - return in.Capability == extensionv1.CapabilityRecovery || kind == "" || kind == AccountTypeOAuth || kind == AccountTypeSetupToken -} - -func (r *NativeCodexRuntime) Invoke(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if !nativeCodexScope(platform, kind, in) { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - snapshot := r.current() - if snapshot == nil || snapshot.ctx.Err() != nil { - return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable - } - if wanted, ok := NativeCodexExecutionFromContext(ctx); ok && wanted != *snapshot.metadata { - return extensionv1.Result{}, ErrNativeCodexRuntimeChanged - } - if in.AccountID > 0 { - account, err := r.gateway.accountRepo.GetByID(ctx, in.AccountID) - if err != nil || account == nil || account.Platform != PlatformOpenAI || (kind != "" && kind != account.Type) || !nativeCodexScope(account.Platform, account.Type, in) { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - } - bound, release, err := snapshot.host.bind(ctx) - if err != nil { - return extensionv1.Result{}, err - } - defer release() - return snapshot.module.Invoke(bound, in) -} - -var invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - runtime := nativeCodexPolicyRuntime.Load() - if runtime == nil { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - return runtime.Invoke(ctx, platform, kind, in) -} - -var bindNativeCodexContext = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - if !nativeCodexScope(platform, kind, in) { - return nil, nil, ErrNativeCodexPolicyDisabled - } - runtime := nativeCodexPolicyRuntime.Load() - snapshot := runtime.current() - if snapshot == nil { - return nil, nil, ErrNativeCodexPolicyDisabled - } - return snapshot.host.bind(ctx) -} diff --git a/backend/internal/service/codex_native_ws_observation.go b/backend/internal/service/codex_native_ws_observation.go deleted file mode 100644 index c122ee4fa42c..000000000000 --- a/backend/internal/service/codex_native_ws_observation.go +++ /dev/null @@ -1,45 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - - openaiwsv2 "github.com/Wei-Shaw/sub2api/internal/service/openai_ws_v2" - coderws "github.com/coder/websocket" - "github.com/tidwall/gjson" -) - -// observeNativeCodexWS records a native WebSocket handshake or response.create -// frame as the account's last outbound request (transport "ws"). -func (s *OpenAIGatewayService) observeNativeCodexWS(ctx context.Context, account *Account, headers, handshake http.Header, payload []byte) { - if !isCodexCredentialOwner(account) || s.nativeCodexRuntime == nil { - return - } - request, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://chatgpt.com/backend-api/codex/responses", bytes.NewReader(payload)) - request.Header = headers.Clone() - request = request.WithContext(context.WithValue(request.Context(), codexIdentityBodyKey{}, inspectCodexIdentityBody(request))) - request = request.WithContext(context.WithValue(request.Context(), codexWireIngressKey{}, "ws")) - s.observeCodexWire(ctx, account, request, &http.Response{StatusCode: http.StatusSwitchingProtocols, Proto: "HTTP/1.1", Header: handshake}, "ws") -} - -func codexWSMetadataBody(payload map[string]any) []byte { - raw, _ := json.Marshal(map[string]any{"client_metadata": payload["client_metadata"]}) - return raw -} - -type codexObservedNativeFrameConn struct { - openaiwsv2.FrameConn - observe func(context.Context, []byte) -} - -func (connection *codexObservedNativeFrameConn) WriteFrame(ctx context.Context, kind coderws.MessageType, payload []byte) error { - if err := connection.FrameConn.WriteFrame(ctx, kind, payload); err != nil { - return err - } - if kind == coderws.MessageText && gjson.GetBytes(payload, "type").String() == "response.create" { - connection.observe(ctx, payload) - } - return nil -} diff --git a/backend/internal/service/codex_profile_selection.go b/backend/internal/service/codex_profile_selection.go deleted file mode 100644 index c6f7182ee2a3..000000000000 --- a/backend/internal/service/codex_profile_selection.go +++ /dev/null @@ -1,67 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// errCodexIdentityUnavailable reports that an account's Codex identity view or -// profile selection cannot be served; the wrapped text names the reason. -var errCodexIdentityUnavailable = errors.New("codex identity unavailable") - -func codexIdentityUnavailable(format string, args ...any) error { - return fmt.Errorf("%w: %s", errCodexIdentityUnavailable, fmt.Sprintf(format, args...)) -} - -type CodexProfileSelection struct { - Profile string `json:"profile"` - ExpectedRevision time.Time `json:"expected_revision"` -} - -func (s *OpenAIGatewayService) SelectCodexProfile(ctx context.Context, id int64, choice CodexProfileSelection) error { - if choice.Profile != "captured_windows_cli" && choice.Profile != "keep_legacy" { - return codexIdentityUnavailable("unknown profile %q (expected captured_windows_cli or keep_legacy)", choice.Profile) - } - account, err := s.accountRepo.GetByID(ctx, id) - switch { - case err != nil: - return codexIdentityUnavailable("read account %d: %v", id, err) - case !isCodexCredentialOwner(account): - return codexIdentityUnavailable("account %d is not an OpenAI OAuth/setup-token Codex account", id) - } - if choice.Profile == "keep_legacy" { - return nil - } - if choice.ExpectedRevision.IsZero() || !choice.ExpectedRevision.Equal(account.UpdatedAt) { - return fmt.Errorf("%w: account revision is %s, the request expected %s", ErrPluginStateChanged, account.UpdatedAt.UTC().Format(time.RFC3339Nano), choice.ExpectedRevision.UTC().Format(time.RFC3339Nano)) - } - seed, valid := codexFingerprintSeed(account.Extra) - if !valid { - return codexIdentityUnavailable("account %d has no valid Codex device seed", id) - } - result, err := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.derive", extensionv1.CodexIdentityQuery{Seed: seed, Preset: choice.Profile}) - if err != nil { - return codexIdentityUnavailable("derive profile %s: %v", choice.Profile, err) - } - if !result.Valid || result.Profile.Version != 2 { - return codexIdentityUnavailable("derived profile %s is not usable (valid=%t, version=%d)", choice.Profile, result.Valid, result.Profile.Version) - } - writer, ok := s.accountRepo.(interface { - UpdateExtraIfRevision(context.Context, int64, time.Time, map[string]any) (bool, error) - }) - if !ok { - return codexIdentityUnavailable("account repository cannot update extra by revision") - } - applied, err := writer.UpdateExtraIfRevision(ctx, id, choice.ExpectedRevision, map[string]any{CodexClientIdentityExtraKey: codexClientIdentityExtraValue(codexClientIdentity(result.Profile), time.Now())}) - if err != nil { - return err - } - if !applied { - return fmt.Errorf("%w: the account changed while the profile was applied", ErrPluginStateChanged) - } - return nil -} diff --git a/backend/internal/service/codex_quality_composition_test.go b/backend/internal/service/codex_quality_composition_test.go deleted file mode 100644 index 274c9db7c57e..000000000000 --- a/backend/internal/service/codex_quality_composition_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -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: &NativeCodexMetadata{}} - 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(), - accountID: run.AccountID, requestModel: run.Model, effort: run.ReasoningEffort, - 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 - // ordinary send path 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 := run.Model - 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) - forwarded, err := io.ReadAll(response.Body) - require.ErrorIs(t, err, ErrCodexModelMismatch) - 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) - 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, run.Model, *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 TestCodexQualityComposedBusinessLimits(t *testing.T) { - svc := &OpenAIGatewayService{cfg: &config.Config{}} - svc.cfg.Gateway.MaxLineSize = 256 << 10 - svc.cfg.Gateway.UpstreamResponseReadMaxBytes = 256 << 10 - request := httptest.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) - terminal := `{"type":"response.completed","response":{"status":"completed","model":"gpt-6-astra","output":[{"text":"` + strings.Repeat("x", 180<<10) + `"}]}}` - for _, contentType := range []string{"text/event-stream", "application/json"} { - t.Run(contentType, func(t *testing.T) { - raw := terminal - if contentType == "text/event-stream" { - raw = "data: " + terminal + "\r\n\r\n" - } - response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(raw))} - body := svc.newCodexQualityObservedBody(response, CodexQualityAttempt{Stage: codexQualityStage, HTTPStatus: http.StatusOK}) - var observed CodexQualityAttempt - body.finish = func(value CodexQualityAttempt) { observed = value } - response.Body = body - reader := svc.newCodexModelGuardBody(request, response, "gpt-6-astra") - forwarded, err := io.ReadAll(reader) - require.NoError(t, err) - require.NoError(t, reader.Close()) - require.Equal(t, raw, string(forwarded)) - require.True(t, observed.Completed, "a 180 KiB frame stays within the ordinary business limits") - }) - } -} diff --git a/backend/internal/service/codex_quality_contract_test.go b/backend/internal/service/codex_quality_contract_test.go deleted file mode 100644 index 0db427503785..000000000000 --- a/backend/internal/service/codex_quality_contract_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package service - -import ( - "bytes" - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - "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 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()) - }) - } -} diff --git a/backend/internal/service/codex_quality_health.go b/backend/internal/service/codex_quality_health.go deleted file mode 100644 index bae2f3b1d281..000000000000 --- a/backend/internal/service/codex_quality_health.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" -) - -// Diagnostics never clear an ordinary breaker or borrow its half-open probe. -// The production adapter implements this with a read-only Redis command. -type codexQualityBreakerReader interface { - CodexQualityRuntimeBlocked(context.Context, int64, []string) (bool, error) -} - -func (s *OpenAIGatewayService) codexQualityHealthBlocked(ctx context.Context, account *Account, model string) bool { - return s.codexQualityHealthBlock(ctx, account, model) != "" -} - -// codexQualityHealthBlock names the ordinary health state that blocks a -// diagnostic send of model, or returns "" when none does. It only reads that -// state. -func (s *OpenAIGatewayService) codexQualityHealthBlock(ctx context.Context, account *Account, model string) string { - now := time.Now() - if value, exists := s.openaiAccountRuntimeBlockUntil.Load(account.ID); exists { - until, valid := value.(time.Time) - switch { - case !valid: - return fmt.Sprintf("account runtime block holds an unreadable value %v", value) - case until.IsZero(): - return "account runtime block has no end time" - case now.Before(until): - return "account runtime block until " + until.UTC().Format(time.RFC3339) - } - } - model = openAIAccountModelTransientModel(model) - state := s.getOpenAIAccountModelTransientState() - state.mu.Lock() - entry := state.entries[openAIAccountModelKey{AccountID: account.ID, Model: model}] - state.mu.Unlock() - if now.Before(entry.blockUntil) { - return fmt.Sprintf("model %s transient block until %s (failure streak %d)", model, entry.blockUntil.UTC().Format(time.RFC3339), entry.failureStreak) - } - if proxyID, exists := openAIProxyStreamCircuitProxyID(account); exists { - circuit := s.getOpenAIProxyStreamCircuit() - circuit.mu.Lock() - disabled := circuit.settings.disabled - blockedUntil := circuit.entries[proxyID].blockedUntil - circuit.mu.Unlock() - if !disabled && now.Before(blockedUntil) { - return fmt.Sprintf("proxy %d stream circuit is open until %s", proxyID, blockedUntil.UTC().Format(time.RFC3339)) - } - } - if s.cache != nil { - reader, ok := s.cache.(codexQualityBreakerReader) - if !ok { - return "the runtime breaker state cannot be read without claiming a probe" - } - blocked, err := reader.CodexQualityRuntimeBlocked(ctx, account.ID, []string{"", model}) - if err != nil { - return "read the runtime breaker state: " + err.Error() - } - if blocked { - return fmt.Sprintf("the runtime breaker of account %d is open or half-open for %s", account.ID, model) - } - } - if s.rateLimitService != nil && s.rateLimitService.settingService != nil { - thresholds := s.rateLimitService.settingService.GetAccountSchedulingThresholds(ctx) - decision := EvaluateAccountSchedulingThreshold(account, thresholds, now) - if decision.ShouldPause && decision.Until != nil && now.Before(*decision.Until) { - return fmt.Sprintf("scheduling threshold pause until %s (%s %s window %s: %.1f%% used, threshold %d%%)", decision.Until.UTC().Format(time.RFC3339), decision.Platform, decision.Scope, decision.Window, decision.UsedPercent, decision.ThresholdPercent) - } - } - return "" -} diff --git a/backend/internal/service/codex_quality_manual_stop_test.go b/backend/internal/service/codex_quality_manual_stop_test.go deleted file mode 100644 index beba0d8cb075..000000000000 --- a/backend/internal/service/codex_quality_manual_stop_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "context" - "net/http" - "strings" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -type qualityManualStopConcurrency struct { - ConcurrencyCache - acquisitions int -} - -func (c *qualityManualStopConcurrency) AcquireAccountSlot(context.Context, int64, int, string) (bool, error) { - c.acquisitions++ - return true, nil -} - -func (*qualityManualStopConcurrency) ReleaseAccountSlot(context.Context, int64, string) error { - return nil -} - -func TestCodexQualityManualStopRequiredBeforeCreateAndSend(t *testing.T) { - ctx := context.Background() - s, account, store, key := qualityCreateFixture() - slots := &qualityManualStopConcurrency{} - s.concurrencyService = &ConcurrencyService{cache: slots} - lookup := func(context.Context, int64) (*APIKey, error) { return key, nil } - request := CodexQualityCreateRequest{RunID: uuid.NewString(), APIKeyID: key.ID, PromptSHA256: codexQualityHash("question"), Model: "gpt-6-astra", ReasoningEffort: "high", MaxSends: 6, TTLSeconds: 7200} - created, err := s.CreateCodexQualityRun(ctx, key.UserID, account.ID, key, request) - require.NoError(t, err, "the stopped account remains eligible for its bounded diagnostic run") - require.NotEmpty(t, created.Grant) - require.False(t, account.Schedulable, "the eligibility copy must not enable the persisted account") - run, revision, err := readCodexQualityRun(ctx, store, request.RunID) - require.NoError(t, err) - // Any administrator reads the run; only its creator closes it. - view, err := s.ReadCodexQualityRun(ctx, key.UserID+1, account.ID, request.RunID) - require.NoError(t, err) - require.Equal(t, key.UserID, view.ActorID) - _, err = s.CloseCodexQualityRun(ctx, key.UserID+1, account.ID, request.RunID) - require.ErrorContains(t, err, "only its creator can close it") - - // Simulate an administrator turning ordinary scheduling back on while the - // existing diagnostic run and its downstream grant remain unexpired. - account.Schedulable = true - otherRequest := request - otherRequest.RunID = uuid.NewString() - _, err = s.CreateCodexQualityRun(ctx, key.UserID, account.ID, key, otherRequest) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - missing, missingRevision, err := readCodexQualityRun(ctx, store, otherRequest.RunID) - require.NoError(t, err) - require.Empty(t, missing.RunID) - require.Zero(t, missingRevision, "a scheduled account must not obtain a new run or grant") - - runtime, err := s.codexQualityRuntime() - require.NoError(t, err) - execution := &codexQualityExecution{runtime: runtime, runID: run.RunID, grantDigest: run.GrantDigest, accountID: account.ID, trialID: uuid.NewString(), keyLookup: lookup} - sendCtx := context.WithValue(ctx, codexQualityExecutionKey{}, execution) - _, err = s.SelectCodexQualityAccount(sendCtx) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - require.Zero(t, slots.acquisitions, "the private selection rejects before taking a concurrency slot") - req, err := http.NewRequestWithContext(sendCtx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"model":"gpt-6-astra","reasoning":{"effort":"high"}}`)) - require.NoError(t, err) - require.ErrorIs(t, reserveCodexQualitySend(req, account), ErrCodexQualityUnavailable) - saved, savedRevision, err := readCodexQualityRun(ctx, store, run.RunID) - require.NoError(t, err) - require.Equal(t, revision, savedRevision) - require.Zero(t, saved.UsedSends) - require.Empty(t, saved.Attempts) - require.True(t, account.Schedulable, "rejection must not flip the administrator's setting back off") -} diff --git a/backend/internal/service/codex_quality_observation.go b/backend/internal/service/codex_quality_observation.go deleted file mode 100644 index be7ef4b1b501..000000000000 --- a/backend/internal/service/codex_quality_observation.go +++ /dev/null @@ -1,408 +0,0 @@ -package service - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "slices" - "strings" - "sync" - "unicode/utf8" - - "github.com/klauspost/compress/zstd" - "github.com/tidwall/gjson" -) - -func codexQualityWireFields(req *http.Request) (string, string, error) { - if req == nil { - return "", "", codexQualityUnavailable("no outbound request") - } - // Reasoning recovery deliberately clears GetBody to prevent transparent POST - // replay. Inspect the final encoded body without restoring that capability. - body := req.Body - independent := req.GetBody != nil - if independent { - var err error - body, err = req.GetBody() - if err != nil { - if body != nil { - _ = body.Close() - } - return "", "", codexQualityUnavailable("copy the outbound body: %v", err) - } - } - if body == nil { - return "", "", codexQualityUnavailable("the outbound request has no body") - } - wire, readErr := io.ReadAll(io.LimitReader(body, (2<<20)+1)) - closeErr := body.Close() - if readErr != nil || closeErr != nil || len(wire) > 2<<20 { - if !independent { - // The original close chain has run. Neither a consumed prefix nor an - // unread suffix may become a later request after a failed snapshot. - req.Body = io.NopCloser(codexQualityRejectedWireBody{}) - } - switch { - case readErr != nil: - return "", "", codexQualityUnavailable("read the outbound body: %v", readErr) - case closeErr != nil: - return "", "", codexQualityUnavailable("close the outbound body: %v", closeErr) - } - return "", "", codexQualityUnavailable("the outbound body exceeds 2 MiB") - } - if !independent { - req.Body = io.NopCloser(bytes.NewReader(wire)) - } - var reader io.Reader = bytes.NewReader(wire) - if strings.EqualFold(req.Header.Get("Content-Encoding"), "zstd") { - decoder, err := zstd.NewReader(reader, zstd.WithDecoderMaxMemory(8<<20), zstd.WithDecoderConcurrency(1)) - if err != nil { - return "", "", codexQualityUnavailable("zstd decoder: %v", err) - } - defer decoder.Close() - reader = decoder - } - raw, err := io.ReadAll(io.LimitReader(reader, (1<<20)+1)) - switch { - case err != nil: - return "", "", codexQualityUnavailable("decode the outbound body: %v", err) - case len(raw) > 1<<20: - return "", "", codexQualityUnavailable("the decoded outbound body exceeds 1 MiB") - case !gjson.ValidBytes(raw): - return "", "", codexQualityUnavailable("the outbound body is not valid JSON") - } - return gjson.GetBytes(raw, "model").String(), gjson.GetBytes(raw, "reasoning.effort").String(), nil -} - -type codexQualityRejectedWireBody struct{} - -func (codexQualityRejectedWireBody) Read([]byte) (int, error) { - return 0, ErrCodexQualityUnavailable -} - -// reserveCodexQualitySend runs on the account's ordinary send path after the -// final wire request exists and immediately before upstream IO. It rechecks -// the run and the account, requires the final body to carry the bound model -// and reasoning effort, and then spends one send of the persistent budget. -func reserveCodexQualitySend(req *http.Request, account *Account) error { - e := codexQualityExecutionFromContext(req.Context()) - if e == nil { - return nil - } - if account == nil || account.ID != e.accountID { - return codexQualityUnavailable("the outbound account is not account %d of the quality run", e.accountID) - } - run, err := e.current(req.Context()) - if err != nil { - return err - } - if _, err = e.runtime.account(req.Context(), run); err != nil { - return err - } - model, effort, err := codexQualityWireFields(req) - if err != nil { - return err - } - boundModel, boundEffort := run.binding() - if model != boundModel || effort != boundEffort { - return codexQualityUnavailable("the outbound body asks for model %q effort %q; quality run %s sends %s with %s", qualityRecordedModel(model), qualityRecordedModel(effort), run.RunID, boundModel, boundEffort) - } - e.requestModel, e.effort = model, effort - return reserveCodexQualityAttempt(e.runtime.ctx(req.Context()), e.runtime.store, e.runID, e.grantDigest, e.attempt()) -} - -func (e *codexQualityExecution) attempt() CodexQualityAttempt { - return CodexQualityAttempt{Stage: codexQualityStage, TrialID: e.trialID, AccountID: e.accountID, RequestModel: e.requestModel, ReasoningEffort: e.effort, ResponseModels: []string{}, HeaderModels: []string{}} -} - -// Read the original response into the diagnostic observer before the model -// 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) { - observeCodexQualityResponse(request.Context(), response, err) - e := codexQualityExecutionFromContext(request.Context()) - if e == nil || err != nil || response == nil || response.Body == nil { - return - } - response.Body = s.newCodexModelGuardBody(request, response, e.requestModel) -} - -func observeCodexQualityResponse(ctx context.Context, response *http.Response, err error) { - e := codexQualityExecutionFromContext(ctx) - if e == nil { - return - } - a := e.attempt() - if err != nil || response == nil { - a.State, a.ErrorCode = "unknown", "transport" - if err != nil { - a.ErrorMessage = err.Error() - } else { - a.ErrorMessage = "the upstream returned no response" - } - finishCodexQualityAttempt(e.runtime.ctx(ctx), e.runtime.store, e.runID, a) - return - } - a.HTTPStatus = response.StatusCode - a.RequestID, a.CFRay = response.Header.Get("x-request-id"), response.Header.Get("cf-ray") - 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 := e.runtime.s.newCodexQualityObservedBody(response, a) - observer.finish = func(a CodexQualityAttempt) { - finishCodexQualityAttempt(e.runtime.ctx(ctx), e.runtime.store, e.runID, a) - } - response.Body = observer -} - -// codexQualityDistinctModels bounds how many distinct declared models one -// attempt keeps; codexQualityModelNameLimit bounds one declared name. -const ( - codexQualityDistinctModels = 32 - codexQualityModelNameLimit = 256 -) - -// qualityRecordedModel keeps the upstream model declaration verbatim (only -// surrounding whitespace is trimmed); detecting a replaced model needs the name. -// A name over the limit is cut on a rune boundary and marked with its length. -func qualityRecordedModel(model string) string { - model = strings.TrimSpace(model) - if len(model) <= codexQualityModelNameLimit { - return model - } - cut := codexQualityModelNameLimit - for cut > 0 && !utf8.RuneStart(model[cut]) { - cut-- - } - return fmt.Sprintf("%s…(truncated, %d bytes)", model[:cut], len(model)) -} - -func recordCodexQualityHeaderModel(attempt *CodexQualityAttempt, value string) { - model := qualityRecordedModel(value) - if model == "" { - return - } - if !slices.Contains(attempt.HeaderModels, model) && len(attempt.HeaderModels) < codexQualityDistinctModels { - attempt.HeaderModels = append(attempt.HeaderModels, model) - } - if len(attempt.HeaderModels) == 1 { - attempt.HeaderModel = &model - } else { - conflict := "conflicting_models" - attempt.HeaderModel = &conflict - } -} - -// A bounded single-frame observer; it never buffers the whole response and -// always forwards the original bytes without editing model fields or content. -// One SSE event is limited by Gateway.MaxLineSize and a JSON body by -// Gateway.UpstreamResponseReadMaxBytes, the limits of ordinary forwarding. -func (s *OpenAIGatewayService) newCodexQualityObservedBody(response *http.Response, attempt CodexQualityAttempt) *codexQualityObservedBody { - 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: s.codexModelGuardEventLimit(), maxJSONBytes: resolveUpstreamResponseReadLimit(s.cfg), - } -} - -type codexQualityObservedBody struct { - io.ReadCloser - detail string - sse, sniff, failed, oversized bool - pending []byte - scanFrom, maxEventBytes int - maxJSONBytes 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) - b.mu.Lock() - if n > 0 && !b.done { - b.feed(p[:n]) - } - b.mu.Unlock() - if err != nil { - b.complete(err) - } - return n, err -} - -func (b *codexQualityObservedBody) feed(raw []byte) { - if b.oversized { - return - } - b.pending = append(b.pending, raw...) - 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 = codexBodyReadLimit - } - if b.sse { - 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 - } - if int64(i) > limit { - b.oversized, b.failed, b.pending = true, true, nil - b.detail = fmt.Sprintf("an SSE event of %d bytes exceeded the %d-byte limit", i, limit) - 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(bytes.Join(data, []byte{'\n'})) - b.pending = b.pending[next+1:] - i = -1 - } - b.scanFrom = max(0, len(b.pending)-2) - } - if int64(len(b.pending)) > limit { - b.detail = fmt.Sprintf("an unterminated event of %d bytes exceeded the %d-byte limit", len(b.pending), limit) - b.oversized, b.failed, b.pending = true, true, nil - } -} - -// recordUpstream keeps the first upstream failure: its error object and the -// failing event or body (bounded). -func (b *codexQualityObservedBody) recordUpstream(found codexUpstreamError, raw []byte) { - a := &b.attempt - if a.UpstreamBody != "" || a.UpstreamErrorType != "" || a.UpstreamErrorCode != "" || a.UpstreamErrorMessage != "" { - return - } - a.UpstreamErrorType, a.UpstreamErrorCode, a.UpstreamErrorMessage, a.UpstreamErrorParam = found.Type, found.Code, found.Message, found.Param - a.UpstreamBody = string(boundedCodexUpstreamBody(raw)) -} - -func (b *codexQualityObservedBody) event(raw []byte) { - if !gjson.ValidBytes(raw) { - return - } - kind := gjson.GetBytes(raw, "type").String() - root := gjson.ParseBytes(raw) - 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() { - r = root - } - model := qualityRecordedModel(r.Get("model").String()) - if model != "" && !slices.Contains(b.attempt.ResponseModels, model) && len(b.attempt.ResponseModels) < codexQualityDistinctModels { - b.attempt.ResponseModels = append(b.attempt.ResponseModels, model) - } - if kind == "response.created" && model != "" { - b.attempt.CreatedModel = &model - } - if kind == "error" || kind == "response.failed" || kind == "response.incomplete" || (root.Get("error").Exists() && root.Get("error").Type != gjson.Null) { - b.failed = true - b.attempt.ErrorCode = "upstream_error" - found := codexUpstreamErrorObject(root.Get("error")) - if found.empty() { - found = codexUpstreamErrorObject(r.Get("error")) - } - if found.empty() && kind == "error" { - found = codexUpstreamError{Code: codexJSONText(root.Get("code")), Message: codexJSONText(root.Get("message")), Param: codexJSONText(root.Get("param"))} - } - if found.empty() && kind == "response.incomplete" { - found = codexUpstreamError{Type: "incomplete", Code: codexJSONText(r.Get("incomplete_details.reason"))} - } - b.recordUpstream(found, raw) - } - if kind == "response.completed" || (root.Get("object").String() == "response" && r.Get("status").String() == "completed") { - if model != "" { - b.attempt.TerminalModel = &model - } - b.attempt.Completed = r.Get("status").String() == "completed" && (!r.Get("error").Exists() || r.Get("error").Type == gjson.Null) - optional := func(path string) *int64 { - value := r.Get(path) - if !value.Exists() || value.Type != gjson.Number || value.Int() < 0 { - return nil - } - n := value.Int() - return &n - } - b.attempt.ReasoningTokens = optional("usage.output_tokens_details.reasoning_tokens") - b.attempt.InputTokens, b.attempt.OutputTokens = optional("usage.input_tokens"), optional("usage.output_tokens") - } -} - -func (b *codexQualityObservedBody) complete(err error) { - b.once.Do(func() { - b.mu.Lock() - if !b.sse && !b.oversized { - b.event(b.pending) - if b.attempt.HTTPStatus >= 400 && len(b.pending) > 0 { - // Non-streaming error bodies may carry {"detail":...} or plain text. - b.recordUpstream(codexUpstreamErrorFromBody(b.pending), b.pending) - } - } - b.attempt.Completed = b.attempt.Completed && !b.failed && b.attempt.HTTPStatus == http.StatusOK - b.attempt.State = "unknown" - if b.attempt.Completed { - b.attempt.State = "complete" - } else if b.failed || b.attempt.HTTPStatus >= 400 { - b.attempt.State, b.attempt.ErrorCode = "error", "upstream_error" - if b.attempt.ErrorMessage == "" { - b.attempt.ErrorMessage = b.detail - } - } else if err != nil && err != io.EOF { - b.attempt.ErrorCode = "stream_error" - b.attempt.ErrorMessage = err.Error() - } else { - b.attempt.ErrorCode = "incomplete" - b.attempt.ErrorMessage = "the response ended without a completed terminal event" - } - b.done, b.pending = true, nil - attempt := b.attempt - b.mu.Unlock() - if b.finish != nil { - b.finish(attempt) - } - }) -} - -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 deleted file mode 100644 index fbd78fe9c14c..000000000000 --- a/backend/internal/service/codex_quality_observation_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestCodexQualityObservationClassifiesStreamFailures(t *testing.T) { - svc := &OpenAIGatewayService{cfg: &config.Config{}} - read := func(body io.ReadCloser) codexResponseCompletion { - response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: body} - guard := svc.newCodexModelGuardBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") - var completed codexResponseCompletion - guard.finish = func(value codexResponseCompletion) { completed = value } - _, _ = io.ReadAll(guard) - require.NoError(t, guard.Close()) - return completed - } - for _, test := range []struct { - name, payload, code string - mismatch bool - }{ - {"capacity", `{"type":"response.failed","response":{"error":{"code":"server_is_overloaded","message":"private upstream text"}}}`, "upstream_capacity", false}, - {"policy", `{"type":"response.failed","response":{"error":{"code":"cyber_policy","message":"private upstream text"}}}`, "upstream_policy", false}, - {"incomplete", `{"type":"response.incomplete","response":{"model":"gpt-6-astra","status":"incomplete"}}`, "incomplete", false}, - {"cancelled", `{"type":"response.cancelled","response":{"status":"cancelled"}}`, "cancelled", false}, - {"mismatch", `{"type":"response.completed","response":{"model":"gpt-6-luna","status":"completed"}}`, "", true}, - {"no_prose_inference", `{"type":"error","error":{"code":"other","message":"server_is_overloaded private upstream text"}}`, "upstream_error", false}, - } { - t.Run(test.name, func(t *testing.T) { - completed := read(io.NopCloser(strings.NewReader("data: " + test.payload + "\n\n"))) - require.Equal(t, test.code, completed.FailureCode) - require.Equal(t, test.mismatch, completed.Mismatch) - // Classification uses explicit codes only, but the upstream's own text - // and the failing event are kept for administrators. - if strings.Contains(test.payload, "private upstream text") { - require.Contains(t, completed.UpstreamError.Message, "private upstream text") - require.JSONEq(t, test.payload, string(completed.UpstreamEvent), "the failure summary keeps the event type and error object") - } - }) - } - cancelled := read(passthroughErrReadCloser{err: context.Canceled}) - require.Equal(t, "cancelled", cancelled.FailureCode) -} - -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.newCodexModelGuardBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") - var completed codexResponseCompletion - body.finish = func(value codexResponseCompletion) { 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) - } -} - -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 = withCodexDownstreamContext(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{}}).newCodexModelGuardBody(wire, response, "gpt-6-astra") - var completed codexResponseCompletion - body.finish = func(value codexResponseCompletion) { 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, "cancelled", completed.FailureCode) -} - -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.newCodexModelGuardBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") - var completed codexResponseCompletion - body.finish = func(value codexResponseCompletion) { completed = value } - data, err := io.ReadAll(body) - require.ErrorIs(t, err, ErrCodexModelMismatch) - if contentType == "text/event-stream" { - require.Empty(t, data) - } - require.True(t, completed.Mismatch) - 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.newCodexModelGuardBody(httptest.NewRequest(http.MethodPost, "/", nil), response, "gpt-6-astra") - data, err := io.ReadAll(body) - require.ErrorIs(t, err, ErrCodexModelMismatch) - 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.newCodexModelGuardBody(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, ErrCodexModelMismatch) - 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_runtime.go b/backend/internal/service/codex_quality_runtime.go deleted file mode 100644 index 8808d3d754d7..000000000000 --- a/backend/internal/service/codex_quality_runtime.go +++ /dev/null @@ -1,513 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "slices" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -type codexQualityRuntime struct { - s *OpenAIGatewayService - store NativeCodexStateStore - installation *NativeCodexMetadata -} - -type codexQualityExecutionKey struct{} -type codexQualityHeadersKey struct{} -type codexQualityHeaders struct { - grant, trial string - present bool -} -type codexQualityExecution struct { - runtime *codexQualityRuntime - runID, grantDigest, trialID string - accountID int64 - requestModel, effort string - keyLookup CodexQualityKeyLookup -} - -type CodexQualityKeyLookup func(context.Context, int64) (*APIKey, error) - -// codexQualityFallbackEfforts are the OpenAI reasoning efforts a run accepts -// when the account test resolver knows no levels for the model. -var codexQualityFallbackEfforts = []string{"none", "minimal", "low", "medium", "high", "xhigh"} - -func codexQualityKeyUsable(key *APIKey, actor, id, group int64) bool { - return key != nil && key.ID == id && key.UserID == actor && key.GroupID != nil && *key.GroupID == group && key.IsActive() && !key.IsExpired() && !key.IsQuotaExhausted() -} - -func codexQualityExecutionFromContext(ctx context.Context) *codexQualityExecution { - if ctx == nil { - return nil - } - e, _ := ctx.Value(codexQualityExecutionKey{}).(*codexQualityExecution) - return e -} - -func IsCodexQualityRequest(ctx context.Context) bool { - return codexQualityExecutionFromContext(ctx) != nil -} - -// Remove the capability before any request copying, forwarding or diagnostics. -func StageCodexQualityHeaders(c *gin.Context) { - if c == nil || c.Request == nil { - return - } - h := codexQualityHeaders{} - grantCount, trialCount := 0, 0 - for key, values := range c.Request.Header { - if strings.EqualFold(key, CodexQualityGrantHeader) || strings.EqualFold(key, CodexQualityTrialHeader) { - h.present = true - value := "" - if len(values) == 1 { - value = values[0] - } - if strings.EqualFold(key, CodexQualityGrantHeader) { - h.grant = value - grantCount += len(values) - } else { - h.trial = value - trialCount += len(values) - } - delete(c.Request.Header, key) - } - } - if grantCount != 1 || trialCount != 1 { - h.grant, h.trial = "", "" - } - if h.present { - c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), codexQualityHeadersKey{}, h)) - } -} - -func (s *OpenAIGatewayService) codexQualityRuntime() (*codexQualityRuntime, error) { - if s == nil || s.nativeCodexRuntime == nil || s.nativeCodexRuntime.repo == nil || s.accountRepo == nil { - return nil, codexQualityUnavailable("native Codex runtime is not configured") - } - repo := s.nativeCodexRuntime.repo - metadata, err := repo.LoadNativeCodexMetadata(context.Background()) - if err != nil { - return nil, codexQualityUnavailable("load Codex runtime metadata: %v", err) - } - return &codexQualityRuntime{s: s, store: repo, installation: metadata}, nil -} - -func (rt *codexQualityRuntime) ctx(ctx context.Context) context.Context { - return WithNativeCodexExecution(ctx, rt.installation) -} - -func qualityProxyID(a *Account) int64 { - if a.ProxyID != nil { - return *a.ProxyID - } - return 0 -} - -// account rechecks the authoritative account row against the run binding, -// including immediately before each send. A diagnostic grant never admits an -// account enabled for ordinary traffic. -func (rt *codexQualityRuntime) account(ctx context.Context, run codexQualityRun) (*Account, error) { - a, err := rt.s.accountRepo.GetByID(ctx, run.AccountID) - model, _ := run.binding() - switch { - case err != nil: - return nil, codexQualityUnavailable("read account %d: %v", run.AccountID, err) - case a == nil || a.ID != run.AccountID: - return nil, codexQualityUnavailable("account %d not found", run.AccountID) - case a.Schedulable: - return nil, codexQualityUnavailable("account %d is schedulable; stop its scheduling manually before a quality diagnosis", a.ID) - case a.Platform != PlatformOpenAI || a.Type != AccountTypeOAuth || a.IsShadow(): - return nil, codexQualityUnavailable("account %d (%s/%s, shadow=%t) is not an OpenAI OAuth account", a.ID, a.Platform, a.Type, a.IsShadow()) - case !slices.Contains(a.GroupIDs, run.GroupID): - return nil, codexQualityUnavailable("account %d is not in group %d of the API key (account groups %v)", a.ID, run.GroupID, a.GroupIDs) - case qualityProxyID(a) != run.ProxyID: - return nil, codexQualityUnavailable("account %d proxy is %d, the run was created with proxy %d", a.ID, qualityProxyID(a), run.ProxyID) - case run.OwnerIdentity == "" || CodexCredentialOwnerIdentity(a) != run.OwnerIdentity: - return nil, codexQualityUnavailable("account %d credential owner changed since the run was created", a.ID) - } - plan := strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) - if plan != "pro" && plan != "chatgpt_pro" { - return nil, codexQualityUnavailable("account %d plan_type is %q; quality diagnosis requires pro", a.ID, plan) - } - // This private stack copy changes only the manual switch for eligibility. - // It is never returned, cached, projected, or persisted. - check := *a - check.Schedulable = true - if problem := rt.s.codexQualityModelProblem(ctx, &check, run.GroupID, model); problem != "" { - return nil, codexQualityUnavailable("%s", problem) - } - if reason := rt.s.codexQualityHealthBlock(ctx, &check, model); reason != "" { - return nil, codexQualityUnavailable("account %d: %s", a.ID, reason) - } - if rt.s.openAIGroupRequiresPrivacySet(ctx, &run.GroupID) && !a.IsPrivacySet() { - return nil, codexQualityUnavailable("group %d requires the privacy setting, which account %d has not set", run.GroupID, a.ID) - } - return a, nil -} - -// codexQualityModelProblem names why the group's channel or the account would -// not send model to the upstream unchanged, or returns "". It reuses the -// scheduler eligibility (account model support), the channel restrictions and -// the channel and account model mappings of ordinary forwarding. -func (s *OpenAIGatewayService) codexQualityModelProblem(ctx context.Context, account *Account, group int64, model string) string { - if s.checkChannelPricingRestriction(ctx, &group, model) { - return fmt.Sprintf("channel pricing of group %d restricts %s", group, model) - } - if mapping, _ := s.ResolveChannelMappingAndRestrict(ctx, &group, model); mapping.Mapped && mapping.MappedModel != model { - return fmt.Sprintf("the channel of group %d maps %s to %s; a quality diagnosis sends the bound model unchanged", group, model, mapping.MappedModel) - } - if s.needsUpstreamChannelRestrictionCheck(ctx, &group) && s.isUpstreamModelRestrictedByChannel(ctx, group, account, model, false) { - return fmt.Sprintf("the upstream channel of group %d restricts %s for account %d", group, model, account.ID) - } - if eligible, reason := openAICompatibleAccountEligibilityBeforeProfit(ctx, account, PlatformOpenAI, model, false, OpenAIEndpointCapabilityResponses); !eligible { - return fmt.Sprintf("account %d is not eligible for %s: %s", account.ID, model, reason) - } - if upstream := resolveOpenAIAccountUpstreamModelForRequest(account, model, false); upstream != model { - return fmt.Sprintf("account %d sends %s upstream as %s; a quality diagnosis sends the bound model unchanged", account.ID, model, upstream) - } - return "" -} - -// codexQualityEffortProblem validates effort with the per-model resolver of the -// account test; when it knows no levels for the model, the OpenAI efforts apply. -func codexQualityEffortProblem(account *Account, model, effort string) string { - levels, _ := AccountTestReasoningOptions(account, model) - if len(levels) == 0 { - if metadata, known := account.GetUpstreamModelMetadata(account.GetMappedModel(model)); known && metadata.Reasoning != nil && !*metadata.Reasoning { - return fmt.Sprintf("model %s takes no reasoning effort on account %d", model, account.ID) - } - levels = codexQualityFallbackEfforts - } - if !slices.Contains(levels, effort) { - return fmt.Sprintf("reasoning_effort %q is not supported for model %s on account %d (supported: %s)", effort, model, account.ID, strings.Join(levels, ", ")) - } - return "" -} - -// codexQualityEffortPolicyProblem names how the reasoning-effort policy of the -// key's group would reject or rewrite the bound effort, or returns "". The -// OpenAI handler applies that policy to OpenAI and composite groups after a -// send is admitted, and a send whose final effort differs from the binding is -// refused, so such a run could never send. -func codexQualityEffortPolicyProblem(group *Group, model, effort string) string { - if group == nil || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { - return "" - } - body, err := json.Marshal(map[string]any{"model": model, "reasoning": map[string]string{"effort": effort}}) - if err != nil { - return fmt.Sprintf("encode the reasoning-effort policy check: %v", err) - } - governed, _, err := ApplyOpenAIReasoningEffortPolicy(body, group.MaxReasoningEffort, group.ReasoningEffortMappings, group.MaxReasoningEffortOverLimit) - if err != nil { - return fmt.Sprintf("the reasoning-effort policy of group %d rejects %s: %v", group.ID, effort, err) - } - if sent := gjson.GetBytes(governed, "reasoning.effort").String(); sent != effort { - return fmt.Sprintf("the reasoning-effort policy of group %d sends %s as %s; a quality diagnosis sends the bound effort unchanged", group.ID, effort, sent) - } - return "" -} - -func (s *OpenAIGatewayService) CreateCodexQualityRun(ctx context.Context, actor, accountID int64, key *APIKey, request CodexQualityCreateRequest) (*CodexQualityRunView, error) { - id, validID := canonicalCodexQualityID(request.RunID) - request.Model = strings.TrimSpace(request.Model) - request.ReasoningEffort = strings.TrimSpace(request.ReasoningEffort) - switch { - case !validID: - return nil, codexQualityUnavailable("run_id %q is not a canonical UUID", request.RunID) - case !validCodexQualityHash(request.PromptSHA256): - return nil, codexQualityUnavailable("prompt_sha256 must be 64 lowercase hex characters") - case request.Model == "": - return nil, codexQualityUnavailable("model is required") - case len(request.Model) > codexQualityModelNameLimit: - // The model guard fails any longer model declaration. - return nil, codexQualityUnavailable("model is %d bytes; at most %d are accepted", len(request.Model), codexQualityModelNameLimit) - case request.ReasoningEffort == "": - return nil, codexQualityUnavailable("reasoning_effort is required") - case actor <= 0: - return nil, codexQualityUnavailable("no authenticated administrator") - case key == nil: - return nil, codexQualityUnavailable("API key %d is not one of this administrator's keys", request.APIKeyID) - case key.GroupID == nil: - return nil, codexQualityUnavailable("API key %d has no group", key.ID) - case !codexQualityKeyUsable(key, actor, request.APIKeyID, *key.GroupID): - return nil, codexQualityUnavailable("API key %d is not usable (status %q, expired %t, quota exhausted %t)", key.ID, key.Status, key.IsExpired(), key.IsQuotaExhausted()) - } - request.RunID = id - if request.MaxSends == 0 { - request.MaxSends = codexQualityMaxSends - } - if request.TTLSeconds == 0 { - request.TTLSeconds = codexQualityMaxTTL - } - if request.MaxSends < 1 || request.MaxSends > codexQualityMaxSends { - return nil, codexQualityUnavailable("max_sends must be 1 to %d, got %d", codexQualityMaxSends, request.MaxSends) - } - if request.TTLSeconds < 60 || request.TTLSeconds > codexQualityMaxTTL { - return nil, codexQualityUnavailable("ttl_seconds must be 60 to %d, got %d", codexQualityMaxTTL, request.TTLSeconds) - } - rt, err := s.codexQualityRuntime() - if err != nil { - return nil, err - } - ctx = rt.ctx(ctx) - a, err := s.accountRepo.GetByID(ctx, accountID) - if err != nil { - return nil, codexQualityUnavailable("read account %d: %v", accountID, err) - } - if a == nil { - return nil, codexQualityUnavailable("account %d not found", accountID) - } - wanted := codexQualityRun{RunID: request.RunID, ActorID: actor, APIKeyID: key.ID, AccountID: accountID, GroupID: *key.GroupID, ProxyID: qualityProxyID(a), OwnerIdentity: CodexCredentialOwnerIdentity(a), Model: request.Model, ReasoningEffort: request.ReasoningEffort, PromptSHA256: request.PromptSHA256, MaxSends: request.MaxSends, Status: "open", Attempts: []CodexQualityAttempt{}} - checked, err := rt.account(ctx, wanted) - if err != nil { - return nil, err - } - if problem := codexQualityEffortProblem(checked, wanted.Model, wanted.ReasoningEffort); problem != "" { - return nil, codexQualityUnavailable("%s", problem) - } - if problem := codexQualityEffortPolicyProblem(key.Group, wanted.Model, wanted.ReasoningEffort); problem != "" { - return nil, codexQualityUnavailable("%s", problem) - } - grant, digest, err := newCodexQualityGrant(request.RunID) - if err != nil { - return nil, err - } - // Reissuing a grant invalidates the old capability; spent entries survive. - run, err := issueCodexQualityGrant(ctx, rt.store, wanted, digest, time.Now().UTC().Add(time.Duration(request.TTLSeconds)*time.Second)) - if err != nil { - return nil, err - } - view := codexQualityView(run) - view.Grant = grant - return view, nil -} - -// ReadCodexQualityRun is readable by every administrator, not only the one -// who created the run (the view names the creator in actor_id). -func (s *OpenAIGatewayService) ReadCodexQualityRun(ctx context.Context, actor, accountID int64, id string) (*CodexQualityRunView, error) { - rt, err := s.codexQualityRuntime() - if err != nil { - return nil, err - } - run, _, err := readCodexQualityRun(rt.ctx(ctx), rt.store, id) - switch { - case err != nil: - return nil, err - case run.RunID == "": - return nil, codexQualityUnavailable("quality run %s not found", id) - case run.AccountID != accountID: - return nil, codexQualityUnavailable("quality run %s belongs to account %d, not %d", run.RunID, run.AccountID, accountID) - } - return codexQualityView(run), nil -} - -// CloseCodexQualityRun revokes the grant and keeps the ledger. -func (s *OpenAIGatewayService) CloseCodexQualityRun(ctx context.Context, actor, accountID int64, id string) (*CodexQualityRunView, error) { - rt, err := s.codexQualityRuntime() - if err != nil { - return nil, err - } - run, err := mutateCodexQualityRun(rt.ctx(ctx), rt.store, id, func(run *codexQualityRun) error { - switch { - case run.RunID == "": - return codexQualityUnavailable("quality run %s not found", id) - case run.ActorID != actor: - return codexQualityUnavailable("quality run %s was created by administrator %d; only its creator can close it", run.RunID, run.ActorID) - case run.AccountID != accountID: - return codexQualityUnavailable("quality run %s belongs to account %d, not %d", run.RunID, run.AccountID, accountID) - } - run.Status, run.GrantDigest = "closed", "" - return nil - }) - if err != nil { - return nil, err - } - return codexQualityView(run), nil -} - -func (s *OpenAIGatewayService) AdmitCodexQualityRequest(c *gin.Context, key *APIKey, body []byte, lookups ...CodexQualityKeyLookup) error { - h, exists := c.Request.Context().Value(codexQualityHeadersKey{}).(codexQualityHeaders) - if !exists { - return nil - } - if len(lookups) != 1 || lookups[0] == nil { - return codexQualityUnavailable("no API key lookup for the quality grant") - } - id, secret, ok := strings.Cut(h.grant, ".") - id, validID := canonicalCodexQualityID(id) - trial, validTrial := canonicalCodexQualityID(h.trial) - switch { - case !ok || len(secret) != 43 || !validID: - return codexQualityUnavailable("the %s header is not a valid grant (exactly one header is required)", CodexQualityGrantHeader) - case !validTrial: - return codexQualityUnavailable("the %s header must be exactly one canonical UUID", CodexQualityTrialHeader) - case c.Request.Method != http.MethodPost || c.Request.URL.Path != "/v1/responses": - return codexQualityUnavailable("quality grants apply only to POST /v1/responses, not %s %s", c.Request.Method, c.Request.URL.Path) - case key == nil || key.GroupID == nil: - return codexQualityUnavailable("the API key has no group") - } - h.trial = trial - var fields map[string]json.RawMessage - if !validCodexQualityJSON(body) { - return codexQualityUnavailable("the request body must be at most 1 MiB of JSON without duplicate keys") - } - if err := json.Unmarshal(body, &fields); err != nil { - return codexQualityUnavailable("decode request body: %v", err) - } - for name := range fields { - if !slices.Contains([]string{"model", "input", "reasoning", "stream", "store", "prompt_cache_key", "client_metadata"}, name) { - return codexQualityUnavailable("request field %q is not allowed in a quality diagnosis", name) - } - } - input, model, effort := gjson.GetBytes(body, "input"), gjson.GetBytes(body, "model"), gjson.GetBytes(body, "reasoning.effort") - if input.Type != gjson.String || model.Type != gjson.String || effort.Type != gjson.String || !gjson.GetBytes(body, "stream").Bool() || gjson.GetBytes(body, "store").Bool() { - return codexQualityUnavailable("a quality diagnosis needs a string input, a model, a reasoning.effort, stream true and store false") - } - rt, err := s.codexQualityRuntime() - if err != nil { - return err - } - run, _, err := readCodexQualityRun(rt.ctx(c.Request.Context()), rt.store, id) - digest := codexQualityHash(h.grant) - boundModel, boundEffort := run.binding() - switch { - case err != nil: - return err - case !codexQualityActive(run, time.Now()): - return codexQualityUnavailable("%s", codexQualityInactiveReason(run, time.Now())) - case run.APIKeyID != key.ID || run.ActorID != key.UserID || run.GroupID != *key.GroupID: - return codexQualityUnavailable("quality run %s belongs to API key %d of user %d in group %d", run.RunID, run.APIKeyID, run.ActorID, run.GroupID) - case run.PromptSHA256 != codexQualityHash(input.String()): - return codexQualityUnavailable("the input does not match the prompt_sha256 of quality run %s", run.RunID) - case !codexQualityGrantMatches(run, digest): - return codexQualityUnavailable("the grant does not match quality run %s (it was reissued)", run.RunID) - case model.String() != boundModel || effort.String() != boundEffort: - return codexQualityUnavailable("quality run %s is bound to model %s with reasoning.effort %s; the request asks for model %q with %q", run.RunID, boundModel, boundEffort, qualityRecordedModel(model.String()), qualityRecordedModel(effort.String())) - } - for _, old := range run.Attempts { - if old.Stage == codexQualityStage && old.TrialID == h.trial { - return fmt.Errorf("%w: trial %s was already sent", ErrCodexQualitySpent, h.trial) - } - } - ctx := ensureOpenAIRuntimeBreakerProbeOwner(c.Request.Context()) - if _, err = rt.account(ctx, run); err != nil { - return err - } - e := &codexQualityExecution{runtime: rt, runID: id, grantDigest: digest, trialID: h.trial, accountID: run.AccountID, keyLookup: lookups[0]} - c.Request = c.Request.WithContext(context.WithValue(ctx, codexQualityExecutionKey{}, e)) - return nil -} - -// Reject duplicate object members: gjson and an upstream JSON parser must not -// disagree about the prompt or effort authorized by the capability. -func validCodexQualityJSON(body []byte) bool { - if len(body) > 1<<20 { - return false - } - decoder := json.NewDecoder(bytes.NewReader(body)) - var value func(int) bool - value = func(depth int) bool { - if depth > 16 { - return false - } - token, err := decoder.Token() - if err != nil { - return false - } - delim, ok := token.(json.Delim) - if !ok { - return true - } - switch delim { - case '{': - seen := map[string]bool{} - for decoder.More() { - key, err := decoder.Token() - name, ok := key.(string) - if err != nil || !ok || seen[name] { - return false - } - seen[name] = true - if !value(depth + 1) { - return false - } - } - case '[': - for decoder.More() { - if !value(depth + 1) { - return false - } - } - default: - return false - } - end, err := decoder.Token() - return err == nil && ((delim == '{' && end == json.Delim('}')) || (delim == '[' && end == json.Delim(']'))) - } - if !value(0) { - return false - } - _, err := decoder.Token() - return err == io.EOF -} - -func (e *codexQualityExecution) current(ctx context.Context) (codexQualityRun, error) { - run, _, err := readCodexQualityRun(e.runtime.ctx(ctx), e.runtime.store, e.runID) - switch { - case err != nil: - return run, err - case !codexQualityActive(run, time.Now()): - return run, codexQualityUnavailable("%s", codexQualityInactiveReason(run, time.Now())) - case run.AccountID != e.accountID: - return run, codexQualityUnavailable("quality run %s belongs to account %d, not %d", run.RunID, run.AccountID, e.accountID) - case e.grantDigest != "" && !codexQualityGrantMatches(run, e.grantDigest): - return run, codexQualityUnavailable("the grant does not match quality run %s (it was reissued)", run.RunID) - case e.keyLookup == nil: - return run, codexQualityUnavailable("no API key lookup for quality run %s", run.RunID) - } - key, err := e.keyLookup(ctx, run.APIKeyID) - if err != nil { - return run, codexQualityUnavailable("look up API key %d: %v", run.APIKeyID, err) - } - if !codexQualityKeyUsable(key, run.ActorID, run.APIKeyID, run.GroupID) { - return run, codexQualityUnavailable("API key %d is no longer usable for quality run %s", run.APIKeyID, run.RunID) - } - return run, nil -} - -// SelectCodexQualityAccount is the private selection of a diagnostic send: -// only the run's account, never the ordinary scheduler and never a fallback. -func (s *OpenAIGatewayService) SelectCodexQualityAccount(ctx context.Context) (*AccountSelectionResult, error) { - e := codexQualityExecutionFromContext(ctx) - if e == nil { - return nil, codexQualityUnavailable("not a quality diagnosis request") - } - run, err := e.current(ctx) - if err != nil { - return nil, err - } - a, err := e.runtime.account(ctx, run) - if err != nil { - return nil, err - } - if s.concurrencyService == nil { - return nil, codexQualityUnavailable("no concurrency service") - } - acquired, err := s.concurrencyService.AcquireAccountSlot(ctx, a.ID, a.Concurrency) - switch { - case err != nil: - return nil, codexQualityUnavailable("acquire a concurrency slot of account %d: %v", a.ID, err) - case acquired == nil || !acquired.Acquired: - return nil, codexQualityUnavailable("account %d has no free concurrency slot (limit %d)", a.ID, a.Concurrency) - } - return attachSelectionProfitGate(ctx, attachSelectionRuntimeBreakerProbe(ctx, &AccountSelectionResult{Account: a, Acquired: true, ReleaseFunc: acquired.ReleaseFunc})), nil -} diff --git a/backend/internal/service/codex_quality_state.go b/backend/internal/service/codex_quality_state.go deleted file mode 100644 index ebf8c6adf399..000000000000 --- a/backend/internal/service/codex_quality_state.go +++ /dev/null @@ -1,373 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/google/uuid" -) - -const ( - CodexQualityGrantHeader = "X-Sub2API-Quality-Grant" - CodexQualityTrialHeader = "X-Sub2API-Quality-Trial" - codexQualityMaxSends = 60 - codexQualityMaxTTL = 7200 - // Every attempt is an ordinary business send. Records written by the - // retired route-qualified diagnostics also hold "acquire" and "verify". - codexQualityStage = "business" - // A record written before runs carried their own model and reasoning - // effort was always bound to these. - codexQualityLegacyModel = "gpt-6-astra" - codexQualityLegacyEffort = "high" -) - -// codexPrivateStateNamespace is the host-private namespace of the native Codex -// runtime state (plugin key NativeCodexPluginKey). Quality-run ledgers and the -// observed final Codex request live there; the stored name is historical. -const codexPrivateStateNamespace = "codex-routing-private" - -// Failures wrap these sentinels with the concrete precondition that failed; -// attempts record upstream error text separately (see CodexQualityAttempt). -var ErrCodexQualityUnavailable = errors.New("codex_quality_unavailable") -var ErrCodexQualitySpent = errors.New("codex_quality_attempt_spent") - -func codexQualityUnavailable(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrCodexQualityUnavailable, fmt.Sprintf(format, args...)) -} - -type CodexQualityCreateRequest struct { - RunID string `json:"run_id"` - APIKeyID int64 `json:"api_key_id"` - PromptSHA256 string `json:"prompt_sha256"` - Model string `json:"model"` - ReasoningEffort string `json:"reasoning_effort"` - MaxSends int `json:"max_sends"` - TTLSeconds int `json:"ttl_seconds"` -} - -type CodexQualityAttempt struct { - Stage string `json:"stage"` - TrialID string `json:"trial_id,omitempty"` - OperationID string `json:"operation_id,omitempty"` - AccountID int64 `json:"account_id"` - State string `json:"state"` - ReservedAt time.Time `json:"reserved_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - RequestModel string `json:"request_model"` - ReasoningEffort string `json:"reasoning_effort"` - ResponseModels []string `json:"response_models"` - CreatedModel *string `json:"created_model"` - TerminalModel *string `json:"terminal_model"` - HeaderModel *string `json:"header_model"` - HeaderModels []string `json:"header_models"` - Completed bool `json:"completed"` - HTTPStatus int `json:"http_status,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - UpstreamErrorType string `json:"upstream_error_type,omitempty"` - UpstreamErrorCode string `json:"upstream_error_code,omitempty"` - UpstreamErrorMessage string `json:"upstream_error_message,omitempty"` - UpstreamErrorParam string `json:"upstream_error_param,omitempty"` - UpstreamBody string `json:"upstream_body,omitempty"` - RequestID string `json:"request_id,omitempty"` - CFRay string `json:"cf_ray,omitempty"` - ReasoningTokens *int64 `json:"reasoning_tokens"` - InputTokens *int64 `json:"input_tokens"` - OutputTokens *int64 `json:"output_tokens"` - // Legacy only: attempts of the retired route-qualified diagnostics name - // their connection and route. New attempts never set them. - ConnectionFingerprint string `json:"connection_fingerprint,omitempty"` - QualificationFingerprint string `json:"qualification_fingerprint,omitempty"` -} - -type CodexQualityRunView struct { - RunID string `json:"run_id"` - Grant string `json:"grant,omitempty"` - ActorID int64 `json:"actor_id"` - AccountID int64 `json:"account_id"` - APIKeyID int64 `json:"api_key_id"` - GroupID int64 `json:"group_id"` - ProxyID int64 `json:"proxy_id"` - Model string `json:"model"` - ReasoningEffort string `json:"reasoning_effort"` - PromptSHA256 string `json:"prompt_sha256"` - Status string `json:"status"` - MaxSends int `json:"max_sends"` - UsedSends int `json:"used_sends"` - ExpiresAt time.Time `json:"expires_at"` - Attempts []CodexQualityAttempt `json:"attempts"` - // Read-only route facts of a record written by the retired - // route-qualified diagnostics; absent from every other run. - RouteGeneration *int64 `json:"route_generation,omitempty"` - RouteExpiresAt *time.Time `json:"route_expires_at,omitempty"` - ConnectionFingerprint string `json:"connection_fingerprint,omitempty"` -} - -// Stored only in the host-private namespace. No grant, API key, prompt, Cookie -// or STATE value is stored in this ledger. Attempts keep the upstream error -// object, transport/stream error text and the bounded failing event or body -// (which can quote response text) for administrators. -type codexQualityRun struct { - RunID string `json:"run_id"` - ActorID int64 `json:"actor_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - ProxyID int64 `json:"proxy_id"` - // OwnerIdentity is CodexCredentialOwnerIdentity of the account when the - // run was created; the credential owner is part of the binding. - OwnerIdentity string `json:"owner_identity,omitempty"` - // Model and ReasoningEffort are bound when the run is created. A record - // without them is legacy (see legacy) and reads as gpt-6-astra/high. - Model string `json:"model,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - PromptSHA256 string `json:"prompt_sha256"` - GrantDigest string `json:"grant_digest"` - Status string `json:"status"` - MaxSends int `json:"max_sends"` - UsedSends int `json:"used_sends"` - ExpiresAt time.Time `json:"expires_at"` - Attempts []CodexQualityAttempt `json:"attempts"` - // Members only a legacy record holds. They are kept verbatim so a - // rewritten record (for example a close) loses none of them, and are - // read only for the view. - Scope json.RawMessage `json:"scope,omitempty"` - RouteGeneration json.RawMessage `json:"route_generation,omitempty"` - RouteRuntimeGeneration json.RawMessage `json:"route_runtime_generation,omitempty"` - Qualification json.RawMessage `json:"qualification,omitempty"` -} - -// legacy reports a record written by the retired route-qualified diagnostics. -// Such a run stays readable and closable but takes no new grant or send. -func (r codexQualityRun) legacy() bool { return r.RunID != "" && r.Model == "" } - -// binding returns the model and reasoning effort every send of the run uses. -func (r codexQualityRun) binding() (string, string) { - if r.legacy() { - return codexQualityLegacyModel, codexQualityLegacyEffort - } - return r.Model, r.ReasoningEffort -} - -func codexQualityKey(id string) string { return "quality-run." + id } - -func canonicalCodexQualityID(text string) (string, bool) { - if len(text) != 36 { - return "", false - } - id, err := uuid.Parse(text) - if err != nil { - return "", false - } - return id.String(), true -} -func codexQualityHash(value string) string { - sum := sha256.Sum256([]byte(value)) - return hex.EncodeToString(sum[:]) -} - -func readCodexQualityRun(ctx context.Context, store NativeCodexStateStore, id string) (codexQualityRun, int64, error) { - var run codexQualityRun - canonical, valid := canonicalCodexQualityID(id) - if !valid { - return run, 0, codexQualityUnavailable("run id %q is not a canonical UUID", id) - } - if store == nil { - return run, 0, codexQualityUnavailable("Codex runtime state store is unavailable") - } - id = canonical - record, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: codexQualityKey(id)}) - if err != nil { - return run, 0, codexQualityUnavailable("read quality run %s: %v", id, err) - } - if !record.Found { - return run, 0, nil - } - if err := json.Unmarshal(record.Value, &run); err != nil { - return codexQualityRun{}, 0, codexQualityUnavailable("decode quality run %s: %v", id, err) - } - if run.RunID != id || run.MaxSends < 1 || run.MaxSends > codexQualityMaxSends || run.UsedSends != len(run.Attempts) || run.UsedSends > run.MaxSends { - return codexQualityRun{}, 0, codexQualityUnavailable("quality run record %s is inconsistent (run_id %q, max_sends %d, used_sends %d, attempts %d)", id, run.RunID, run.MaxSends, run.UsedSends, len(run.Attempts)) - } - return run, record.Revision, nil -} - -func mutateCodexQualityRun(ctx context.Context, store NativeCodexStateStore, id string, change func(*codexQualityRun) error) (codexQualityRun, error) { - canonical, valid := canonicalCodexQualityID(id) - if !valid { - return codexQualityRun{}, codexQualityUnavailable("run id %q is not a canonical UUID", id) - } - id = canonical - for attempt := 0; attempt < 12; attempt++ { - run, revision, err := readCodexQualityRun(ctx, store, id) - if err != nil { - return run, err - } - if err = change(&run); err != nil { - return run, err - } - raw, err := json.Marshal(run) - if err != nil { - return run, codexQualityUnavailable("encode quality run %s: %v", id, err) - } - result, err := store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: codexQualityKey(id), ExpectedRevision: revision, Value: raw}) - if err != nil { - return run, codexQualityUnavailable("save quality run %s: %v", id, err) - } - if result.Applied { - return run, nil - } - } - return codexQualityRun{}, codexQualityUnavailable("quality run %s kept changing concurrently", id) -} - -func newCodexQualityGrant(id string) (string, string, error) { - var secret [32]byte - if _, err := rand.Read(secret[:]); err != nil { - return "", "", codexQualityUnavailable("generate the grant: %v", err) - } - grant := id + "." + base64.RawURLEncoding.EncodeToString(secret[:]) - return grant, codexQualityHash(grant), nil -} - -func codexQualityGrantMatches(run codexQualityRun, digest string) bool { - return len(digest) == 64 && len(run.GrantDigest) == 64 && subtle.ConstantTimeCompare([]byte(digest), []byte(run.GrantDigest)) == 1 -} - -func codexQualityActive(run codexQualityRun, now time.Time) bool { - return codexQualityInactiveReason(run, now) == "" -} - -// codexQualityInactiveReason explains why a run takes no send, or returns "". -func codexQualityInactiveReason(run codexQualityRun, now time.Time) string { - switch { - case run.RunID == "": - return "quality run not found" - case run.legacy(): - return fmt.Sprintf("quality run %s was recorded by the retired route-qualified diagnostics; it is read-only, use a new run id", run.RunID) - case run.Status != "open": - return fmt.Sprintf("quality run %s is %s", run.RunID, run.Status) - case !now.Before(run.ExpiresAt): - return fmt.Sprintf("quality run %s expired at %s", run.RunID, run.ExpiresAt.UTC().Format(time.RFC3339)) - } - return "" -} - -func issueCodexQualityGrant(ctx context.Context, store NativeCodexStateStore, wanted codexQualityRun, digest string, expiry time.Time) (codexQualityRun, error) { - canonical, valid := canonicalCodexQualityID(wanted.RunID) - if !valid { - return codexQualityRun{}, codexQualityUnavailable("run id %q is not a canonical UUID", wanted.RunID) - } - wanted.RunID = canonical - if wanted.legacy() { - return codexQualityRun{}, codexQualityUnavailable("quality run %s names no model", wanted.RunID) - } - return mutateCodexQualityRun(ctx, store, wanted.RunID, func(run *codexQualityRun) error { - if run.RunID == "" { - *run = wanted - } else if run.legacy() { - return codexQualityUnavailable("%s", codexQualityInactiveReason(*run, time.Now())) - } else if run.Status == "closed" { - return codexQualityUnavailable("quality run %s is closed; use a new run id", run.RunID) - } else if run.ActorID != wanted.ActorID || run.APIKeyID != wanted.APIKeyID || run.AccountID != wanted.AccountID || run.GroupID != wanted.GroupID || run.ProxyID != wanted.ProxyID || run.Model != wanted.Model || run.ReasoningEffort != wanted.ReasoningEffort || run.PromptSHA256 != wanted.PromptSHA256 || run.MaxSends != wanted.MaxSends || run.OwnerIdentity != wanted.OwnerIdentity { - return codexQualityUnavailable("quality run %s exists with other parameters (actor %d/%d, api key %d/%d, account %d/%d, group %d/%d, proxy %d/%d, model %s/%s, reasoning_effort %s/%s, max_sends %d/%d, prompt matches %t, credential owner matches %t; stored/requested)", run.RunID, run.ActorID, wanted.ActorID, run.APIKeyID, wanted.APIKeyID, run.AccountID, wanted.AccountID, run.GroupID, wanted.GroupID, run.ProxyID, wanted.ProxyID, run.Model, wanted.Model, run.ReasoningEffort, wanted.ReasoningEffort, run.MaxSends, wanted.MaxSends, run.PromptSHA256 == wanted.PromptSHA256, run.OwnerIdentity == wanted.OwnerIdentity) - } - run.GrantDigest, run.ExpiresAt = digest, expiry - return nil - }) -} - -func sameCodexQualityAttempt(a, b CodexQualityAttempt) bool { - return a.Stage == b.Stage && a.TrialID == b.TrialID && a.OperationID == b.OperationID -} - -func reserveCodexQualityAttempt(ctx context.Context, store NativeCodexStateStore, id, grantDigest string, attempt CodexQualityAttempt) error { - _, err := mutateCodexQualityRun(ctx, store, id, func(run *codexQualityRun) error { - if reason := codexQualityInactiveReason(*run, time.Now()); reason != "" { - return codexQualityUnavailable("%s", reason) - } - if grantDigest != "" && !codexQualityGrantMatches(*run, grantDigest) { - return codexQualityUnavailable("the grant does not match quality run %s (it was reissued)", run.RunID) - } - if run.AccountID != attempt.AccountID { - return codexQualityUnavailable("attempt account %d differs from run account %d", attempt.AccountID, run.AccountID) - } - for _, previous := range run.Attempts { - if sameCodexQualityAttempt(previous, attempt) { - return fmt.Errorf("%w: %s attempt (trial %q, operation %q) was already sent", ErrCodexQualitySpent, attempt.Stage, attempt.TrialID, attempt.OperationID) - } - } - if run.UsedSends >= run.MaxSends { - return fmt.Errorf("%w: %d of %d sends used", ErrCodexQualitySpent, run.UsedSends, run.MaxSends) - } - attempt.State, attempt.ReservedAt = "unknown", time.Now().UTC() - attempt.ResponseModels = []string{} - attempt.HeaderModels = []string{} - run.Attempts = append(run.Attempts, attempt) - run.UsedSends++ - return nil - }) - return err -} - -func finishCodexQualityAttempt(ctx context.Context, store NativeCodexStateStore, id string, finished CodexQualityAttempt) { - ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) - defer cancel() - _, _ = mutateCodexQualityRun(ctx, store, id, func(run *codexQualityRun) error { - for index, old := range run.Attempts { - if sameCodexQualityAttempt(old, finished) { - finished.ReservedAt = old.ReservedAt - now := time.Now().UTC() - finished.FinishedAt = &now - run.Attempts[index] = finished - return nil - } - } - return codexQualityUnavailable("%s attempt (trial %q, operation %q) was never reserved", finished.Stage, finished.TrialID, finished.OperationID) - }) -} - -func codexQualityView(run codexQualityRun) *CodexQualityRunView { - model, effort := run.binding() - v := &CodexQualityRunView{RunID: run.RunID, ActorID: run.ActorID, AccountID: run.AccountID, APIKeyID: run.APIKeyID, GroupID: run.GroupID, ProxyID: run.ProxyID, Model: model, ReasoningEffort: effort, PromptSHA256: run.PromptSHA256, Status: run.Status, MaxSends: run.MaxSends, UsedSends: run.UsedSends, ExpiresAt: run.ExpiresAt, Attempts: run.Attempts} - if v.Attempts == nil { - v.Attempts = []CodexQualityAttempt{} - } - if !run.legacy() { - return v - } - var generation int64 - if json.Unmarshal(run.RouteGeneration, &generation) == nil { - v.RouteGeneration = &generation - } - var route struct { - ExpiresAt time.Time `json:"expires_at"` - Scope struct { - ConnectionLeaseID string `json:"connection_lease_id"` - } `json:"scope"` - } - if len(run.Qualification) > 0 && json.Unmarshal(run.Qualification, &route) == nil { - if !route.ExpiresAt.IsZero() { - v.RouteExpiresAt = &route.ExpiresAt - } - if route.Scope.ConnectionLeaseID != "" { - v.ConnectionFingerprint = codexQualityHash(route.Scope.ConnectionLeaseID)[:16] - } - } - return v -} - -func validCodexQualityHash(value string) bool { - decoded, err := hex.DecodeString(value) - return err == nil && len(decoded) == 32 && strings.ToLower(value) == value -} diff --git a/backend/internal/service/codex_quality_state_test.go b/backend/internal/service/codex_quality_state_test.go deleted file mode 100644 index 701ac3f421db..000000000000 --- a/backend/internal/service/codex_quality_state_test.go +++ /dev/null @@ -1,129 +0,0 @@ -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") - stageCodexLogicalTurn(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")) - stageCodexLogicalTurn(c, []byte(`{"client_metadata":{"turn_id":"two"}}`)) - require.Empty(t, svc.getOpenAICompatSessionTurnState(ctx, c, account, "cache")) - stageCodexLogicalTurn(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) - stageCodexLogicalTurn(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") - stageCodexLogicalTurn(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"}`) - stageCodexLogicalWSTurn(c, []byte(`{"client_metadata":{"turn_id":"tool-turn"}}`)) - svc.noteStagedOpenAICodexTurnStateCommitted(c, account, http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"tool-state"}}) - stageCodexLogicalWSTurn(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)) - stageCodexLogicalWSTurn(c, []byte(`{"input":[{"type":"message","role":"user","content":"new user turn"}]}`)) - require.Empty(t, codexLogicalTurnID(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_test.go b/backend/internal/service/codex_quality_test.go deleted file mode 100644 index 5b41bba2b14e..000000000000 --- a/backend/internal/service/codex_quality_test.go +++ /dev/null @@ -1,438 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -func qualityStateFixture(t *testing.T, limit int) (*nativeCodexMemoryStore, codexQualityRun) { - t.Helper() - store := &nativeCodexMemoryStore{} - run := codexQualityRun{RunID: uuid.NewString(), ActorID: 9, APIKeyID: 101, AccountID: 16380, GroupID: 53, ProxyID: 34, OwnerIdentity: "owner", Model: "gpt-6-astra", ReasoningEffort: "high", PromptSHA256: codexQualityHash("question"), MaxSends: limit, Status: "open"} - _, digest, err := newCodexQualityGrant(run.RunID) - require.NoError(t, err) - run, err = issueCodexQualityGrant(context.Background(), store, run, digest, time.Now().Add(time.Hour)) - require.NoError(t, err) - return store, run -} - -func TestCodexQualityBudgetReplayAndConcurrentCAS(t *testing.T) { - store, run := qualityStateFixture(t, 8) - ctx := context.Background() - first := CodexQualityAttempt{Stage: "business", TrialID: uuid.NewString(), AccountID: run.AccountID} - require.NoError(t, reserveCodexQualityAttempt(ctx, store, run.RunID, run.GrantDigest, first)) - require.ErrorIs(t, reserveCodexQualityAttempt(ctx, store, run.RunID, run.GrantDigest, first), ErrCodexQualitySpent) - var successes atomic.Int32 - var workers sync.WaitGroup - for i := 0; i < 24; i++ { - workers.Add(1) - go func() { - defer workers.Done() - attempt := CodexQualityAttempt{Stage: "business", TrialID: uuid.NewString(), AccountID: run.AccountID} - if reserveCodexQualityAttempt(ctx, store, run.RunID, run.GrantDigest, attempt) == nil { - successes.Add(1) - } - }() - } - workers.Wait() - saved, _, err := readCodexQualityRun(ctx, store, run.RunID) - require.NoError(t, err) - require.Equal(t, int(successes.Load())+1, saved.UsedSends) - require.LessOrEqual(t, saved.UsedSends, 8) - require.Equal(t, saved.UsedSends, len(saved.Attempts)) - for _, a := range saved.Attempts { - require.Equal(t, "unknown", a.State) - } -} - -func TestCodexQualityGrantReissueKeepsBudgetAndBinding(t *testing.T) { - store, run := qualityStateFixture(t, 3) - ctx := context.Background() - for _, trial := range []string{"one", "two", "three"} { - require.NoError(t, reserveCodexQualityAttempt(ctx, store, run.RunID, run.GrantDigest, CodexQualityAttempt{Stage: codexQualityStage, TrialID: trial, AccountID: run.AccountID})) - } - _, digest, err := newCodexQualityGrant(run.RunID) - require.NoError(t, err) - saved, err := issueCodexQualityGrant(ctx, store, run, digest, time.Now().Add(2*time.Hour)) - require.NoError(t, err) - require.Equal(t, 3, saved.UsedSends) - require.False(t, codexQualityGrantMatches(saved, run.GrantDigest)) - require.True(t, codexQualityGrantMatches(saved, digest)) - require.ErrorIs(t, reserveCodexQualityAttempt(ctx, store, run.RunID, digest, CodexQualityAttempt{Stage: codexQualityStage, TrialID: "four", AccountID: run.AccountID}), ErrCodexQualitySpent) - for _, mutate := range []func(*codexQualityRun){func(r *codexQualityRun) { r.ActorID++ }, func(r *codexQualityRun) { r.APIKeyID++ }, func(r *codexQualityRun) { r.AccountID++ }, func(r *codexQualityRun) { r.GroupID++ }, func(r *codexQualityRun) { r.ProxyID++ }, func(r *codexQualityRun) { r.PromptSHA256 = codexQualityHash("changed") }, func(r *codexQualityRun) { r.MaxSends++ }, func(r *codexQualityRun) { r.OwnerIdentity = "different" }, func(r *codexQualityRun) { r.Model = "gpt-6-sol" }, func(r *codexQualityRun) { r.ReasoningEffort = "xhigh" }} { - other := run - mutate(&other) - _, err := issueCodexQualityGrant(ctx, store, other, digest, time.Now().Add(time.Hour)) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - } - _, err = mutateCodexQualityRun(ctx, store, run.RunID, func(r *codexQualityRun) error { r.Status = "closed"; return nil }) - require.NoError(t, err) - _, err = issueCodexQualityGrant(ctx, store, run, digest, time.Now().Add(time.Hour)) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) -} - -func TestCodexQualityRawObserverDoesNotRewriteOrInventUsage(t *testing.T) { - t.Run("event_header_disagrees_with_body", func(t *testing.T) { - raw := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-astra\",\"metadata\":{\"headers\":{\"openai-model\":\"gpt-6-luna\",\"x-openai-model\":[\"gpt-6-luna\"]}}}}\n\n" - var observed CodexQualityAttempt - body := &codexQualityObservedBody{ReadCloser: io.NopCloser(strings.NewReader(raw)), sse: true, attempt: CodexQualityAttempt{HTTPStatus: 200}, finish: func(a CodexQualityAttempt) { observed = a }} - recordCodexQualityHeaderModel(&body.attempt, "gpt-6-astra") - wire, err := io.ReadAll(body) - require.NoError(t, err) - require.Equal(t, raw, string(wire)) - require.Equal(t, []string{"gpt-6-astra"}, observed.ResponseModels) - require.Equal(t, []string{"gpt-6-astra", "gpt-6-luna"}, observed.HeaderModels) - require.Equal(t, "conflicting_models", *observed.HeaderModel) - }) - raw := "data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-6-luna\"}}\n\n" + - "data: {\"type\":\"response.output_text.delta\",\"delta\":\"29\"}\n\n" + - "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-luna\",\"usage\":{\"output_tokens\":0}}}\n\n" - var final CodexQualityAttempt - finishes := 0 - body := &codexQualityObservedBody{ReadCloser: io.NopCloser(strings.NewReader(raw)), sse: true, attempt: CodexQualityAttempt{HTTPStatus: 200}, finish: func(a CodexQualityAttempt) { final = a; finishes++ }} - forwarded, err := io.ReadAll(body) - require.NoError(t, err) - require.NoError(t, body.Close()) - require.Equal(t, raw, string(forwarded)) - require.Equal(t, 1, finishes) - require.True(t, final.Completed) - require.Equal(t, []string{"gpt-6-luna"}, final.ResponseModels) - require.Equal(t, "gpt-6-luna", *final.CreatedModel) - require.Equal(t, "gpt-6-luna", *final.TerminalModel) - require.Nil(t, final.ReasoningTokens) - require.Nil(t, final.InputTokens) - require.NotNil(t, final.OutputTokens) - require.Zero(t, *final.OutputTokens) - require.Equal(t, strings.Repeat("m", 256)+"…(truncated, 300 bytes)", qualityRecordedModel(strings.Repeat("m", 300)), "one declared name is bounded and marked") - failedEvent := `{"type":"response.failed","response":{"model":"o-internal/Model X","error":{"code":"server_error","message":"upstream said no"}}}` - var failed CodexQualityAttempt - odd := &codexQualityObservedBody{ReadCloser: io.NopCloser(strings.NewReader("data: " + failedEvent + "\n\n")), sse: true, attempt: CodexQualityAttempt{HTTPStatus: 200}, finish: func(a CodexQualityAttempt) { failed = a }} - _, err = io.ReadAll(odd) - require.NoError(t, err) - require.Equal(t, []string{"o-internal/Model X"}, failed.ResponseModels, "declared models are recorded verbatim") - require.Equal(t, "upstream_error", failed.ErrorCode) - require.Equal(t, "server_error", failed.UpstreamErrorCode) - require.Equal(t, "upstream said no", failed.UpstreamErrorMessage) - require.Equal(t, failedEvent, failed.UpstreamBody) -} - -func TestCodexQualityStrictJSONAndHeaderScope(t *testing.T) { - require.True(t, validCodexQualityJSON([]byte(`{"model":"gpt-6-astra","input":"question","reasoning":{"effort":"high"}}`))) - for _, body := range []string{`{"input":"question","input":"changed"}`, `{"reasoning":{"effort":"high","effort":"low"}}`, `{} {}`} { - require.False(t, validCodexQualityJSON([]byte(body))) - } - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - StageCodexQualityHeaders(c) - require.NoError(t, (&OpenAIGatewayService{}).AdmitCodexQualityRequest(c, nil, nil), "ordinary requests are unchanged") - c.Request.Header.Set(CodexQualityGrantHeader, "invalid-secret") - c.Request.Header.Set(CodexQualityTrialHeader, uuid.NewString()) - StageCodexQualityHeaders(c) - require.Empty(t, c.Request.Header.Get(CodexQualityGrantHeader)) - require.Empty(t, c.Request.Header.Get(CodexQualityTrialHeader)) - require.ErrorIs(t, (&OpenAIGatewayService{}).AdmitCodexQualityRequest(c, nil, nil), ErrCodexQualityUnavailable) - require.False(t, IsCodexQualityRequest(c.Request.Context())) -} - -func TestCodexQualityLocalBudgetErrorIsNotAccountFailure(t *testing.T) { - ctx := context.WithValue(context.Background(), codexQualityExecutionKey{}, &codexQualityExecution{}) - service := &OpenAIGatewayService{} - for _, err := range []error{ErrCodexQualitySpent, ErrCodexQualityUnavailable} { - require.ErrorIs(t, service.handleOpenAIUpstreamTransportError(ctx, nil, nil, err, false), err) - } -} - -func TestCodexQualityUUIDCaseCannotCreateAnotherBudget(t *testing.T) { - store, run := qualityStateFixture(t, 3) - ctx := context.Background() - trial := "a1b2c3d4-1111-4111-8111-a1b2c3d4e5f6" - canonical, ok := canonicalCodexQualityID(strings.ToUpper(trial)) - require.True(t, ok) - require.Equal(t, trial, canonical) - first := CodexQualityAttempt{Stage: "business", TrialID: trial, AccountID: run.AccountID} - require.NoError(t, reserveCodexQualityAttempt(ctx, store, run.RunID, run.GrantDigest, first)) - first.TrialID = canonical - require.ErrorIs(t, reserveCodexQualityAttempt(ctx, store, strings.ToUpper(run.RunID), run.GrantDigest, first), ErrCodexQualitySpent) - other := run - other.RunID = strings.ToUpper(run.RunID) - saved, err := issueCodexQualityGrant(ctx, store, other, codexQualityHash("new grant"), time.Now().Add(time.Hour)) - require.NoError(t, err) - require.Equal(t, run.RunID, saved.RunID) - require.Equal(t, 1, saved.UsedSends) -} - -type qualityHealthRepository struct { - AccountRepository - errorWrites, cooldownWrites int -} - -func (r *qualityHealthRepository) SetError(context.Context, int64, string) error { - r.errorWrites++ - return nil -} -func (r *qualityHealthRepository) SetTempUnschedulable(context.Context, int64, time.Time, string) error { - r.cooldownWrites++ - return nil -} - -type quality403Counter struct{ resets int } - -func (*quality403Counter) IncrementOpenAI403Count(context.Context, int64, int) (int64, error) { - return 1, nil -} -func (c *quality403Counter) ResetOpenAI403Count(context.Context, int64) error { c.resets++; return nil } - -func TestCodexQualityHTTPFailuresAndSuccessDoNotChangeOrdinaryHealth(t *testing.T) { - repo := &qualityHealthRepository{} - counter := &quality403Counter{} - rateLimit := &RateLimitService{accountRepo: repo, openAI403CounterCache: counter} - s := &OpenAIGatewayService{accountRepo: repo, rateLimitService: rateLimit} - proxy := int64(34) - account := &Account{ID: 16380, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, ProxyID: &proxy} - ctx := context.WithValue(context.Background(), codexQualityExecutionKey{}, &codexQualityExecution{}) - for _, status := range []int{401, 429, 529} { - require.False(t, s.handleOpenAIAccountUpstreamError(ctx, account, status, nil, []byte(`{"error":{"code":"account_deactivated","message":"account deactivated"}}`), "gpt-6-astra")) - } - transportErr := errors.New("connection refused") - require.ErrorIs(t, s.handleOpenAIUpstreamTransportError(ctx, nil, account, transportErr, false), transportErr) - require.False(t, rateLimit.HandleStreamTimeout(ctx, account, "gpt-6-astra")) - s.observeOpenAIUsageAccountHealth(context.Background(), &OpenAIRecordUsageInput{Account: account, CodexQuality: true}) - require.Zero(t, repo.errorWrites) - require.Zero(t, repo.cooldownWrites) - require.Zero(t, counter.resets) - _, blocked := s.openaiAccountRuntimeBlockUntil.Load(account.ID) - require.False(t, blocked) - // The ordinary control retains both its permanent 401 observation and its - // successful-request reset; the diagnostic guard is not a global disable. - require.True(t, s.handleOpenAIAccountUpstreamError(context.Background(), account, 401, nil, []byte(`{"error":{"code":"account_deactivated","message":"account deactivated"}}`), "gpt-6-astra")) - require.Equal(t, 1, repo.errorWrites) - s.observeOpenAIUsageAccountHealth(context.Background(), &OpenAIRecordUsageInput{Account: account}) - require.Equal(t, 1, counter.resets) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - apiAccount := &Account{ID: 16463, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, ProxyID: &proxy} - _ = s.handleOpenAIUpstreamTransportError(WithOpenAIOfficialHTTPFailover(context.Background()), c, apiAccount, transportErr, false) - require.Equal(t, 1, repo.cooldownWrites) - // Proxy disconnect/success isolation is also independent of account health. - circuit := s.getOpenAIProxyStreamCircuit() - circuit.entries[proxy] = openAIProxyStreamCircuitEntry{failureCount: 1, blockedUntil: time.Now().Add(time.Minute)} - before := circuit.entries[proxy] - s.recordOpenAIProxyStreamDisconnect(account, transportErr, "fixture", ctx) - s.clearOpenAIProxyStreamDisconnect(account, ctx) - require.Equal(t, before, circuit.entries[proxy]) -} - -type qualityBreakerReadCache struct { - GatewayCache - reads int - blocked bool -} - -func (c *qualityBreakerReadCache) CodexQualityRuntimeBlocked(context.Context, int64, []string) (bool, error) { - c.reads++ - return c.blocked, nil -} -func (*qualityBreakerReadCache) AllowOpenAIRuntimeBreakerProbe(context.Context, int64, string, string, time.Duration) (bool, error) { - panic("diagnostic must not claim a probe") -} - -func TestCodexQualityHealthReadDoesNotClaimOrClear(t *testing.T) { - cache := &qualityBreakerReadCache{} - s := &OpenAIGatewayService{cache: cache} - account := &Account{ID: 16380, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - expired := time.Now().Add(-time.Minute) - s.openaiAccountRuntimeBlockUntil.Store(account.ID, expired) - state := s.getOpenAIAccountModelTransientState() - key := openAIAccountModelKey{AccountID: account.ID, Model: "gpt-6-astra"} - entry := openAIAccountModelTransientEntry{blockUntil: expired, failureStreak: 2, lastFailure: expired, lastTouched: expired} - state.entries[key] = entry - require.False(t, s.codexQualityHealthBlocked(context.Background(), account, "gpt-6-astra")) - value, exists := s.openaiAccountRuntimeBlockUntil.Load(account.ID) - require.True(t, exists) - require.Equal(t, expired, value) - require.Equal(t, entry, state.entries[key]) - require.Equal(t, 1, cache.reads) - cache.blocked = true - require.True(t, s.codexQualityHealthBlocked(context.Background(), account, "gpt-6-astra"), "ordinary half-open marker stays unavailable to diagnostics") -} - -func TestCodexQualityKeyRevalidationRejectsExpiredQuotaAndDrift(t *testing.T) { - group := int64(53) - key := APIKey{ID: 101, UserID: 9, GroupID: &group, Status: StatusAPIKeyActive} - require.True(t, codexQualityKeyUsable(&key, 9, 101, 53)) - for _, change := range []func(*APIKey){func(k *APIKey) { k.Status = StatusAPIKeyDisabled }, func(k *APIKey) { t := time.Now().Add(-time.Second); k.ExpiresAt = &t }, func(k *APIKey) { k.Quota, k.QuotaUsed = 1, 1 }, func(k *APIKey) { k.UserID++ }, func(k *APIKey) { group := int64(54); k.GroupID = &group }} { - other := key - change(&other) - require.False(t, codexQualityKeyUsable(&other, 9, 101, 53)) - store, run := qualityStateFixture(t, 3) - rt := &codexQualityRuntime{store: store, installation: &NativeCodexMetadata{}} - e := &codexQualityExecution{runtime: rt, runID: run.RunID, grantDigest: run.GrantDigest, accountID: run.AccountID, trialID: uuid.NewString(), keyLookup: func(context.Context, int64) (*APIKey, error) { return &other, nil }} - ctx := context.WithValue(context.Background(), codexQualityExecutionKey{}, e) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"model":"gpt-6-astra","reasoning":{"effort":"high"}}`)) - require.NoError(t, err) - require.ErrorIs(t, reserveCodexQualitySend(req, &Account{ID: run.AccountID}), ErrCodexQualityUnavailable) - saved, _, err := readCodexQualityRun(context.Background(), store, run.RunID) - require.NoError(t, err) - require.Zero(t, saved.UsedSends, "revoked key must fail before the budget is spent") - } -} - -// qualityCreateFixture is a manually stopped Pro OAuth account in group 53 -// behind proxy 34, with an administrator key of that group. -func qualityCreateFixture() (*OpenAIGatewayService, *Account, *nativeCodexMemoryStore, *APIKey) { - group, proxy := int64(53), int64(34) - account := &Account{ID: 16380, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: false, Concurrency: 1, GroupIDs: []int64{group}, ProxyID: &proxy, Credentials: map[string]any{"plan_type": "pro", "chatgpt_account_id": "quality-account"}} - store := &nativeCodexMemoryStore{} - s := &OpenAIGatewayService{nativeCodexRuntime: &NativeCodexRuntime{repo: store}, accountRepo: &codexAccountRepositoryFixture{account: account}} - return s, account, store, &APIKey{ID: 101, UserID: 9, GroupID: &group, Status: StatusAPIKeyActive} -} - -func TestCodexQualityCreateBindsModelAndReasoningEffort(t *testing.T) { - ctx := context.Background() - s, account, store, key := qualityCreateFixture() - request := func(model, effort string) CodexQualityCreateRequest { - return CodexQualityCreateRequest{RunID: uuid.NewString(), APIKeyID: key.ID, PromptSHA256: codexQualityHash("question"), Model: model, ReasoningEffort: effort, MaxSends: 4, TTLSeconds: 600} - } - for _, test := range []struct { - name, model, effort, reason string - mapping map[string]any - group *Group - }{ - {name: "model_required", effort: "high", reason: "model is required"}, - {name: "effort_required", model: "gpt-6-sol", reason: "reasoning_effort is required"}, - {name: "effort_outside_model_levels", model: "gpt-6-sol", effort: "minimal", reason: `reasoning_effort "minimal" is not supported for model gpt-6-sol on account 16380 (supported: low, medium, high, xhigh, max)`}, - {name: "effort_outside_openai_set", model: "gpt-4.1", effort: "max", reason: `reasoning_effort "max" is not supported for model gpt-4.1 on account 16380 (supported: none, minimal, low, medium, high, xhigh)`}, - {name: "model_not_served", model: "gpt-6-sol", effort: "high", reason: "account 16380 is not eligible for gpt-6-sol: model_not_supported", mapping: map[string]any{"gpt-6-astra": "gpt-6-astra"}}, - {name: "model_mapped", model: "gpt-6-sol", effort: "high", reason: "account 16380 sends gpt-6-sol upstream as gpt-6-luna", mapping: map[string]any{"gpt-6-sol": "gpt-6-luna"}}, - {name: "effort_capped_by_group", model: "gpt-6-sol", effort: "xhigh", reason: "the reasoning-effort policy of group 53 sends xhigh as high", group: &Group{ID: 53, Platform: PlatformOpenAI, MaxReasoningEffort: "high"}}, - {name: "effort_denied_by_group", model: "gpt-6-sol", effort: "xhigh", reason: `the reasoning-effort policy of group 53 rejects xhigh: reasoning effort "xhigh" exceeds this group's limit of "high"`, group: &Group{ID: 53, Platform: PlatformOpenAI, MaxReasoningEffort: "high", MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny}}, - } { - t.Run(test.name, func(t *testing.T) { - delete(account.Credentials, "model_mapping") - if test.mapping != nil { - account.Credentials["model_mapping"] = test.mapping - } - key.Group = test.group - rejected := request(test.model, test.effort) - _, err := s.CreateCodexQualityRun(ctx, key.UserID, account.ID, key, rejected) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - require.ErrorContains(t, err, test.reason) - stored, _, err := readCodexQualityRun(ctx, store, rejected.RunID) - require.NoError(t, err) - require.Empty(t, stored.RunID, "a rejected binding stores nothing") - }) - } - delete(account.Credentials, "model_mapping") - // A group policy that leaves the bound effort unchanged does not block it. - key.Group = &Group{ID: 53, Platform: PlatformOpenAI, MaxReasoningEffort: "xhigh"} - accepted := request("gpt-6-sol", "xhigh") - created, err := s.CreateCodexQualityRun(ctx, key.UserID, account.ID, key, accepted) - require.NoError(t, err) - require.NotEmpty(t, created.Grant) - require.Equal(t, "gpt-6-sol", created.Model) - require.Equal(t, "xhigh", created.ReasoningEffort) - require.Nil(t, created.RouteGeneration) - require.Nil(t, created.RouteExpiresAt) - var members map[string]json.RawMessage - require.NoError(t, json.Unmarshal(store.values[codexPrivateStateNamespace+":"+codexQualityKey(accepted.RunID)].Value, &members)) - require.JSONEq(t, `"gpt-6-sol"`, string(members["model"])) - require.JSONEq(t, `"xhigh"`, string(members["reasoning_effort"])) - owner, err := json.Marshal(CodexCredentialOwnerIdentity(account)) - require.NoError(t, err) - require.JSONEq(t, string(owner), string(members["owner_identity"])) - for _, name := range []string{"scope", "qualification", "route_generation", "route_runtime_generation"} { - require.NotContains(t, members, name) - } - // The same run id resumes only with the same binding; a new grant never - // grows the budget. - for _, other := range []CodexQualityCreateRequest{{Model: "gpt-6-astra", ReasoningEffort: "xhigh"}, {Model: "gpt-6-sol", ReasoningEffort: "high"}} { - changed := accepted - changed.Model, changed.ReasoningEffort = other.Model, other.ReasoningEffort - _, err = s.CreateCodexQualityRun(ctx, key.UserID, account.ID, key, changed) - require.ErrorContains(t, err, "exists with other parameters") - } - resumed, err := s.CreateCodexQualityRun(ctx, key.UserID, account.ID, key, accepted) - require.NoError(t, err) - require.NotEqual(t, created.Grant, resumed.Grant) - require.Equal(t, 4, resumed.MaxSends) -} - -// A record written by the retired route-qualified diagnostics: no model or -// effort of its own, a routing scope, a Cookie route qualification and probe -// attempts that name their connection. -const qualityLegacyRecord = `{"run_id":"138bf94a-1bea-4aeb-9e7a-378e9c394bc9","actor_id":9,"api_key_id":101,"account_id":7,"group_id":53,"proxy_id":34,` + - `"scope":{"account_id":7,"identity":"legacy-owner","profile_hash":"legacy-profile","route_hash":"legacy-route","route_evidence":"connection_required","transport":"http","account_proxy_id":34},` + - `"prompt_sha256":"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8","grant_digest":"0b3a8f9e1c2d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7","status":"open","max_sends":60,"used_sends":3,"expires_at":"2099-01-01T00:00:00Z",` + - `"route_generation":2,"route_runtime_generation":5,` + - `"qualification":{"scope":{"account_id":7,"identity":"legacy-owner","profile_hash":"legacy-profile","route_hash":"legacy-route","route_evidence":"connection","connection_lease_id":"legacy-lease","transport":"http"},"bundle":{"key":"bundle.quality.legacy.live","revision":4,"expires_at":"2026-09-23T09:02:00Z","connection_lease_id":"legacy-lease"},"model":"gpt-6-astra","verified_at":"2026-09-23T09:00:00Z","expires_at":"2026-09-23T09:02:00Z"},` + - `"attempts":[` + - `{"stage":"acquire","operation_id":"5d0c4f5e-2b58-4d27-9a43-8a0fd4d2f101","account_id":7,"state":"complete","reserved_at":"2026-09-23T08:59:00Z","request_model":"gpt-6-astra","reasoning_effort":"high","response_models":["gpt-6-astra"],"created_model":null,"terminal_model":"gpt-6-astra","header_model":null,"header_models":[],"completed":true,"reasoning_tokens":null,"input_tokens":null,"output_tokens":null},` + - `{"stage":"verify","operation_id":"5d0c4f5e-2b58-4d27-9a43-8a0fd4d2f101","account_id":7,"state":"complete","reserved_at":"2026-09-23T08:59:30Z","request_model":"gpt-6-astra","reasoning_effort":"high","response_models":["gpt-6-astra"],"created_model":null,"terminal_model":"gpt-6-astra","header_model":null,"header_models":[],"completed":true,"reasoning_tokens":null,"input_tokens":null,"output_tokens":null,"connection_fingerprint":"aaaaaaaaaaaaaaaa"},` + - `{"stage":"business","trial_id":"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d","account_id":7,"state":"complete","reserved_at":"2026-09-23T09:00:10Z","request_model":"gpt-6-astra","reasoning_effort":"high","response_models":["gpt-6-astra"],"created_model":"gpt-6-astra","terminal_model":"gpt-6-astra","header_model":"gpt-6-astra","header_models":["gpt-6-astra"],"completed":true,"reasoning_tokens":12,"input_tokens":30,"output_tokens":40,"connection_fingerprint":"aaaaaaaaaaaaaaaa","qualification_fingerprint":"bbbbbbbbbbbbbbbb"}]}` - -func TestCodexQualityLegacyLedgerReadsAsAstraHighAndRoundTrips(t *testing.T) { - ctx := context.Background() - store := &nativeCodexMemoryStore{values: map[string]extensionv1.StateResult{}} - account := codexOAuthTestAccount(7) - s := &OpenAIGatewayService{nativeCodexRuntime: &NativeCodexRuntime{repo: store}, accountRepo: &codexAccountRepositoryFixture{account: account}} - runID := "138bf94a-1bea-4aeb-9e7a-378e9c394bc9" - key := codexPrivateStateNamespace + ":" + codexQualityKey(runID) - require.True(t, json.Valid([]byte(qualityLegacyRecord))) - store.values[key] = extensionv1.StateResult{Found: true, Revision: 11, Value: json.RawMessage(qualityLegacyRecord)} - - view, err := s.ReadCodexQualityRun(ctx, 10, 7, runID) - require.NoError(t, err, "any administrator reads a legacy record") - require.Equal(t, codexQualityLegacyModel, view.Model) - require.Equal(t, codexQualityLegacyEffort, view.ReasoningEffort) - require.Equal(t, "open", view.Status) - require.Equal(t, 3, view.UsedSends) - require.Len(t, view.Attempts, 3) - require.Equal(t, "verify", view.Attempts[1].Stage) - require.Equal(t, "bbbbbbbbbbbbbbbb", view.Attempts[2].QualificationFingerprint) - require.NotNil(t, view.RouteGeneration) - require.EqualValues(t, 2, *view.RouteGeneration) - require.NotNil(t, view.RouteExpiresAt) - require.Equal(t, "2026-09-23T09:02:00Z", view.RouteExpiresAt.UTC().Format(time.RFC3339)) - require.Equal(t, codexQualityHash("legacy-lease")[:16], view.ConnectionFingerprint) - require.True(t, bytes.Equal([]byte(qualityLegacyRecord), store.values[key].Value), "reading keeps the stored bytes") - require.EqualValues(t, 11, store.values[key].Revision) - - // An unexpired legacy run takes no new grant and no send. - run, _, err := readCodexQualityRun(ctx, store, runID) - require.NoError(t, err) - require.True(t, run.legacy()) - require.False(t, codexQualityActive(run, time.Now())) - _, err = issueCodexQualityGrant(ctx, store, codexQualityRun{RunID: runID, ActorID: 9, APIKeyID: 101, AccountID: 7, GroupID: 53, ProxyID: 34, OwnerIdentity: "legacy-owner", Model: codexQualityLegacyModel, ReasoningEffort: codexQualityLegacyEffort, PromptSHA256: run.PromptSHA256, MaxSends: 60}, codexQualityHash("new grant"), time.Now().Add(time.Hour)) - require.ErrorContains(t, err, "retired route-qualified diagnostics") - require.ErrorIs(t, reserveCodexQualityAttempt(ctx, store, runID, "", CodexQualityAttempt{Stage: codexQualityStage, TrialID: uuid.NewString(), AccountID: 7}), ErrCodexQualityUnavailable) - require.EqualValues(t, 11, store.values[key].Revision, "refusals never rewrite a legacy record") - - // Closing rewrites the record; every legacy-only member survives. - closed, err := s.CloseCodexQualityRun(ctx, 9, 7, runID) - require.NoError(t, err) - require.Equal(t, "closed", closed.Status) - var before, after map[string]json.RawMessage - require.NoError(t, json.Unmarshal([]byte(qualityLegacyRecord), &before)) - require.NoError(t, json.Unmarshal(store.values[key].Value, &after)) - for _, name := range []string{"scope", "qualification", "route_generation", "route_runtime_generation", "attempts", "prompt_sha256", "max_sends", "used_sends"} { - require.JSONEq(t, string(before[name]), string(after[name]), name) - } - require.NotContains(t, after, "model", "a rewritten legacy record does not gain a binding") - require.JSONEq(t, `"closed"`, string(after["status"])) - require.JSONEq(t, `""`, string(after["grant_digest"])) -} diff --git a/backend/internal/service/codex_quality_wire_test.go b/backend/internal/service/codex_quality_wire_test.go deleted file mode 100644 index a84ffdbf7d59..000000000000 --- a/backend/internal/service/codex_quality_wire_test.go +++ /dev/null @@ -1,270 +0,0 @@ -//go:build unit - -package service - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -const qualityWireFixtureBody = `{"model":"gpt-6-sol","reasoning":{"effort":"xhigh"},"input":"question","stream":true,"store":false}` - -// qualityWireUpstream is the account's ordinary transport (HTTPUpstream.Do); -// a send that needed any other transport would panic on the nil embedding. -type qualityWireUpstream struct { - HTTPUpstream - reply string - calls int - request *http.Request - body []byte - accountID int64 - proxyURL string -} - -func (u *qualityWireUpstream) Do(req *http.Request, proxyURL string, accountID int64, _ int) (*http.Response, error) { - u.calls++ - u.request, u.accountID, u.proxyURL = req, accountID, proxyURL - defer func() { _ = req.Body.Close() }() - var err error - u.body, err = io.ReadAll(req.Body) - if err != nil { - return nil, err - } - reply := u.reply - if reply == "" { - reply = "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-sol\"}}\n\n" - } - return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(reply))}, nil -} - -type qualityWireSource struct { - reader io.Reader - readErr error - closeErr error - read int - closed int -} - -func (s *qualityWireSource) Read(p []byte) (int, error) { - n, err := s.reader.Read(p) - s.read += n - if err == io.EOF && s.readErr != nil { - return n, s.readErr - } - return n, err -} - -func (s *qualityWireSource) Close() error { - s.closed++ - return s.closeErr -} - -// Use the real ordinary send path and durable reservation boundary. Only the -// database and network ports are in-memory fixtures; no model call is possible. -func qualityWireServiceFixture(t *testing.T, compressed, recovery bool) (*OpenAIGatewayService, *Account, *nativeCodexMemoryStore, codexQualityRun, *qualityWireUpstream, context.Context, *http.Request) { - t.Helper() - group, proxy := int64(53), int64(34) - account := &Account{ID: 16380, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, - Schedulable: false, Concurrency: 1, GroupIDs: []int64{group}, ProxyID: &proxy, - Credentials: map[string]any{"plan_type": "pro", "chatgpt_account_id": "quality-wire-fixture"}} - if !recovery { - account.Extra = map[string]any{OpenAIReasoningSignatureRecoveryEnabledExtraKey: false} - } - store := &nativeCodexMemoryStore{} - upstream := &qualityWireUpstream{} - svc := &OpenAIGatewayService{cfg: &config.Config{}, nativeCodexRuntime: &NativeCodexRuntime{repo: store}, httpUpstream: upstream, - accountRepo: &codexAccountRepositoryFixture{account: account}} - ctx := withCodexTransportFixture(context.Background(), compressed) - run := codexQualityRun{RunID: uuid.NewString(), ActorID: 9, APIKeyID: 101, AccountID: account.ID, GroupID: group, ProxyID: proxy, - OwnerIdentity: CodexCredentialOwnerIdentity(account), Model: "gpt-6-sol", ReasoningEffort: "xhigh", - PromptSHA256: codexQualityHash("question"), MaxSends: 60, Status: "open"} - _, digest, err := newCodexQualityGrant(run.RunID) - require.NoError(t, err) - run, err = issueCodexQualityGrant(ctx, store, run, digest, time.Now().Add(time.Hour)) - require.NoError(t, err) - runtime, err := svc.codexQualityRuntime() - require.NoError(t, err) - key := &APIKey{ID: run.APIKeyID, UserID: run.ActorID, GroupID: &group, Status: StatusAPIKeyActive} - execution := &codexQualityExecution{runtime: runtime, runID: run.RunID, grantDigest: run.GrantDigest, trialID: uuid.NewString(), - accountID: account.ID, keyLookup: func(context.Context, int64) (*APIKey, error) { return key, nil }} - ctx = context.WithValue(ctx, codexQualityExecutionKey{}, execution) - _, err = runtime.account(ctx, run) - require.NoError(t, err, "the fixture must pass account checks before exercising wire inspection") - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx) - c.Set("api_key", key) - state := svc.newOpenAIReasoningRecoveryState(ctx, c, account, "synthetic-token") - require.Equal(t, recovery, state.enabled, "missing account setting must use the real enabled-by-default recovery policy") - t.Cleanup(state.Close) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(qualityWireFixtureBody)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - req = withCodexExpectedModel(req, "gpt-6-sol") - req, _, err = state.PrepareRequest(req, []byte(qualityWireFixtureBody), "") - require.NoError(t, err) - return svc, account, store, run, upstream, ctx, req -} - -func TestCodexQualityWireRecoveryPreservesBodyAndReplayPolicy(t *testing.T) { - for _, test := range []struct { - name string - compressed, recovery bool - }{ - {"plain_nonreplayable", false, true}, {"zstd_nonreplayable", true, true}, - {"plain_existing_snapshot", false, false}, {"zstd_existing_snapshot", true, false}, - } { - t.Run(test.name, func(t *testing.T) { - svc, account, store, run, upstream, ctx, req := qualityWireServiceFixture(t, test.compressed, test.recovery) - require.Equal(t, test.recovery, req.GetBody == nil) - source := &qualityWireSource{reader: req.Body} - req.Body = source - expected := []byte(qualityWireFixtureBody) - if test.compressed { - var err error - expected, err = compressCodexRequestBodyZstd(expected) - require.NoError(t, err) - } - response, err := svc.doOpenAICodexUpstream(req, account, "socks5://quality-proxy:1080") - require.NoError(t, err) - require.NotNil(t, response) - _, err = io.Copy(io.Discard, response.Body) - require.NoError(t, err) - require.NoError(t, response.Body.Close()) - require.Equal(t, 1, upstream.calls, "one send on the account's ordinary transport") - require.Equal(t, account.ID, upstream.accountID) - require.Equal(t, "socks5://quality-proxy:1080", upstream.proxyURL, "the account's ordinary proxy is used") - require.Empty(t, upstream.request.Header.Get("Cookie"), "a diagnosis carries no routing cookies") - require.Equal(t, expected, upstream.body, "the actual transport receives the exact final wire bytes") - require.Equal(t, int64(len(expected)), upstream.request.ContentLength) - require.Equal(t, test.recovery, upstream.request.GetBody == nil, "inspection must not enable transparent replay") - require.Equal(t, test.recovery, HTTPUpstreamRedirectsDisabled(upstream.request.Context())) - if !test.compressed || test.recovery { - require.Equal(t, 1, source.closed, "the consumed source keeps its close obligation") - } - if test.compressed { - require.Equal(t, "zstd", upstream.request.Header.Get("Content-Encoding")) - } - saved, _, err := readCodexQualityRun(ctx, store, run.RunID) - require.NoError(t, err) - require.Equal(t, 1, saved.UsedSends) - require.Len(t, saved.Attempts, 1) - attempt := saved.Attempts[0] - require.Equal(t, codexQualityStage, attempt.Stage) - require.Equal(t, "gpt-6-sol", attempt.RequestModel) - require.Equal(t, "xhigh", attempt.ReasoningEffort) - require.Equal(t, "complete", attempt.State, "the raw observation records the completed send") - require.Equal(t, "gpt-6-sol", *attempt.TerminalModel) - require.False(t, account.Schedulable) - }) - } -} - -func TestCodexQualityOrdinarySendRecordsRawEvidenceBeforeGuard(t *testing.T) { - svc, account, store, run, upstream, ctx, req := qualityWireServiceFixture(t, false, true) - upstream.reply = "data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-6-luna\"}}\n\n" + - "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-6-luna\"}}\n\n" - response, err := svc.doOpenAICodexUpstream(req, account, "") - require.NoError(t, err) - forwarded, err := io.ReadAll(response.Body) - require.ErrorIs(t, err, ErrCodexModelMismatch) - require.Empty(t, forwarded, "the guard stops a mismatched model before the first downstream byte") - require.NoError(t, response.Body.Close()) - saved, _, err := readCodexQualityRun(ctx, store, run.RunID) - require.NoError(t, err) - require.Equal(t, 1, saved.UsedSends, "the spent send stays counted") - require.Equal(t, "gpt-6-luna", *saved.Attempts[0].CreatedModel, "the raw observer saw the frame the guard rejected") - // The same trial is never sent again on the ordinary path. - retry, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(qualityWireFixtureBody)) - require.NoError(t, err) - _, err = svc.doOpenAICodexUpstream(retry, account, "") - require.ErrorIs(t, err, ErrCodexQualitySpent) - require.Equal(t, 1, upstream.calls) -} - -func TestCodexQualityWireFailuresDoNotReserveOrSend(t *testing.T) { - for _, test := range []struct { - name, body, encoding string - readErr, closeErr error - snapshot bool - getBodyErr error - }{ - {name: "read_error", body: qualityWireFixtureBody[:20], readErr: io.ErrUnexpectedEOF}, - {name: "close_error", body: qualityWireFixtureBody, closeErr: errors.New("synthetic close failure")}, - {name: "wire_limit", body: strings.Repeat("x", (2<<20)+2)}, - {name: "invalid_json", body: qualityWireFixtureBody[:20]}, - {name: "invalid_zstd", body: "invalid zstd frame", encoding: "zstd"}, - {name: "decoded_limit", body: `{"model":"gpt-6-sol","reasoning":{"effort":"xhigh"},"input":"` + strings.Repeat("x", 1<<20) + `"}`, encoding: "compress"}, - {name: "unbound_model", body: `{"model":"gpt-6-astra","reasoning":{"effort":"xhigh"},"input":"question","stream":true,"store":false}`}, - {name: "unbound_effort", body: `{"model":"gpt-6-sol","reasoning":{"effort":"high"},"input":"question","stream":true,"store":false}`}, - {name: "snapshot_read_error", body: qualityWireFixtureBody[:20], readErr: io.ErrUnexpectedEOF, snapshot: true}, - {name: "snapshot_close_error", body: qualityWireFixtureBody, closeErr: errors.New("synthetic close failure"), snapshot: true}, - {name: "snapshot_wire_limit", body: strings.Repeat("x", (2<<20)+2), snapshot: true}, - {name: "snapshot_getbody_error", body: qualityWireFixtureBody, getBodyErr: io.ErrUnexpectedEOF, snapshot: true}, - } { - t.Run(test.name, func(t *testing.T) { - svc, account, store, run, upstream, ctx, req := qualityWireServiceFixture(t, false, !test.snapshot) - body := []byte(test.body) - encoding := test.encoding - if encoding == "compress" { - var err error - body, err = compressCodexRequestBodyZstd(body) - require.NoError(t, err) - encoding = "zstd" - } - source := &qualityWireSource{reader: bytes.NewReader(body), readErr: test.readErr, closeErr: test.closeErr} - if test.snapshot { - req.GetBody = func() (io.ReadCloser, error) { return source, test.getBodyErr } - } else { - req.Body, req.ContentLength = source, int64(len(body)) - } - req.Header.Set("Content-Encoding", encoding) - _, revision, err := readCodexQualityRun(ctx, store, run.RunID) - require.NoError(t, err) - response, err := svc.doOpenAICodexUpstream(req, account, "") - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - require.Nil(t, response) - require.Zero(t, upstream.calls) - require.Equal(t, !test.snapshot, req.GetBody == nil) - require.Equal(t, 1, source.closed) - require.LessOrEqual(t, source.read, (2<<20)+1) - saved, savedRevision, err := readCodexQualityRun(ctx, store, run.RunID) - require.NoError(t, err) - require.Equal(t, revision, savedRevision) - require.Zero(t, saved.UsedSends) - require.Empty(t, saved.Attempts) - require.False(t, account.Schedulable) - }) - } -} - -func TestCodexQualityWireFailedSnapshotCannotReplayPrefix(t *testing.T) { - ctx := WithHTTPUpstreamRedirectsDisabled(context.Background()) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(qualityWireFixtureBody)) - require.NoError(t, err) - req.GetBody = nil - source := &qualityWireSource{reader: strings.NewReader(qualityWireFixtureBody[:20]), readErr: io.ErrUnexpectedEOF} - req.Body = source - length := req.ContentLength - _, _, err = codexQualityWireFields(req) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - remaining, err := io.ReadAll(req.Body) - require.ErrorIs(t, err, ErrCodexQualityUnavailable) - require.Empty(t, remaining) - require.NoError(t, req.Body.Close()) - require.Equal(t, 1, source.closed, "snapshot failure closes the original body once") - require.Equal(t, length, req.ContentLength) - require.Nil(t, req.GetBody) - require.True(t, HTTPUpstreamRedirectsDisabled(req.Context())) -} diff --git a/backend/internal/service/codex_wire_fingerprint.go b/backend/internal/service/codex_wire_fingerprint.go deleted file mode 100644 index 134e91aa64ce..000000000000 --- a/backend/internal/service/codex_wire_fingerprint.go +++ /dev/null @@ -1,147 +0,0 @@ -package service - -import ( - "context" - "crypto/tls" - _ "embed" - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// codexWireIngressKey marks an outbound request that serves a downstream -// WebSocket turn (the HTTP bridge), so its observation reports ingress "ws". -type codexWireIngressKey struct{} - -//go:embed codex_client_reference.json -var codexClientReference json.RawMessage - -// CodexWireFingerprint is the last final outbound request observed for an -// account: identity headers, encoding and transport facts, never credentials -// or the request body. -type CodexWireFingerprint struct { - IdentityFields map[string]CodexIdentityFieldObservation `json:"identity_fields"` - Ingress string `json:"ingress"` - ObservedAt time.Time `json:"observed_at"` - Source string `json:"source"` - Transport string `json:"transport"` - UserAgent string `json:"user_agent"` - Originator string `json:"originator"` - Version string `json:"version"` - HTTPProtocol string `json:"http_protocol"` - RequestEncoding string `json:"request_encoding"` - TLSImplementation string `json:"tls_implementation"` - TLSVersion string `json:"tls_version,omitempty"` - CipherSuite string `json:"cipher_suite,omitempty"` - ALPN string `json:"alpn,omitempty"` - JA3 string `json:"ja3_status"` - AccountProxyID int64 `json:"account_proxy_id,omitempty"` - SessionPresent bool `json:"session_present"` - ThreadPresent bool `json:"thread_present"` - WindowPresent bool `json:"window_present"` - StatePresent bool `json:"state_present"` - StateLength int `json:"state_length"` - State string `json:"state,omitempty"` - WebSocketExtensions string `json:"websocket_extensions,omitempty"` -} - -type CodexFingerprintView struct { - ConfiguredProfile *extensionv1.CodexClientProfile `json:"configured_profile,omitempty"` - CapturedReference json.RawMessage `json:"captured_reference"` - AccountID int64 `json:"account_id"` - Configured codexIdentitySnapshot `json:"configured"` - Observed *CodexWireFingerprint `json:"observed,omitempty"` - ObservedError string `json:"observed_error,omitempty"` - ReferenceCommit string `json:"reference_commit"` - ReferenceSource string `json:"reference_source"` - Alignment string `json:"alignment"` -} - -// observeCodexWire records the final outbound request of an OAuth account -// (transport "http", or "ws" for a native WebSocket) for the fingerprint view. -func (s *OpenAIGatewayService) observeCodexWire(ctx context.Context, account *Account, request *http.Request, response *http.Response, transport string) { - if s == nil || s.nativeCodexRuntime == nil || account == nil || request == nil || response == nil { - return - } - installation := s.nativeCodexRuntime.metadata() - store := s.nativeCodexRuntime.repo - if installation == nil || store == nil { - return - } - value := CodexWireFingerprint{ObservedAt: time.Now().UTC(), Source: "final_outbound", Transport: transport, UserAgent: request.Header.Get("User-Agent"), Originator: request.Header.Get("originator"), Version: request.Header.Get("version"), HTTPProtocol: response.Proto, RequestEncoding: request.Header.Get("Content-Encoding"), TLSImplementation: "go-crypto-tls", JA3: "unknown", SessionPresent: extractClientSessionID(request.Header) != "", ThreadPresent: request.Header.Get("thread-id") != "", WindowPresent: request.Header.Get("x-codex-window-id") != "", StatePresent: request.Header.Get(openAICodexTurnStateHeader) != "", StateLength: len(request.Header.Get(openAICodexTurnStateHeader))} - value.Ingress = "http" - value.State = request.Header.Get(openAICodexTurnStateHeader) - value.AccountProxyID = qualityProxyID(account) - body, carried := request.Context().Value(codexIdentityBodyKey{}).(codexIdentityBodyObservation) - if !carried { - // Only a rewritten (zstd) wire copy carries a pre-encoding observation; - // an unrewritten request still exposes its plaintext replay body. - body = inspectCodexIdentityBody(request) - } - value.IdentityFields = codexIdentityFieldObservations(request.Header, body) - if ingress, ok := request.Context().Value(codexWireIngressKey{}).(string); ok && ingress == "ws" { - value.Ingress = ingress - } - if response.TLS != nil { - value.TLSVersion = tls.VersionName(response.TLS.Version) - value.CipherSuite = tls.CipherSuiteName(response.TLS.CipherSuite) - value.ALPN = response.TLS.NegotiatedProtocol - } - if value.Transport == "ws" { - value.WebSocketExtensions = response.Header.Get("Sec-WebSocket-Extensions") - } - ctx = WithNativeCodexExecution(context.WithoutCancel(ctx), installation) - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - key := "wire." + strconv.FormatInt(account.ID, 10) - previous, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: key}) - if err != nil { - return - } - raw, _ := json.Marshal(value) - _, _ = store.CompareSwapExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: key, ExpectedRevision: previous.Revision, Value: raw}) -} - -// CodexFingerprint is read-only: opening an account never refreshes OAuth, -// pings a proxy, or sends a model request. -func (s *OpenAIGatewayService) CodexFingerprint(ctx context.Context, id int64) (*CodexFingerprintView, error) { - if s.nativeCodexRuntime == nil { - return nil, codexIdentityUnavailable("native Codex runtime is not configured") - } - a, err := s.accountRepo.GetByID(ctx, id) - switch { - case err != nil: - return nil, codexIdentityUnavailable("read account %d: %v", id, err) - case !isCodexCredentialOwner(a): - return nil, codexIdentityUnavailable("account %d is not an OpenAI OAuth/setup-token Codex account", id) - } - installation := s.nativeCodexRuntime.metadata() - store := s.nativeCodexRuntime.repo - if installation == nil || store == nil { - return nil, codexIdentityUnavailable("native Codex runtime is not loaded") - } - view := &CodexFingerprintView{AccountID: id, Configured: resolveCodexIdentitySnapshotContext(ctx, a, a, codexAccountIdentityOverrideUA(a)), ReferenceCommit: "4b664e0ef0397f82e68c60088a90fcd035deb796", ReferenceSource: "official_source"} - wire, err := store.ReadExtensionState(ctx, NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: "wire." + strconv.FormatInt(id, 10)}) - if err != nil { - return nil, fmt.Errorf("read the last outbound observation of account %d: %w", id, err) - } - if wire.Found { - var observation CodexWireFingerprint - if err := json.Unmarshal(wire.Value, &observation); err != nil { - view.ObservedError = "decode the last outbound observation: " + err.Error() - } else { - view.Observed = &observation - } - } - if profile, ok := a.codexClientIdentityContext(ctx); ok { - public := extensionv1.CodexClientProfile(profile) - view.ConfiguredProfile = &public - } - view.Alignment = "application_reference_available_transport_unverified" - view.CapturedReference = append(json.RawMessage(nil), codexClientReference...) - return view, nil -} diff --git a/backend/internal/service/codex_wire_fingerprint_test.go b/backend/internal/service/codex_wire_fingerprint_test.go deleted file mode 100644 index be7788b98642..000000000000 --- a/backend/internal/service/codex_wire_fingerprint_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/coder/websocket" - "github.com/stretchr/testify/require" -) - -// The view shows the configured identity and the last final outbound request. -// Dormant route-qualification records (per-model state, cookies in an older -// wire record) are neither read nor returned, and GET writes nothing. -func TestCodexFingerprintShowsLastOutboundRequestWithoutRoutingState(t *testing.T) { - account := codexOAuthTestAccount(7) - manager := nativeCodexTestRuntime(t) - store := &nativeCodexMemoryStore{values: map[string]extensionv1.StateResult{}} - manager.repo = store - service := &OpenAIGatewayService{nativeCodexRuntime: manager, accountRepo: &codexAccountRepositoryFixture{account: account}} - _, err := store.CompareSwapExtensionState(context.Background(), NativeCodexPluginKey, extensionv1.StateRequest{Namespace: "tickets", Key: "7.model", Value: json.RawMessage(`{"schema":2,"phase":"ready"}`)}) - require.NoError(t, err) - _, err = store.CompareSwapExtensionState(context.Background(), NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: "wire.7", Value: json.RawMessage(`{"user_agent":"older-agent","cookies":[{"name":"route","value":"dormant-cookie"}],"route_hash":"dormant-route"}`)}) - require.NoError(t, err) - view, err := service.CodexFingerprint(context.Background(), 7) - require.NoError(t, err) - require.Empty(t, view.ObservedError, "an older wire record still decodes") - require.Equal(t, "older-agent", view.Observed.UserAgent) - raw, err := json.Marshal(view) - require.NoError(t, err) - for _, retired := range []string{"dormant-cookie", "dormant-route", "routing_enabled", "harvest_proxy_url", "current_scope", "cookie_max_age_seconds", `"models"`} { - require.NotContains(t, string(raw), retired) - } - - request, err := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) - require.NoError(t, err) - request.Header.Set("User-Agent", "final-agent") - request.Header.Set(openAICodexTurnStateHeader, "raw-request-state") - service.observeCodexWire(context.Background(), account, request, &http.Response{StatusCode: http.StatusOK, Proto: "HTTP/2.0"}, "http") - view, err = service.CodexFingerprint(context.Background(), 7) - require.NoError(t, err) - require.Equal(t, "final-agent", view.Observed.UserAgent) - require.Equal(t, "http", view.Observed.Transport) - require.Equal(t, "raw-request-state", view.Observed.State) - require.NotEmpty(t, view.CapturedReference) - before := len(store.values) - _, err = service.CodexFingerprint(context.Background(), 7) - require.NoError(t, err) - require.Len(t, store.values, before, "read-only fingerprint GET wrote state") - require.False(t, resolveCodexIdentitySnapshotContext(context.Background(), account, account, "").UAOverridePresent) - require.True(t, resolveCodexIdentitySnapshotContext(context.Background(), account, account, "explicit UA override").UAOverridePresent) -} - -func TestCodexWireObservesOrdinaryHTTPAndNativeFrames(t *testing.T) { - account := codexOAuthTestAccount(7) - manager := nativeCodexTestRuntime(t) - store := &nativeCodexMemoryStore{values: map[string]extensionv1.StateResult{}} - manager.repo = store - upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Proto: "HTTP/2.0", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`))}} - service := &OpenAIGatewayService{httpUpstream: upstream, nativeCodexRuntime: manager} - request, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"model":"gpt-5.5","input":"private-prompt","client_metadata":{"session_id":"session"}}`)) - request.Header.Set("Authorization", "Bearer private-token") - request.Header.Set("User-Agent", "actual-wire-agent") - request.Header.Set("session-id", "session") - response, err := service.doOpenAICodexUpstream(request, account, "") - require.NoError(t, err) - _ = response.Body.Close() - require.Empty(t, upstream.lastReq.Header.Get("Cookie"), "an ordinary OAuth request carries no Cookie header") - record, err := store.ReadExtensionState(context.Background(), NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: "wire.7"}) - require.NoError(t, err) - var observation CodexWireFingerprint - require.NoError(t, json.Unmarshal(record.Value, &observation)) - require.Equal(t, "http", observation.Transport) - require.Equal(t, "actual-wire-agent", observation.UserAgent) - require.Equal(t, "match", observation.IdentityFields["session"].Consistency) - require.NotContains(t, string(record.Value), "private-token") - require.NotContains(t, string(record.Value), "private-prompt") - frames := newStagedPassthroughConn() - observed := &codexObservedNativeFrameConn{FrameConn: frames, observe: func(ctx context.Context, body []byte) { - service.observeNativeCodexWS(ctx, account, request.Header, http.Header{"Sec-Websocket-Extensions": []string{"permessage-deflate"}}, body) - }} - require.NoError(t, observed.WriteFrame(context.Background(), websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","client_metadata":{"session_id":"session"}}`))) - record, err = store.ReadExtensionState(context.Background(), NativeCodexPluginKey, extensionv1.StateRequest{Namespace: codexPrivateStateNamespace, Key: "wire.7"}) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(record.Value, &observation)) - require.Equal(t, "ws", observation.Transport) - require.Equal(t, "ws", observation.Ingress) - require.Equal(t, "permessage-deflate", observation.WebSocketExtensions) - require.Equal(t, "unknown", observation.JA3) - require.Empty(t, observation.TLSVersion) - require.Equal(t, "match", observation.IdentityFields["session"].Consistency) -} diff --git a/backend/internal/service/native_feature_bootstrap.go b/backend/internal/service/native_feature_bootstrap.go index 972ba5c4a8a2..f1bf3a43e463 100644 --- a/backend/internal/service/native_feature_bootstrap.go +++ b/backend/internal/service/native_feature_bootstrap.go @@ -18,7 +18,7 @@ var FirstPartyNativePluginKeys = []string{ } // These records describe the retired installation, not an enabled native plugin. -// Ciphertext and package bytes remain in their original columns. +// Ciphertext and package bytes are not copied into them. type NativeRetirementBinding struct { ID int64 `json:"id"` Capability string `json:"capability"` @@ -37,7 +37,6 @@ type NativeRetirementPlugin struct { Bootstrap json.RawMessage `json:"bootstrap,omitempty"` ConfigEncrypted string `json:"-"` Manifest json.RawMessage `json:"-"` - NativeCreated bool `json:"native_created,omitempty"` } type NativeRetirementSnapshot struct { @@ -295,17 +294,6 @@ func nativeCapabilityScopeKnown(key, capability, platform, accountType string) b return global && (capability == "extensions.admin.v1" || capability == "extensions.request.v1") case "codexrip.prompt-skills": return (global && capability == "extensions.admin.v1") || (capability == "extensions.request.v1" && platform == PlatformOpenAI && accountType == "*") - case "codexrip.codex-runtime": - if platform != PlatformOpenAI { - return false - } - if capability == "extensions.recovery.v1" { - return accountType == "*" - } - if accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken { - return false - } - return capability == "extensions.admin.v1" || capability == "extensions.credentials.v1" || capability == "extensions.jobs.v1" || capability == "extensions.request.v1" || capability == "extensions.scheduling.v1" } return false } diff --git a/backend/internal/service/native_feature_bootstrap_test.go b/backend/internal/service/native_feature_bootstrap_test.go index a1111168cfc8..566a17c8f4e5 100644 --- a/backend/internal/service/native_feature_bootstrap_test.go +++ b/backend/internal/service/native_feature_bootstrap_test.go @@ -56,19 +56,19 @@ func TestNativeFeatureBootstrapPreservesEffectiveConfiguration(t *testing.T) { plugin: NativeRetirementPlugin{Key: "codexrip.account-tools", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.admin.v1","platform":"*","account_type":"*"}]}`)}, wantErr: true, }, { - name: "Codex separately bound OAuth and setup tokens stay equivalent", - plugin: NativeRetirementPlugin{Key: "codexrip.codex-runtime", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.request.v1","platform":"openai","account_type":"oauth"},{"id":"extensions.request.v1","platform":"openai","account_type":"setup-token"}]}`), Bindings: []NativeRetirementBinding{ - {Capability: "extensions.request.v1", Platform: "openai", AccountType: "oauth", Enabled: true, RolloutPercent: 100}, - {Capability: "extensions.request.v1", Platform: "openai", AccountType: "setup-token", Enabled: true, RolloutPercent: 100}, + name: "fully bound prompt skills, platform-scoped request included, stay equivalent", + plugin: NativeRetirementPlugin{Key: "codexrip.prompt-skills", State: "enabled", Manifest: json.RawMessage(`{"capabilities":[{"id":"extensions.admin.v1","platform":"*","account_type":"*"},{"id":"extensions.request.v1","platform":"openai","account_type":"*"}]}`), Bindings: []NativeRetirementBinding{ + {Capability: "extensions.admin.v1", Platform: "*", AccountType: "*", Enabled: true, RolloutPercent: 100}, + {Capability: "extensions.request.v1", Platform: "openai", AccountType: "*", Enabled: true, RolloutPercent: 100}, }}, }, { name: "unfinished update cannot be retired", - plugin: NativeRetirementPlugin{Key: "codexrip.codex-runtime", State: "updating"}, wantErr: true, + plugin: NativeRetirementPlugin{Key: "codexrip.model-policy", State: "updating"}, wantErr: true, }, { - name: "disabled whole Codex domain needs an explicit decision", - plugin: NativeRetirementPlugin{Key: "codexrip.codex-runtime", State: "disabled"}, wantErr: true, + name: "disabled whole domain without a native switch needs an explicit decision", + plugin: NativeRetirementPlugin{Key: "codexrip.model-policy", State: "disabled"}, wantErr: true, }, { name: "missing capability scope is rejected", diff --git a/backend/internal/service/native_policy_fixtures.go b/backend/internal/service/native_policy_fixtures.go index 255d21e6fda0..02c63439ed2f 100644 --- a/backend/internal/service/native_policy_fixtures.go +++ b/backend/internal/service/native_policy_fixtures.go @@ -6,26 +6,13 @@ import ( ) // ConfigureNativePolicyOperations is the in-process composition seam used by -// cross-package contract fixtures. The server constructs NativeCodexRuntime. +// cross-package contract fixtures. // It has no plugin registry, process transport, capabilities or lifecycle side effects. func ConfigureNativePolicyOperations(invoker extensionv1.OperationInvoker) { invokeAccountTools = func(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { if invoker == nil { return extensionv1.Result{}, ErrExtensionOperationDisabled } - return invoker.InvokeOperation(ctx, "", "", in) - } - invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if invoker == nil { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - return invoker.InvokeOperation(ctx, platform, kind, in) - } - bindNativeCodexContext = func(ctx context.Context, _ string, _ string, _ extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - if invoker == nil { - return nil, nil, ErrNativeCodexPolicyDisabled - } - bound, cancel := context.WithCancel(ctx) - return bound, cancel, nil + return invoker.InvokeOperation(ctx, in) } } diff --git a/backend/internal/service/native_setting_load_test.go b/backend/internal/service/native_setting_load_test.go index f9a5898d5935..6bee8235efc6 100644 --- a/backend/internal/service/native_setting_load_test.go +++ b/backend/internal/service/native_setting_load_test.go @@ -6,6 +6,7 @@ import ( "errors" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -23,12 +24,15 @@ func (r *nativeSwitchReadRepository) GetValue(context.Context, string) (string, func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { oldImage := EffectiveImageToolsConfig() oldObservability := EffectiveAdminObservabilityConfig() + oldCodexRuntime := EffectiveCodexRuntimeConfig() t.Cleanup(func() { ConfigureImageTools(&oldImage) ConfigureAdminObservability(&oldObservability) + SetCodexRequestZstdEnabled(oldCodexRuntime.RequestZstd) }) ConfigureImageTools(&extensionv1.ImageToolsConfig{}) ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) + SetCodexRequestZstdEnabled(false) for _, test := range []struct { name string value string @@ -41,11 +45,12 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { svc := NewSettingService(&nativeSwitchReadRepository{value: test.value, err: test.err}, nil) - for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig} { + for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { require.Error(t, load(context.Background())) } require.Equal(t, extensionv1.ImageToolsConfig{}, EffectiveImageToolsConfig()) require.Equal(t, extensionv1.AdminObservabilityConfig{}, EffectiveAdminObservabilityConfig()) + require.Equal(t, extensionv1.CodexRuntimeConfig{}, EffectiveCodexRuntimeConfig()) }) } svc := NewSettingService(&nativeSwitchReadRepository{err: ErrSettingNotFound}, nil) @@ -54,3 +59,74 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { require.NoError(t, err) require.False(t, found) } + +type codexRuntimeSettingRepository struct { + SettingRepository + values map[string]string +} + +func (r *codexRuntimeSettingRepository) GetValue(_ context.Context, key string) (string, error) { + value, ok := r.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil +} + +func (r *codexRuntimeSettingRepository) Set(_ context.Context, key, value string) error { + r.values[key] = value + return nil +} + +// The Codex request compression switch is one plain settings row. Without a +// row the deploy-time gateway.openai_codex_request_zstd applies; a row that is +// not exactly a request_zstd boolean object is an error. +func TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault(t *testing.T) { + old := EffectiveCodexRuntimeConfig() + t.Cleanup(func() { SetCodexRequestZstdEnabled(old.RequestZstd) }) + ctx := context.Background() + load := func(deploy bool, stored ...string) (extensionv1.CodexRuntimeConfig, error) { + repo := &codexRuntimeSettingRepository{values: map[string]string{}} + if len(stored) > 0 { + repo.values["codex_runtime_config"] = stored[0] + } + cfg := &config.Config{} + cfg.Gateway.OpenAICodexRequestZstd = deploy + err := NewSettingService(repo, cfg).LoadCodexRuntimeConfig(ctx) + return EffectiveCodexRuntimeConfig(), err + } + for _, test := range []struct { + name string + deploy bool + stored []string + expected bool + }{ + {name: "no row follows the deploy default on", deploy: true, expected: true}, + {name: "no row follows the deploy default off", deploy: false, expected: false}, + {name: "stored off overrides the deploy default", deploy: true, stored: []string{`{"request_zstd":false}`}, expected: false}, + {name: "stored on overrides the deploy default", deploy: false, stored: []string{`{"request_zstd":true}`}, expected: true}, + {name: "an omitted switch is on", deploy: false, stored: []string{`{}`}, expected: true}, + } { + t.Run(test.name, func(t *testing.T) { + SetCodexRequestZstdEnabled(!test.expected) + got, err := load(test.deploy, test.stored...) + require.NoError(t, err) + require.Equal(t, extensionv1.CodexRuntimeConfig{RequestZstd: test.expected}, got) + }) + } + for _, stored := range []string{`{"request_zstd":"true"}`, `{"request_zstd":null}`, `{"request_zstd":true,"other":true}`, `{"other":true}`, `null`, `[]`, `true`, ``} { + SetCodexRequestZstdEnabled(false) + got, err := load(true, stored) + require.Error(t, err, stored) + require.False(t, got.RequestZstd, "a rejected row must not switch compression on: %s", stored) + } + + repo := &codexRuntimeSettingRepository{values: map[string]string{}} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.UpdateCodexRuntimeConfig(ctx, extensionv1.CodexRuntimeConfig{RequestZstd: true})) + require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":true}`}, repo.values) + require.True(t, EffectiveCodexRuntimeConfig().RequestZstd) + require.NoError(t, svc.UpdateCodexRuntimeConfig(ctx, extensionv1.CodexRuntimeConfig{})) + require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":false}`}, repo.values) + require.False(t, EffectiveCodexRuntimeConfig().RequestZstd) +} diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 5ff211861481..3c3f3bb3aa39 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -448,11 +448,6 @@ func isOpenAIAccount(account *Account) bool { // handleOpenAIAccountUpstreamError expects canonicalModel to be the model used // for scheduling after applying account mapping exactly once. func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, canonicalModel ...string) bool { - // A scoped quality experiment records its own failures without changing - // ordinary account health, cooldowns, linked accounts or scheduling state. - if IsCodexQualityRequest(ctx) { - return false - } // Classify request-local failures before any policy can mutate account health. // The same predicates are used by the HTTP failover and error constructors. if isOpenAIRequestScopedSafetyRejection(responseBody) { diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index b07610094f63..a9af7bca3ce6 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -4070,56 +4070,3 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_LegacyAccountModelRoute selection.ReleaseFunc() } } - -// Without route qualification an OAuth account serves every model, gated -// GPT-6 models included: a dormant deny projection in its extra, a missing -// native runtime or one without a loaded snapshot block nothing. -func TestSelectAccountWithSchedulerServesOAuthAccountsWithoutRouteQualification(t *testing.T) { - for _, tc := range []struct { - name string - advanced bool - runtime func(t *testing.T) *NativeCodexRuntime - }{ - {name: "legacy_load_aware_without_runtime", runtime: func(*testing.T) *NativeCodexRuntime { return nil }}, - {name: "advanced_scheduler_with_runtime", advanced: true, runtime: nativeCodexTestRuntime}, - {name: "advanced_scheduler_runtime_not_loaded", advanced: true, runtime: func(*testing.T) *NativeCodexRuntime { return &NativeCodexRuntime{} }}, - } { - t.Run(tc.name, func(t *testing.T) { - resetOpenAIAdvancedSchedulerSettingCacheForTest() - groupID := int64(92001) - full := &Account{ - ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, - Concurrency: 1, GroupIDs: []int64{groupID}, - Credentials: map[string]any{"access_token": "scheduler-token", "chatgpt_account_id": "scheduler-principal", "model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}}, - Extra: map[string]any{"plugin_account_projections": map[string]any{NativeCodexPluginKey: map[string]any{ - "identity": "dormant", "scheduling": map[string]any{"gpt-6-astra": map[string]any{"model": "gpt-6-astra", "effect": "deny"}}, - }}}, - } - projected := *full - projected.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}} - projected.Extra = nil - cfg := &config.Config{} - cfg.Gateway.Scheduling.LoadBatchEnabled = true - svc := &OpenAIGatewayService{ - accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*full}}, - cfg: cfg, - schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{snapshotAccounts: []*Account{&projected}, accountsByID: map[int64]*Account{full.ID: full}}}, - concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), - nativeCodexRuntime: tc.runtime(t), - } - if tc.advanced { - svc.rateLimitService = newOpenAIAdvancedSchedulerRateLimitService("true") - } - - selection, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-6-astra", nil, OpenAIUpstreamTransportAny, false) - - require.NoError(t, err) - require.NotNil(t, selection) - require.NotNil(t, selection.Account) - require.Equal(t, full.ID, selection.Account.ID) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } - }) - } -} diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index 2020add1b747..cb5ed55e9da0 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -137,7 +137,6 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))), }} service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} - service.cfg.Gateway.OpenAICodexRequestZstd = true account := &Account{ ID: 43, Platform: PlatformOpenAI, @@ -151,7 +150,8 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { }, } - result, err := service.ForwardAlphaSearch(withCodexTransportFixture(context.Background(), true), c, account, body) + enableCodexRequestZstd(t) + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) diff --git a/backend/internal/service/openai_chat_reasoning_replay.go b/backend/internal/service/openai_chat_reasoning_replay.go index aecb3d429c74..f811a0570f03 100644 --- a/backend/internal/service/openai_chat_reasoning_replay.go +++ b/backend/internal/service/openai_chat_reasoning_replay.go @@ -267,7 +267,6 @@ type openAIChatReasoningReplayCandidate struct { } func (s *OpenAIGatewayService) prepareOpenAIChatReasoningReplay(ctx context.Context, c *gin.Context, account *Account, request *http.Request, chatBody, wireBody []byte, enabled bool) (*openAIChatReasoningReplay, []byte) { - ctx = withCodexRecoveryScope(ctx, account) if c != nil { c.Set(openAIChatReasoningReplayContextKey, (*openAIChatReasoningReplay)(nil)) c.Set("openai_chat_reasoning_replay_hits", 0) diff --git a/backend/internal/service/openai_codex_client_identity.go b/backend/internal/service/openai_codex_client_identity.go index 333113fba446..d391269dbe8b 100644 --- a/backend/internal/service/openai_codex_client_identity.go +++ b/backend/internal/service/openai_codex_client_identity.go @@ -3,12 +3,12 @@ package service import ( "context" "encoding/json" - "errors" "fmt" "log/slog" "sync" "time" + codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) @@ -18,8 +18,6 @@ import ( // 是同一台设备上运行的同一个真实 Codex TUI,而不是多个用户各自的客户端。 const CodexClientIdentityExtraKey = "codex_client_identity" -const codexClientIdentitySchemaVersion = 1 // legacy persisted representation remains valid - type codexClientIdentity extensionv1.CodexClientProfile // Stable values used by the persisted protocol representation and diagnostics. @@ -34,51 +32,25 @@ const ( ) func (id codexClientIdentity) valid() bool { - return id.validForAccountContext(context.Background(), nil) -} - -func (id codexClientIdentity) validForAccountContext(ctx context.Context, account *Account) bool { - result, err := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.validate", extensionv1.CodexIdentityQuery{Profile: extensionv1.CodexClientProfile(id)}) - return err == nil && result.Valid + return codexprofile.Valid(extensionv1.CodexClientProfile(id)) } +// UserAgent 返回该身份在给定版本号下的 Codex TUI User-Agent;身份或版本号不合法时返回空串。 func (id codexClientIdentity) UserAgent(version string) string { - return id.userAgentForAccountContext(context.Background(), nil, version) -} - -func (id codexClientIdentity) userAgentForAccountContext(ctx context.Context, account *Account, version string) string { - result, _ := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.agent", extensionv1.CodexIdentityQuery{Profile: extensionv1.CodexClientProfile(id), Version: version}) - return result.UserAgent + userAgent, _ := codexprofile.UserAgent(extensionv1.CodexClientProfile(id), version) + return userAgent } func codexSandboxForUserAgent(userAgent string) string { - return codexSandboxForUserAgentForAccountContext(context.Background(), nil, userAgent) -} - -func codexSandboxForUserAgentForAccountContext(ctx context.Context, account *Account, userAgent string) string { - result, _ := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.sandbox", extensionv1.CodexIdentityQuery{UserAgent: userAgent}) - return result.Sandbox + return codexprofile.SandboxForUserAgent(userAgent) } func deriveCodexClientIdentity(seed string) codexClientIdentity { - return deriveCodexClientIdentityForAccountContext(context.Background(), nil, seed) -} - -func deriveCodexClientIdentityForAccountContext(ctx context.Context, account *Account, seed string) codexClientIdentity { - preset := "captured_windows_cli" - if account != nil && account.ID > 0 { - preset = "legacy" - } - result, _ := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.derive", extensionv1.CodexIdentityQuery{Seed: seed, Preset: preset}) - return codexClientIdentity(result.Profile) + return codexClientIdentity(codexprofile.Derive(seed)) } // codexClientIdentityFromExtra 读取持久化身份;缺失、非对象或字段不全时返回 false。 func codexClientIdentityFromExtra(extra map[string]any) (codexClientIdentity, bool) { - return codexClientIdentityFromExtraContext(context.Background(), nil, extra) -} - -func codexClientIdentityFromExtraContext(ctx context.Context, account *Account, extra map[string]any) (codexClientIdentity, bool) { if extra == nil { return codexClientIdentity{}, false } @@ -104,7 +76,7 @@ func codexClientIdentityFromExtraContext(ctx context.Context, account *Account, encoded = data } var id codexClientIdentity - if err := json.Unmarshal(encoded, &id); err != nil || !id.validForAccountContext(ctx, account) { + if err := json.Unmarshal(encoded, &id); err != nil || !id.valid() { return codexClientIdentity{}, false } return id, true @@ -126,40 +98,29 @@ func codexClientIdentitySeed(account *Account) string { // CodexClientIdentity 返回账号的 Codex 客户端身份。仅 OpenAI OAuth-like 账号有身份: // 优先读持久化值,缺失时按种子即时派生(派生确定性,落库前后结果一致)。 func (a *Account) CodexClientIdentity() (codexClientIdentity, bool) { - return a.codexClientIdentityContext(context.Background()) -} - -func (a *Account) codexClientIdentityContext(ctx context.Context) (codexClientIdentity, bool) { if a == nil || !a.IsOpenAIOAuthLike() { return codexClientIdentity{}, false } - if available, err := codexIdentityPolicyAvailable(ctx, a.Type, a.ID); err != nil || !available { - return codexClientIdentity{}, false - } - if id, ok := codexClientIdentityFromExtraContext(ctx, a, a.Extra); ok { + if id, ok := codexClientIdentityFromExtra(a.Extra); ok { return id, true } seed := codexClientIdentitySeed(a) if seed == "" { return codexClientIdentity{}, false } - identity := deriveCodexClientIdentityForAccountContext(ctx, a, seed) - return identity, identity.Version == codexClientIdentitySchemaVersion || identity.Version == 2 + return deriveCodexClientIdentity(seed), true } // codexClientIdentityExtraValue 把身份编码为可直接写入 extra 的 map。 func codexClientIdentityExtraValue(id codexClientIdentity, now time.Time) map[string]any { return map[string]any{ - "source": id.Source, - "originator": id.Originator, - "client_version": id.ClientVersion, - "v": id.Version, - "os_type": id.OSType, - "os_version": id.OSVersion, - "arch": id.Arch, - "terminal": id.Terminal, - "sandbox": id.Sandbox, - "generated_at": now.UTC().Format(time.RFC3339), + "v": id.Version, + "os_type": id.OSType, + "os_version": id.OSVersion, + "arch": id.Arch, + "terminal": id.Terminal, + "sandbox": id.Sandbox, + "generated_at": now.UTC().Format(time.RFC3339), } } @@ -167,18 +128,10 @@ func codexClientIdentityExtraValue(id codexClientIdentity, now time.Time) map[st // 只对 OpenAI OAuth-like 账号生效;种子取 extra 中的 codex_fingerprint_seed, // 调用方需先确保种子已就位(prepareCodexFingerprintExtraFor* 负责)。 func ensureCodexClientIdentityExtra(platform, accountType string, extra map[string]any, now time.Time) map[string]any { - // Creation has a known type but no persisted ID. Do not invent a rollout key. - return ensureCodexClientIdentityExtraForAccount(&Account{Platform: platform, Type: accountType}, extra, now) -} - -func ensureCodexClientIdentityExtraForAccount(account *Account, extra map[string]any, now time.Time) map[string]any { - if account == nil || !account.IsOpenAIOAuthLike() { + if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) { return extra } - if available, err := codexIdentityPolicyAvailable(context.Background(), account.Type, account.ID); err != nil || !available { - return extra - } - if _, ok := codexClientIdentityFromExtraContext(context.Background(), account, extra); ok { + if _, ok := codexClientIdentityFromExtra(extra); ok { return extra } seed, ok := codexFingerprintSeed(extra) @@ -188,11 +141,7 @@ func ensureCodexClientIdentityExtraForAccount(account *Account, extra map[string if extra == nil { extra = make(map[string]any, 1) } - identity := deriveCodexClientIdentityForAccountContext(context.Background(), account, seed) - if identity.Version != codexClientIdentitySchemaVersion && identity.Version != 2 { - return extra - } - extra[CodexClientIdentityExtraKey] = codexClientIdentityExtraValue(identity, now) + extra[CodexClientIdentityExtraKey] = codexClientIdentityExtraValue(deriveCodexClientIdentity(seed), now) return extra } @@ -277,52 +226,26 @@ func (s *CodexClientIdentityBackfillService) RunOnce(ctx context.Context) (int, if !account.IsOpenAIOAuthLike() { continue } - if available, err := codexIdentityPolicyAvailable(ctx, account.Type, account.ID); err != nil { - return updated, failed, err - } else if !available { + if _, ok := codexClientIdentityFromExtra(account.Extra); ok { continue } - // The background scan may skip inapplicable accounts, but its policy - // lease and write must stay scoped to each actual applicable account. - if err := func() error { - bound, release, err := bindNativeCodexContext(ctx, account.Platform, account.Type, extensionv1.Invocation{ - Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.derive", AccountID: account.ID, - }) - if errors.Is(err, ErrNativeCodexPolicyDisabled) { - return nil - } - if err != nil { - return err - } - defer release() - if _, ok := codexClientIdentityFromExtraContext(bound, account, account.Extra); ok { - return nil - } - seed := codexClientIdentitySeed(account) - if seed == "" { - return nil - } - if err := bound.Err(); err != nil { - return err - } - result, err := invokeCodexIdentityPolicyForAccount(bound, account, "codex.identity.derive", extensionv1.CodexIdentityQuery{Seed: seed, Preset: "legacy"}) - if err != nil { - return err - } - value := codexClientIdentityExtraValue(codexClientIdentity(result.Profile), s.now()) - applied, err := writer.UpdateExtraIfRevision(bound, account.ID, account.UpdatedAt, map[string]any{CodexClientIdentityExtraKey: value}) - if err != nil { - failed++ - slog.Warn("codex_client_identity_backfill_account_failed", "account_id", account.ID, "error", err) - return nil - } - if applied { - updated++ - } - return nil - }(); err != nil { + seed := codexClientIdentitySeed(account) + if seed == "" { + continue + } + if err := ctx.Err(); err != nil { return updated, failed, err } + value := codexClientIdentityExtraValue(deriveCodexClientIdentity(seed), s.now()) + applied, err := writer.UpdateExtraIfRevision(ctx, account.ID, account.UpdatedAt, map[string]any{CodexClientIdentityExtraKey: value}) + if err != nil { + failed++ + slog.Warn("codex_client_identity_backfill_account_failed", "account_id", account.ID, "error", err) + continue + } + if applied { + updated++ + } } return updated, failed, nil } diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index 953d4b38b2b6..315cdf5400b2 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -8,9 +8,10 @@ import ( "net/http/httptest" "regexp" "testing" + "time" + codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -73,10 +74,101 @@ func TestCodexIdentitySnapshotIsSecretFreeAndConsistent(t *testing.T) { SetCodexForceCLIEnabled(true) forced := resolveCodexIdentitySnapshotContext(context.Background(), account, account, codexAccountIdentityOverrideUA(account)) require.Equal(t, "account", forced.IdentitySource) - require.Equal(t, legacyCodexClientIdentity(seed).UserAgent(forced.Version), forced.UserAgent, + require.Equal(t, deriveCodexClientIdentity(seed).UserAgent(forced.Version), forced.UserAgent, "ForceCodexCLI drops the custom UA while retaining the account's derived TUI profile") } +func TestCodexIdentitySnapshotReportsTheSelectedFingerprintSource(t *testing.T) { + SetCodexForceCLIEnabled(true) + t.Cleanup(func() { SetCodexForceCLIEnabled(false) }) + account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} + snapshot := resolveCodexIdentitySnapshotContext(context.Background(), account, account, "") + require.Equal(t, "account", snapshot.IdentitySource) + require.False(t, snapshot.UAOverridePresent) + snapshot = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "codex_cli_rs/0.144.0 (Windows 10.0.19045; x86_64) unknown") + require.Equal(t, "override_ua", snapshot.IdentitySource) + require.True(t, snapshot.UAOverridePresent) + // An override that does not pair as a Codex identity leaves the canonical + // identity in place; the flag is then the only sign that one is configured. + snapshot = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "explicit UA override") + require.Equal(t, "canonical", snapshot.IdentitySource) + require.True(t, snapshot.UAOverridePresent) +} + +// 生效版本号写不进账号身份的 User-Agent 时发送必须失败:请求头保持原样、token 刷新不出站, +// 而不是退回全局规范身份。 +// +// 不得给本用例加 t.Parallel():它改写进程级解析器与版本号校验。 +func TestCodexIdentityInvalidVersionCannotSendCanonicalFallback(t *testing.T) { + // 生效版本号到达账号身份之前已由 NormalizeCodexClientVersion 校验;放宽这层校验, + // 让一个不合法的版本号走到身份层。 + previous := codexClientVersionPattern + codexClientVersionPattern = regexp.MustCompile(`^\S+$`) + SetCodexCanonicalUserAgentResolver(func() string { + return "codex-tui/9.9.9_bad (Ubuntu 22.4.0; x86_64) xterm-256color" + }) + t.Cleanup(func() { + codexClientVersionPattern = previous + SetCodexCanonicalUserAgentResolver(nil) + }) + account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "fixture-refresh"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} + require.Equal(t, "9.9.9_bad", resolveCodexOutboundIdentity("").version) + + _, err := resolveCodexOutboundIdentityForAccountContext(context.Background(), account, "") + require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) + headers := http.Header{"Originator": []string{"fixture-original"}} + err = enforceCodexIdentityHeadersForAccountContext(context.Background(), headers, account, "") + require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) + require.Equal(t, "fixture-original", headers.Get("Originator")) + require.Empty(t, headers.Get("User-Agent")) + client := &identityRefreshingOAuthClientStub{} + _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) + require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) + require.Empty(t, client.userAgent) +} + +type codexIdentityBackfillRepo struct { + AccountRepository + accounts []Account + written map[int64]map[string]any +} + +func (r *codexIdentityBackfillRepo) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) { + return r.accounts, nil +} + +func (r *codexIdentityBackfillRepo) UpdateExtraIfRevision(_ context.Context, id int64, _ time.Time, updates map[string]any) (bool, error) { + r.written[id] = updates + return true, nil +} + +// 启动回填只给缺少合法身份的 OpenAI OAuth-like 账号写入按种子派生的身份。 +func TestCodexIdentityBackfillWritesOnlyMissingIdentities(t *testing.T) { + const seed = "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55" + stored := codexClientIdentityExtraValue(deriveCodexClientIdentity("6f2c1c7e-6e2d-4a4b-9f0b-2f5f0c8a1d33"), time.Unix(0, 0)) + repo := &codexIdentityBackfillRepo{written: map[int64]map[string]any{}, accounts: []Account{ + {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}}, + {ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed, CodexClientIdentityExtraKey: stored}}, + {ID: 5, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}}, + {ID: 7, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}}, + {ID: 8, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}}, + }} + + updated, failed, err := NewCodexClientIdentityBackfillService(repo).RunOnce(context.Background()) + require.NoError(t, err) + require.Zero(t, failed) + require.Equal(t, 2, updated) + require.Len(t, repo.written, 2) + for _, id := range []int64{2, 7} { + got, ok := codexClientIdentityFromExtra(repo.written[id]) + require.True(t, ok, "account %d", id) + require.Equal(t, deriveCodexClientIdentity(seed), got.withoutGeneratedAt()) + } +} + // 账号级身份:由种子确定性派生,UA 三处版本同源,形态与 codex-rs 的 // `codex-tui/ ( ; ) (codex-tui; )` 一致。 func TestEnforceCodexIdentityHeadersForAccountUsesDerivedTUIIdentity(t *testing.T) { @@ -117,13 +209,10 @@ func TestCodexClientIdentityIsSystemManagedAndSchemaChecked(t *testing.T) { prepared := prepareCodexFingerprintExtraForUpdate(account, map[string]any{CodexClientIdentityExtraKey: crafted}) got, ok := codexClientIdentityFromExtra(prepared) require.True(t, ok) - require.Equal(t, legacyCodexClientIdentity(seed), got.withoutGeneratedAt(), "已有账号保留按种子派生的 v1 身份") + require.Equal(t, deriveCodexClientIdentity(seed), got.withoutGeneratedAt()) require.NotContains(t, sanitizedCodexFingerprintExtraUpdates(map[string]any{CodexClientIdentityExtraKey: crafted}), CodexClientIdentityExtraKey) - created, ok := codexClientIdentityFromExtra(prepareCodexFingerprintExtraForCreate(PlatformOpenAI, AccountTypeOAuth, map[string]any{CodexClientIdentityExtraKey: crafted})) - require.True(t, ok) - require.Equal(t, deriveCodexClientIdentity(seed), created.withoutGeneratedAt(), "新建账号同样忽略表单身份,落库官方 Windows CLI 参照(v2)") - mismatched := legacyCodexClientIdentity(seed) + mismatched := deriveCodexClientIdentity(seed) mismatched.Sandbox = "seccomp" mismatched.OSType = "Windows" require.False(t, mismatched.valid()) @@ -157,19 +246,12 @@ func TestRefreshAccountTokenSharesForceCodexCLIOverridePolicy(t *testing.T) { require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, stub.userAgent, "ForceCodexCLI 时与推理面一样忽略显式 UA") } -// legacyCodexClientIdentity 是已有账号(有 ID、未存储身份)按种子派生的 v1 身份; -// 不带账号的 deriveCodexClientIdentity 表示新建账号,得到官方 Windows CLI 参照(v2)。 -func legacyCodexClientIdentity(seed string) codexClientIdentity { - result, _ := invokeCodexIdentityPolicyForAccount(context.Background(), nil, "codex.identity.derive", extensionv1.CodexIdentityQuery{Seed: seed, Preset: "legacy"}) - return codexClientIdentity(result.Profile) -} - -// codexTestSeedForOS 返回一个让已有账号派生出指定操作系统身份的合法种子(确定性搜索)。 +// codexTestSeedForOS 返回一个派生出指定操作系统身份的合法种子(确定性搜索)。 func codexTestSeedForOS(t *testing.T, osType string) string { t.Helper() for i := 0; i < 4096; i++ { seed := fmt.Sprintf("00000000-0000-4000-8000-%012d", i) - if legacyCodexClientIdentity(seed).OSType == osType { + if deriveCodexClientIdentity(seed).OSType == osType { return seed } } diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index a2d3d2f53218..1719ebc8ba49 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -1,7 +1,6 @@ package service import ( - "context" "crypto/sha256" "encoding/binary" "encoding/json" @@ -11,7 +10,6 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" @@ -67,7 +65,8 @@ type codexFingerprintMode string const ( // codexFingerprintOff 不做任何收敛,原样透传客户端标识。 - // 这是默认值:收敛是显式 opt-in 的(见 GetCodexFingerprintMode)。 + // 非 OpenAI OAuth 类账号恒为此值;OAuth 类账号须显式写入 off 才是此值 + // (见 GetCodexFingerprintMode)。 codexFingerprintOff codexFingerprintMode = "off" // codexFingerprintDevice 仅收敛 installation_id 为账号级恒定值。 // 上游看到 1 台设备 + 多会话(每用户各自的 session)。 @@ -113,24 +112,13 @@ func stripCodexFingerprintSeed(extra map[string]any) map[string]any { } // codexFingerprintModeFromExtra 读取账号完整 extra 上的收敛模式。 -// 显式配置保持上游语义;下游默认值由 Codex 插件提供,停用时使用上游 off 默认。 +// 未设置、空值或非法值一律按 device 处理:多人共享同一 OAuth 账号时,上游只应看到 +// 一台设备;显式 off 仍然生效(原样透传客户端标识)。 func codexFingerprintModeFromExtra(extra map[string]any) codexFingerprintMode { - return codexFingerprintModeFromExtraContext(context.Background(), nil, extra) -} - -func codexFingerprintModeFromExtraContext(ctx context.Context, account *Account, extra map[string]any) codexFingerprintMode { if mode, ok := codexFingerprintModeExplicit(extra); ok { return mode } - policy, err := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.plan", extensionv1.CodexIdentityQuery{}) - if err != nil { - return codexFingerprintOff - } - switch mode := codexFingerprintMode(policy.DefaultFingerprintMode); mode { - case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: - return mode - } - return codexFingerprintOff + return codexFingerprintDevice } // codexFingerprintModeExplicit 只识别显式写入的合法模式;缺失、空值、非法值返回 false。 @@ -199,12 +187,10 @@ func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]an // 身份是系统管理字段:忽略表单带回的值(可能过期或被篡改),保留库中已持久化的 // 合法身份;没有则按种子派生。派生确定性,与启动回填并发也只会写出同一个值。 delete(prepared, CodexClientIdentityExtraKey) - if stored, exists := account.Extra[CodexClientIdentityExtraKey]; exists { - // Keep server-owned data even while its domain is disabled or unavailable. - // An available policy can validate and replace an invalid value below. - prepared[CodexClientIdentityExtraKey] = stored + if _, ok := codexClientIdentityFromExtra(account.Extra); ok { + prepared[CodexClientIdentityExtraKey] = account.Extra[CodexClientIdentityExtraKey] } - return ensureCodexClientIdentityExtraForAccount(account, prepared, time.Now()) + return ensureCodexClientIdentityExtra(account.Platform, account.Type, prepared, time.Now()) } func sanitizedCodexFingerprintExtraUpdates(updates map[string]any) map[string]any { @@ -232,20 +218,16 @@ func ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates map[string]any) boo return codexFingerprintModeRequiresSeed(mode) } -// GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。 +// GetCodexFingerprintMode 返回账号的指纹收敛模式。 // -// 管理员显式选择始终保留;仅适用账号类型且启用的 Codex 插件可提供下游默认值。 +// 非 OpenAI OAuth 类账号恒为 off。OAuth 类账号取 extra 中显式写入的合法模式, +// 显式 off 生效;未设置、空值或非法值按 device 处理(见 +// codexFingerprintModeFromExtra)。 func (a *Account) GetCodexFingerprintMode() codexFingerprintMode { if a == nil || !a.IsOpenAIOAuthLike() { return codexFingerprintOff } - if mode, explicit := codexFingerprintModeExplicit(a.Extra); explicit { - return mode - } - if available, err := codexIdentityPolicyAvailable(context.Background(), a.Type, a.ID); err != nil || !available { - return codexFingerprintOff - } - return codexFingerprintModeFromExtraContext(context.Background(), a, a.Extra) + return codexFingerprintModeFromExtra(a.Extra) } // deriveStableUUIDv4 从种子确定性派生一个 UUIDv4 格式的字符串。 @@ -302,7 +284,6 @@ func resolveConvergedThreadID(seed, clientSessionID string) string { // client_metadata.session_id,用于识别 root prompt_cache_key 的默认值。 type codexFingerprintIDs struct { accountID int64 - accountType string mode codexFingerprintMode installationID string sessionID string @@ -331,7 +312,6 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c ids := &codexFingerprintIDs{ accountID: account.ID, - accountType: account.Type, mode: mode, turnStartedAtUnixMs: time.Now().UnixMilli(), } @@ -441,11 +421,7 @@ func (ids *codexFingerprintIDs) alignSandboxWithUserAgent(userAgent string) { if ids == nil { return } - var account *Account - if ids.accountType != "" { - account = &Account{ID: ids.accountID, Platform: PlatformOpenAI, Type: ids.accountType} - } - ids.sandbox = codexSandboxForUserAgentForAccountContext(context.Background(), account, userAgent) + ids.sandbox = codexSandboxForUserAgent(userAgent) } // turnMetadataFields 在身份字段之外补上与出站 UA 配套的 sandbox 标签,使 turn @@ -527,14 +503,6 @@ func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *code } // session / full 模式 - // A real turn can contain several tool continuations or retries. Preserve - // its already-account-scoped UUID instead of minting another turn per HTTP - // attempt; headers and body share this same IDs object. - if original, ok := existing["turn_id"].(string); ok { - if _, err := uuid.Parse(original); err == nil { - ids.turnID = original - } - } existing["session_id"] = ids.sessionID existing["thread_id"] = ids.threadID existing["turn_id"] = ids.turnID diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 05d6cf40f1a9..94b49d74e70d 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -8,6 +8,8 @@ import ( "sync" "sync/atomic" + codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/google/uuid" ) @@ -278,6 +280,25 @@ func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) return identity } +// resolveCodexOutboundIdentityForAccountContext 与 resolveCodexOutboundIdentityForAccount 同语义, +// 并把唯一的失败原因交还调用方:生效版本号无法写进该账号身份的 User-Agent 时返回错误, +// 调用方必须让本次发送失败,而不是退回全局规范身份出站。 +func resolveCodexOutboundIdentityForAccountContext(_ context.Context, account *Account, overrideUA string) (codexOutboundIdentity, error) { + canonical := resolveCodexOutboundIdentity(overrideUA) + if overrideUA != "" { + return canonical, nil + } + identity, ok := account.CodexClientIdentity() + if !ok { + return canonical, nil + } + userAgent, err := codexprofile.UserAgent(extensionv1.CodexClientProfile(identity), canonical.version) + if err != nil { + return codexOutboundIdentity{}, err + } + return codexOutboundIdentity{userAgent: userAgent, originator: codexTUIOriginator, version: canonical.version}, nil +} + // enforceCodexIdentityHeadersForAccount 与 enforceCodexIdentityHeadersWithUA 语义相同, // 但强制统一时使用账号级身份而不是全局规范身份,使同一账号的所有出站请求 // 表现为同一台机器上的同一个 Codex TUI。 diff --git a/backend/internal/service/openai_codex_identity_snapshot.go b/backend/internal/service/openai_codex_identity_snapshot.go index 7302c8039482..3e143b7907d1 100644 --- a/backend/internal/service/openai_codex_identity_snapshot.go +++ b/backend/internal/service/openai_codex_identity_snapshot.go @@ -8,7 +8,6 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) @@ -17,8 +16,6 @@ import ( // process state published by NewOpenAIGatewayService, so the snapshot describes // the policy that was actually active in this process. type codexIdentitySnapshot struct { - ProfileSource string `json:"profile_source"` - ProfileSchema int `json:"profile_schema"` SchemaVersion int `json:"v"` AccountID int64 `json:"account_id"` IdentityAccountID int64 `json:"identity_account_id"` @@ -59,9 +56,7 @@ func resolveCodexIdentitySnapshotContext(ctx context.Context, routed, source *Ac UAOverridePresent: strings.TrimSpace(overrideUA) != "", } if source != nil && source.IsOpenAIOAuthLike() { - if plan, err := codexTransportPlan(ctx, source.Type, source.ID, extensionv1.CodexTransportQuery{}); err == nil { - snapshot.ZstdEnabled = plan.Enabled - } + snapshot.ZstdEnabled = codexRequestZstd.Load() } if routed != nil { snapshot.AccountID = routed.ID @@ -88,17 +83,10 @@ func resolveCodexIdentitySnapshotContext(ctx context.Context, routed, source *Ac } } if source != nil { - if profile, ok := source.codexClientIdentityContext(ctx); ok { - snapshot.ProfileSchema = profile.Version - snapshot.ProfileSource = profile.Source - if snapshot.ProfileSource == "" { - snapshot.ProfileSource = "legacy_generated" - } - } snapshot.IdentityAccountID = source.ID snapshot.AccountRevision = source.UpdatedAt snapshot.IdentityPersisted = func() bool { - _, ok := codexClientIdentityFromExtraContext(ctx, source, source.Extra) + _, ok := codexClientIdentityFromExtra(source.Extra) return ok }() } @@ -115,7 +103,7 @@ func resolveCodexIdentitySnapshotContext(ctx context.Context, routed, source *Ac snapshot.IdentitySource = "override_ua" } } else if source != nil { - if accountIdentity, ok := source.codexClientIdentityContext(ctx); ok && accountIdentity.userAgentForAccountContext(ctx, source, identity.version) == identity.userAgent { + if accountIdentity, ok := source.CodexClientIdentity(); ok && accountIdentity.UserAgent(identity.version) == identity.userAgent { snapshot.IdentitySource = "account" } } diff --git a/backend/internal/service/openai_codex_logical_turn.go b/backend/internal/service/openai_codex_logical_turn.go deleted file mode 100644 index 17655b5a9995..000000000000 --- a/backend/internal/service/openai_codex_logical_turn.go +++ /dev/null @@ -1,60 +0,0 @@ -package service - -import ( - "strings" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -// codexLogicalTurnContextKey holds the logical turn staged for the current -// request (empty when the request names none). -const codexLogicalTurnContextKey = "codex_logical_turn_id" - -// A turn identifier is independent of the conversation/session. Opaque state -// cannot cross a turn simply because the same downstream session continues. -func stageCodexLogicalTurn(c *gin.Context, body []byte) { - stageCodexLogicalTurnWithHeader(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 stageCodexLogicalWSTurn(c *gin.Context, body []byte) { - stageCodexLogicalTurnWithHeader(c, body, false) -} - -func stageCodexLogicalTurnWithHeader(c *gin.Context, body []byte, allowHeader bool) { - if c == nil { - return - } - turn := strings.TrimSpace(gjson.GetBytes(body, "client_metadata.turn_id").String()) - if turn == "" { - metadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String() - turn = strings.TrimSpace(gjson.Get(metadata, "turn_id").String()) - } - if turn == "" && allowHeader && c.Request != nil { - turn = strings.TrimSpace(gjson.Get(c.Request.Header.Get(openAIWSTurnMetadataHeader), "turn_id").String()) - } - if len(turn) > 128 { - turn = "" - } - c.Set(codexLogicalTurnContextKey, turn) -} - -func codexLogicalTurnID(c *gin.Context) string { - if c == nil { - return "" - } - if value, exists := c.Get(codexLogicalTurnContextKey); exists { - text, _ := value.(string) - return text - } - if c.Request == nil { - return "" - } - turn := strings.TrimSpace(gjson.Get(c.Request.Header.Get(openAIWSTurnMetadataHeader), "turn_id").String()) - if len(turn) > 128 { - return "" - } - return turn -} diff --git a/backend/internal/service/openai_codex_request_compression.go b/backend/internal/service/openai_codex_request_compression.go index 4471204fd12a..452a346e6e83 100644 --- a/backend/internal/service/openai_codex_request_compression.go +++ b/backend/internal/service/openai_codex_request_compression.go @@ -2,7 +2,6 @@ package service import ( "bytes" - "context" "errors" "fmt" "io" @@ -10,6 +9,7 @@ import ( "net/http" "strings" "sync" + "sync/atomic" "github.com/klauspost/compress/zstd" ) @@ -57,6 +57,26 @@ func compressCodexRequestBodyZstd(src []byte) ([]byte, error) { return buf.Bytes(), nil } +// codexRequestZstd 是「Codex 运行设置」压缩开关的进程级快照:网关转发、账号测试与用量探针 +// 都向 /backend-api/codex/responses 发 OAuth POST,必须用同一套压缩策略出站,而后两者拿不到 +// 网关服务。由 SettingService 在启动加载和后台保存时发布。 +var codexRequestZstd atomic.Bool + +// SetCodexRequestZstdEnabled 发布压缩开关的当前值。 +func SetCodexRequestZstdEnabled(enabled bool) { + codexRequestZstd.Store(enabled) +} + +// isCodexStreamingResponsesRequest 判断请求是否是官方客户端会压缩的那一类: +// POST 到 ChatGPT 后端 /backend-api/codex/responses(不含 /compact)。 +func isCodexStreamingResponsesRequest(req *http.Request) bool { + if req == nil || req.URL == nil || req.Method != http.MethodPost { + return false + } + path := strings.TrimSuffix(req.URL.Path, "/") + return strings.HasSuffix(path, "/codex/responses") +} + // prepareOpenAICodexWireRequest 返回真正发往上游的请求。满足条件时返回一个请求体已 // zstd 压缩、Content-Encoding/Content-Length 已改写的克隆;其余情况原样返回。 // @@ -64,22 +84,19 @@ func compressCodexRequestBodyZstd(src []byte) ([]byte, error) { // 不可重放请求(GetBody 已被 PrepareRequest 清空)只能读取 Body,读取成功后用同一份明文 // 重新填充。读取失败时绝不把已读出的前缀当作完整请求发送:可重放请求退回原请求明文发送, // 不可重放请求显式返回错误(该请求本就无法完整发出)。编码失败一律退回明文。 -func (s *OpenAIGatewayService) prepareOpenAICodexWireRequest(req *http.Request, account *Account) (*http.Request, error) { - return prepareCodexTransport(req, account) -} - -// prepareOpenAICodexWireRequestUngated executes an approved encoding plan. Body -// ownership, complete reads, replayability and wire framing remain host IO. -func prepareOpenAICodexWireRequestUngated(req *http.Request, account *Account) (*http.Request, error) { - if account == nil || !account.IsOpenAIOAuthLike() { +func prepareOpenAICodexWireRequest(req *http.Request, account *Account) (*http.Request, error) { + if !codexRequestZstd.Load() || account == nil || !account.IsOpenAIOAuthLike() { return req, nil } - if req == nil || req.Body == nil || req.Body == http.NoBody { + if !isCodexStreamingResponsesRequest(req) || req.Body == nil || req.Body == http.NoBody { return req, nil } if strings.TrimSpace(req.Header.Get("Content-Encoding")) != "" { return req, nil } + if contentType := strings.ToLower(req.Header.Get("Content-Type")); contentType != "" && !strings.Contains(contentType, "json") { + return req, nil + } var raw []byte if req.GetBody != nil { snapshot, err := req.GetBody() @@ -110,14 +127,7 @@ func prepareOpenAICodexWireRequestUngated(req *http.Request, account *Account) ( slog.Debug("codex_request_zstd_skipped", "reason", "encode", "error", err) return req, nil } - // The zstd body is opaque to final-outbound diagnostics, so only this copy - // carries the plaintext identity-field observation. Unrewritten requests stay - // the caller's object and are inspected directly by observeCodexWire. - ctx := req.Context() - if _, ok := ctx.Value(codexIdentityBodyKey{}).(codexIdentityBodyObservation); !ok { - ctx = context.WithValue(ctx, codexIdentityBodyKey{}, inspectCodexIdentityBody(req)) - } - wire := req.Clone(ctx) + wire := req.Clone(req.Context()) wire.Body = io.NopCloser(bytes.NewReader(compressed)) wire.ContentLength = int64(len(compressed)) wire.Header.Set("Content-Encoding", "zstd") @@ -134,28 +144,9 @@ func prepareOpenAICodexWireRequestUngated(req *http.Request, account *Account) ( // doOpenAICodexUpstream 是 OpenAI 网关所有 Responses 端点 POST 的统一发送入口:先按上述 // 规则生成线上请求(满足条件时压缩),再交给 httpUpstream。读取失败作为传输错误返回。 func (s *OpenAIGatewayService) doOpenAICodexUpstream(req *http.Request, account *Account, proxyURL string) (*http.Response, error) { - wire, err := s.prepareOpenAICodexWireRequest(req, account) + wire, err := prepareOpenAICodexWireRequest(req, account) if err != nil { return nil, err } - // A quality diagnosis send takes this same ordinary path. Its persistent - // budget is reserved from the final wire request before any upstream IO; - // its raw observation then sits inside the model guard. - quality := IsCodexQualityRequest(wire.Context()) - if quality { - if err := reserveCodexQualitySend(wire, account); err != nil { - return nil, err - } - } - response, err := s.httpUpstream.Do(wire, proxyURL, account.ID, account.Concurrency) - if quality { - s.observeCodexQualityBusinessResponse(wire, response, err) - } - if err == nil && isCodexCredentialOwner(account) { - s.observeCodexWire(wire.Context(), account, wire, response, "http") - if !quality { - s.guardCodexResponseModel(wire, account, response) - } - } - return response, err + return s.httpUpstream.Do(wire, proxyURL, account.ID, account.Concurrency) } diff --git a/backend/internal/service/openai_codex_request_compression_test.go b/backend/internal/service/openai_codex_request_compression_test.go index 15042c9b0c68..18123221c926 100644 --- a/backend/internal/service/openai_codex_request_compression_test.go +++ b/backend/internal/service/openai_codex_request_compression_test.go @@ -2,14 +2,12 @@ package service import ( "bytes" - "context" "errors" "io" "net/http" "strings" "testing" - "github.com/Wei-Shaw/sub2api/internal/config" "github.com/klauspost/compress/zstd" "github.com/stretchr/testify/require" ) @@ -17,20 +15,18 @@ import ( // 只有 OAuth-like 账号发往 /backend-api/codex/responses 的 POST 才在发送副本上做 zstd; // 调用方持有的请求保持明文可重读,compact 与 API Key 账号不压缩。 func TestPrepareOpenAICodexWireRequestCompressesOnlyCodexStreamingTurns(t *testing.T) { - svc := &OpenAIGatewayService{cfg: &config.Config{}} - svc.cfg.Gateway.OpenAICodexRequestZstd = true - enabled := true + enableCodexRequestZstd(t) body := []byte(`{"model":"gpt-5.5","input":"` + strings.Repeat("hello ", 200) + `","stream":true}`) newReq := func(path string) *http.Request { req, err := http.NewRequest(http.MethodPost, "https://chatgpt.com"+path, bytes.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") - return req.WithContext(withCodexTransportFixture(req.Context(), enabled)) + return req } oauth := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} req := newReq("/backend-api/codex/responses") - wire, err := svc.prepareOpenAICodexWireRequest(req, oauth) + wire, err := prepareOpenAICodexWireRequest(req, oauth) require.NoError(t, err) require.NotSame(t, req, wire) require.Equal(t, "zstd", wire.Header.Get("Content-Encoding")) @@ -59,32 +55,37 @@ func TestPrepareOpenAICodexWireRequestCompressesOnlyCodexStreamingTurns(t *testi require.Equal(t, body, original) compact := newReq("/backend-api/codex/responses/compact") - same, err := svc.prepareOpenAICodexWireRequest(compact, oauth) + same, err := prepareOpenAICodexWireRequest(compact, oauth) require.NoError(t, err) require.Same(t, compact, same) apiKey := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - same, err = svc.prepareOpenAICodexWireRequest(req, apiKey) + same, err = prepareOpenAICodexWireRequest(req, apiKey) require.NoError(t, err) require.Same(t, req, same) - enabled = false + for _, change := range []func(*http.Request){ + func(r *http.Request) { r.Method = http.MethodGet }, + func(r *http.Request) { r.Header.Set("Content-Encoding", "gzip") }, + func(r *http.Request) { r.Header.Set("Content-Type", "multipart/form-data; boundary=fixture") }, + func(r *http.Request) { r.Body = http.NoBody }, + } { + candidate := newReq("/backend-api/codex/responses") + change(candidate) + same, err = prepareOpenAICodexWireRequest(candidate, oauth) + require.NoError(t, err) + require.Same(t, candidate, same) + } + SetCodexRequestZstdEnabled(false) disabled := newReq("/backend-api/codex/responses") - same, err = svc.prepareOpenAICodexWireRequest(disabled, oauth) + same, err = prepareOpenAICodexWireRequest(disabled, oauth) require.NoError(t, err) require.Same(t, disabled, same) +} - // Quota and gateway paths consult the same domain policy. - enabled = true - snapshotWire, err := prepareCodexTransport(newReq("/backend-api/codex/responses"), oauth) - require.NoError(t, err) - require.Equal(t, "zstd", snapshotWire.Header.Get("Content-Encoding")) - snapshotCompressed, err := io.ReadAll(snapshotWire.Body) - require.NoError(t, err) - require.Equal(t, body, zstdDecodeForTest(t, snapshotCompressed)) - enabled = false - snapshotOff := newReq("/backend-api/codex/responses") - same, err = prepareCodexTransport(snapshotOff, oauth) - require.NoError(t, err) - require.Same(t, snapshotOff, same) +// enableCodexRequestZstd 在单个测试内打开请求体压缩开关,结束时恢复关闭。 +func enableCodexRequestZstd(t *testing.T) { + t.Helper() + SetCodexRequestZstdEnabled(true) + t.Cleanup(func() { SetCodexRequestZstdEnabled(false) }) } // zstdDecodeForTest 解压 zstd 压缩的请求体,供断言「压缩后语义不变」的 fixture 复用。 @@ -114,29 +115,26 @@ func (r *prefixThenErrorReader) Read(p []byte) (int, error) { // 读取失败时绝不发送截断前缀:不可重放请求显式报错,可重放请求原请求不动、退回明文。 func TestPrepareOpenAICodexWireRequestNeverSendsTruncatedBody(t *testing.T) { - svc := &OpenAIGatewayService{cfg: &config.Config{}} - svc.cfg.Gateway.OpenAICodexRequestZstd = true + enableCodexRequestZstd(t) oauth := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} readErr := errors.New("connection reset while reading body") broken, err := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) require.NoError(t, err) broken.Header.Set("Content-Type", "application/json") - broken = broken.WithContext(withCodexTransportFixture(context.Background(), true)) broken.Body = io.NopCloser(&prefixThenErrorReader{prefix: []byte(`{"model":"gpt-5.5",`), err: readErr}) broken.GetBody = nil - _, err = svc.prepareOpenAICodexWireRequest(broken, oauth) + _, err = prepareOpenAICodexWireRequest(broken, oauth) require.ErrorIs(t, err, readErr, "不可重放请求读取失败必须显式报错") require.Empty(t, broken.Header.Get("Content-Encoding")) replayable, err := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", bytes.NewReader([]byte(`{"model":"gpt-5.5"}`))) require.NoError(t, err) replayable.Header.Set("Content-Type", "application/json") - replayable = replayable.WithContext(withCodexTransportFixture(context.Background(), true)) replayable.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(&prefixThenErrorReader{prefix: []byte(`{"mo`), err: readErr}), nil } - same, err := svc.prepareOpenAICodexWireRequest(replayable, oauth) + same, err := prepareOpenAICodexWireRequest(replayable, oauth) require.NoError(t, err) require.Same(t, replayable, same, "可重放请求快照失败时原请求原样明文发送") plain, err := io.ReadAll(replayable.Body) diff --git a/backend/internal/service/openai_codex_turn_scope_test.go b/backend/internal/service/openai_codex_turn_scope_test.go deleted file mode 100644 index b45c61500950..000000000000 --- a/backend/internal/service/openai_codex_turn_scope_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestOpenAIWSCodexTurnScopeRetainsFirstValueAndRejectsOtherOwners(t *testing.T) { - service := &OpenAIGatewayService{} - store := NewOpenAIWSStateStore(nil) - account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - c, _ := newTurnStateTestContext(t, 11, "session") - firstScope := openAIWSTurnStateScope(c, account, "execution") - service.commitOpenAIWSSessionTurnState(c, account, store, 1, "execution", "first") - service.commitOpenAIWSSessionTurnState(c, account, store, 1, "execution", "later") - service.commitOpenAIWSSessionTurnState(c, account, store, 1, "execution", "") - state, ok := store.GetSessionTurnState(1, firstScope, account.ID) - require.True(t, ok) - require.Equal(t, "first", state) - other := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - _, ok = store.GetSessionTurnState(1, firstScope, other.ID) - require.False(t, ok) - foreign := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"first"}} - service.guardOpenAICodexTurnStateEcho(c, other, foreign) - require.Empty(t, foreign.Get(openAICodexTurnStateHeader)) - stageCodexLogicalTurn(c, []byte(`{"client_metadata":{"turn_id":"next-turn"}}`)) - nextScope := openAIWSTurnStateScope(c, account, "execution") - require.NotEqual(t, firstScope, nextScope) - _, ok = store.GetSessionTurnState(1, nextScope, account.ID) - require.False(t, ok) - prior := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"first"}} - service.guardOpenAICodexTurnStateEcho(c, account, prior) - require.Empty(t, prior.Get(openAICodexTurnStateHeader)) - c.Request.Header.Del(openAIWSTurnMetadataHeader) - stageCodexLogicalTurn(c, nil) - require.Empty(t, openAIWSTurnStateScope(c, account, "execution")) - apiKey := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - require.Equal(t, "execution", openAIWSTurnStateScope(c, apiKey, "execution")) - require.Equal(t, "11\x00session", openAICodexTurnStateSeed(c, apiKey)) -} - -func TestCodexTurnStateCannotCrossLogicalTurn(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest("POST", "/v1/responses", nil) - c.Request.Header.Set("session-id", "session") - s := &OpenAIGatewayService{} - account := &Account{ID: 7} - stageCodexLogicalTurn(c, []byte(`{"client_metadata":{"turn_id":"turn-one"}}`)) - s.noteOpenAICodexTurnStateProvenance(c, account) - headers := http.Header{} - headers.Set(openAICodexTurnStateHeader, "opaque") - s.guardOpenAICodexTurnStateEcho(c, account, headers) - require.Equal(t, "opaque", headers.Get(openAICodexTurnStateHeader)) - stageCodexLogicalTurn(c, []byte(`{"client_metadata":{"turn_id":"turn-two"}}`)) - s.guardOpenAICodexTurnStateEcho(c, account, headers) - require.Empty(t, headers.Get(openAICodexTurnStateHeader)) -} diff --git a/backend/internal/service/openai_codex_turn_state.go b/backend/internal/service/openai_codex_turn_state.go index 6935304d4801..774041f18a9e 100644 --- a/backend/internal/service/openai_codex_turn_state.go +++ b/backend/internal/service/openai_codex_turn_state.go @@ -22,20 +22,14 @@ const openAICodexTurnStateHeader = "x-codex-turn-state" // 账号,出站守卫据此剥离已知异账号的回带值。 type openAICodexTurnStateOrigin struct { accountID int64 - identity string - state string expiresAt time.Time } -// API-key destinations retain their existing session/owner contract. Codex -// OAuth state additionally belongs to one logical turn. -func openAICodexTurnStateUsesSessionContract(account *Account) bool { - return account != nil && (account.Type == AccountTypeAPIKey || account.Platform == PlatformGrok) -} - -// Optional account preserves the strict Codex scope for callers without a -// destination. Production reads, commits and clears always pass the account. -func openAICodexTurnStateSeed(c *gin.Context, accounts ...*Account) string { +// openAICodexTurnStateSeed 返回溯源表键:API Key + 客户端原始会话标识。 +// 客户端会话标识取自请求头(与指纹收敛的 thread 派生同源,见 +// extractClientSessionID),确保同一下游会话的记录/守卫两侧使用同一键。 +// 无会话标识时返回空串,表示不做跟踪(保持透传现状)。 +func openAICodexTurnStateSeed(c *gin.Context) string { if c == nil || c.Request == nil { return "" } @@ -43,29 +37,7 @@ func openAICodexTurnStateSeed(c *gin.Context, accounts ...*Account) string { if sessionID == "" { return "" } - seed := strconv.FormatInt(getAPIKeyIDFromContext(c), 10) + "\x00" + sessionID - if len(accounts) > 0 && openAICodexTurnStateUsesSessionContract(accounts[0]) { - return seed - } - turnID := codexLogicalTurnID(c) - if turnID == "" { - return "" - } - return seed + "\x00" + turnID -} - -func openAIWSTurnStateScope(c *gin.Context, account *Account, sessionHash string) string { - if sessionHash == "" || openAICodexTurnStateUsesSessionContract(account) { - return sessionHash - } - if turn := codexLogicalTurnID(c); turn != "" { - scope := sessionHash + "\x00" + turn - if account != nil && account.IsOpenAIOAuthLike() { - scope += "\x00" + CodexCredentialOwnerIdentity(account) - } - return scope - } - return "" + return strconv.FormatInt(getAPIKeyIDFromContext(c), 10) + "\x00" + sessionID } // relayOpenAICodexTurnState 将上游响应中的 turn-state 显式写入下游响应头, @@ -78,14 +50,14 @@ func (s *OpenAIGatewayService) relayOpenAICodexTurnState(c *gin.Context, account return } canonical := http.CanonicalHeaderKey(openAICodexTurnStateHeader) - state := s.firstCommittedCodexTurnState(c, account, extractOpenAICodexTurnState(upstream)) + state := extractOpenAICodexTurnState(upstream) if state == "" { c.Writer.Header().Del(canonical) - s.clearOpenAICodexTurnStateProvenance(c, account) + s.clearOpenAICodexTurnStateProvenance(c) return } c.Writer.Header().Set(canonical, state) - s.noteOpenAICodexTurnStateProvenance(c, account, state) + s.noteOpenAICodexTurnStateProvenance(c, account) } // stageOpenAICodexTurnState 将上游 turn-state 暂存到延迟提交的响应头集合 @@ -114,14 +86,13 @@ func stageOpenAICodexTurnState(dst *http.Header, upstream http.Header) { // noteStagedOpenAICodexTurnStateCommitted 在暂存响应头真正写入下游时记录 // 铸造账号——只有此刻客户端才确定收到了该 blob,溯源表才与客户端持有的 // 值一致(否则被 failover 丢弃的 attempt 会污染溯源,导致后续误剥离)。 -// OAuth 同主体同 turn 的首个已提交值保持不变;API Key 保留缺失即清除的契约。 +// 成功提交但无 state 表示上一来源已失效,必须清除旧 provenance。 func (s *OpenAIGatewayService) noteStagedOpenAICodexTurnStateCommitted(c *gin.Context, account *Account, staged http.Header) { - state := s.firstCommittedCodexTurnState(c, account, extractOpenAICodexTurnState(staged)) - if state == "" { - s.clearOpenAICodexTurnStateProvenance(c, account) + if staged == nil || strings.TrimSpace(staged.Get(openAICodexTurnStateHeader)) == "" { + s.clearOpenAICodexTurnStateProvenance(c) return } - s.noteOpenAICodexTurnStateProvenance(c, account, state) + s.noteOpenAICodexTurnStateProvenance(c, account) } func extractOpenAICodexTurnState(upstream http.Header) string { @@ -132,92 +103,34 @@ func extractOpenAICodexTurnState(upstream http.Header) string { } // noteOpenAICodexTurnStateProvenance 记录(下游会话 → 铸造账号)。 -func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context, account *Account, states ...string) { +func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context, account *Account) { if s == nil || account == nil || account.ID <= 0 { return } - seed := openAICodexTurnStateSeed(c, account) + seed := openAICodexTurnStateSeed(c) if seed == "" { return } - state := "" - if len(states) > 0 { - state = strings.TrimSpace(states[0]) - } - strict := account.IsOpenAIOAuthLike() - if strict && state == "" { - return - } - origin := openAICodexTurnStateOrigin{ + s.openaiCodexTurnStateOrigins.Store(seed, openAICodexTurnStateOrigin{ accountID: account.ID, - identity: CodexCredentialOwnerIdentity(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 == CodexCredentialOwnerIdentity(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) { +func (s *OpenAIGatewayService) clearOpenAICodexTurnStateProvenance(c *gin.Context) { if s == nil { return } - if seed := openAICodexTurnStateSeed(c, accounts...); seed != "" { + if seed := openAICodexTurnStateSeed(c); seed != "" { s.openaiCodexTurnStateOrigins.Delete(seed) } } // commitOpenAIWSSessionTurnState updates the server-side WS fallback state at -// the same commit boundary as the downstream response. Codex retains its first -// value within the logical turn. API destinations retain the existing contract -// where an empty successful response clears the prior session owner. A failed -// attempt must never call this helper. +// the same commit boundary as the downstream response. An empty successful +// state invalidates every previous owner for the session; a failed attempt +// must never call this helper. func (s *OpenAIGatewayService) commitOpenAIWSSessionTurnState( c *gin.Context, account *Account, @@ -226,30 +139,26 @@ func (s *OpenAIGatewayService) commitOpenAIWSSessionTurnState( sessionHash string, turnState string, ) { - turnState = s.firstCommittedCodexTurnState(c, account, strings.TrimSpace(turnState)) - stateScope := openAIWSTurnStateScope(c, account, sessionHash) - if stateStore != nil && stateScope != "" { - if !openAICodexTurnStateUsesSessionContract(account) && account != nil { - if first, ok := stateStore.GetSessionTurnState(groupID, stateScope, account.ID); ok { - turnState = first - } - } + turnState = strings.TrimSpace(turnState) + if stateStore != nil && sessionHash != "" { if turnState == "" { - stateStore.DeleteSessionTurnState(groupID, stateScope) + stateStore.DeleteSessionTurnState(groupID, sessionHash) } else if account != nil && account.ID > 0 { - stateStore.BindSessionTurnState(groupID, stateScope, account.ID, turnState, s.openAIWSSessionStickyTTL()) + stateStore.BindSessionTurnState(groupID, sessionHash, account.ID, turnState, s.openAIWSSessionStickyTTL()) } } if turnState == "" { - s.clearOpenAICodexTurnStateProvenance(c, account) + s.clearOpenAICodexTurnStateProvenance(c) return } - s.noteOpenAICodexTurnStateProvenance(c, account, turnState) + s.noteOpenAICodexTurnStateProvenance(c, account) } // guardOpenAICodexTurnStateEcho 出站守卫:客户端回带的 turn-state 只有在 // 能证明由当前账号铸造时才保留。来源未知、过期、缺少会话标识或来自其他 -// 账号的值均剥离。此守卫只处理 STATE 回显,不以 STATE 长度或此来源表授予模型资格。 +// 账号的值均剥离。只剥离、不注入—— +// /responses 路径的客户端是真实 Codex,会按自身回合语义自行回带;服务端 +// 注入是 Claude 兼容桥(无法回带的客户端)的专属行为。 func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, account *Account, h http.Header) { if h == nil { return @@ -261,7 +170,7 @@ func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, acc h.Del(openAICodexTurnStateHeader) return } - seed := openAICodexTurnStateSeed(c, account) + seed := openAICodexTurnStateSeed(c) if seed == "" { h.Del(openAICodexTurnStateHeader) return @@ -284,11 +193,6 @@ func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, acc } if origin.accountID != account.ID { h.Del(openAICodexTurnStateHeader) - return - } - if account.IsOpenAIOAuthLike() && (origin.identity != CodexCredentialOwnerIdentity(account) || - origin.state == "" || strings.TrimSpace(h.Get(openAICodexTurnStateHeader)) != origin.state) { - h.Del(openAICodexTurnStateHeader) } } diff --git a/backend/internal/service/openai_codex_turn_state_test.go b/backend/internal/service/openai_codex_turn_state_test.go index 17aa5eb02588..18f5bd0de7d5 100644 --- a/backend/internal/service/openai_codex_turn_state_test.go +++ b/backend/internal/service/openai_codex_turn_state_test.go @@ -19,7 +19,6 @@ func newTurnStateTestContext(t *testing.T, apiKeyID int64, sessionID string) (*g rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - c.Request.Header.Set(openAIWSTurnMetadataHeader, `{"turn_id":"turn-fixture"}`) if sessionID != "" { c.Request.Header.Set("session_id", sessionID) } @@ -31,11 +30,11 @@ func newTurnStateTestContext(t *testing.T, apiKeyID int64, sessionID string) (*g func TestOpenAICodexTurnStateSeed(t *testing.T) { c, _ := newTurnStateTestContext(t, 7, "sess-1") - require.Equal(t, "7\x00sess-1\x00turn-fixture", openAICodexTurnStateSeed(c)) + require.Equal(t, "7\x00sess-1", openAICodexTurnStateSeed(c)) // 连字符形式优先(Codex CLI 标准头) c.Request.Header.Set("session-id", "sess-hyphen") - require.Equal(t, "7\x00sess-hyphen\x00turn-fixture", openAICodexTurnStateSeed(c)) + require.Equal(t, "7\x00sess-hyphen", openAICodexTurnStateSeed(c)) // 无会话标识 → 不跟踪 cNoSession, _ := newTurnStateTestContext(t, 7, "") @@ -55,7 +54,7 @@ func TestRelayOpenAICodexTurnState_SetsHeaderAndRecordsProvenance(t *testing.T) require.Equal(t, "blob-A", c.Writer.Header().Get("X-Codex-Turn-State")) - raw, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-relay\x00turn-fixture") + raw, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-relay") require.True(t, ok) origin, ok := raw.(openAICodexTurnStateOrigin) require.True(t, ok) @@ -68,7 +67,7 @@ func TestRelayOpenAICodexTurnState_ClearsStaleValueWhenUpstreamAbsent(t *testing c, _ := newTurnStateTestContext(t, 7, "sess-stale") // 模拟上一 failover attempt 残留的值 c.Writer.Header().Set("X-Codex-Turn-State", "blob-old") - svc.openaiCodexTurnStateOrigins.Store("7\x00sess-stale\x00turn-fixture", openAICodexTurnStateOrigin{ + svc.openaiCodexTurnStateOrigins.Store("7\x00sess-stale", openAICodexTurnStateOrigin{ accountID: 42, expiresAt: time.Now().Add(time.Minute), }) @@ -76,7 +75,7 @@ func TestRelayOpenAICodexTurnState_ClearsStaleValueWhenUpstreamAbsent(t *testing svc.relayOpenAICodexTurnState(c, &Account{ID: 43}, http.Header{}) require.Empty(t, c.Writer.Header().Get("X-Codex-Turn-State")) - _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-stale\x00turn-fixture") + _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-stale") require.False(t, ok) } @@ -91,12 +90,12 @@ func TestStageOpenAICodexTurnState_StagedHeaders(t *testing.T) { stageOpenAICodexTurnState(&staged, upstream) require.NotNil(t, staged) require.Equal(t, "blob-B", staged.Get("X-Codex-Turn-State")) - _, noted := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged\x00turn-fixture") + _, noted := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged") require.False(t, noted, "暂存阶段不得记录溯源:该 attempt 仍可能 failover 丢弃") // 真正提交时才记录 svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 44}, staged) - raw, ok := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged\x00turn-fixture") + raw, ok := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged") require.True(t, ok) origin, ok := raw.(openAICodexTurnStateOrigin) require.True(t, ok) @@ -131,7 +130,7 @@ func TestStagedTurnState_AbandonedAttemptDoesNotPoisonProvenance(t *testing.T) { svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 52}, h) require.Equal(t, "blob-A", h.Get("x-codex-turn-state")) - raw, ok := svc.openaiCodexTurnStateOrigins.Load("11\x00sess-abandoned\x00turn-fixture") + raw, ok := svc.openaiCodexTurnStateOrigins.Load("11\x00sess-abandoned") require.True(t, ok) origin, ok := raw.(openAICodexTurnStateOrigin) require.True(t, ok) @@ -141,22 +140,22 @@ func TestStagedTurnState_AbandonedAttemptDoesNotPoisonProvenance(t *testing.T) { func TestNoteStagedOpenAICodexTurnStateCommitted_ClearsProvenanceWithoutState(t *testing.T) { svc := &OpenAIGatewayService{} c, _ := newTurnStateTestContext(t, 12, "sess-nostate") - svc.openaiCodexTurnStateOrigins.Store("12\x00sess-nostate\x00turn-fixture", openAICodexTurnStateOrigin{ + svc.openaiCodexTurnStateOrigins.Store("12\x00sess-nostate", openAICodexTurnStateOrigin{ accountID: 59, expiresAt: time.Now().Add(time.Minute), }) svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 60}, nil) - _, ok := svc.openaiCodexTurnStateOrigins.Load("12\x00sess-nostate\x00turn-fixture") + _, ok := svc.openaiCodexTurnStateOrigins.Load("12\x00sess-nostate") require.False(t, ok) - svc.openaiCodexTurnStateOrigins.Store("12\x00sess-nostate\x00turn-fixture", openAICodexTurnStateOrigin{ + svc.openaiCodexTurnStateOrigins.Store("12\x00sess-nostate", openAICodexTurnStateOrigin{ accountID: 59, expiresAt: time.Now().Add(time.Minute), }) svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 60}, http.Header{"X-Request-Id": []string{"rid"}}) - _, ok = svc.openaiCodexTurnStateOrigins.Load("12\x00sess-nostate\x00turn-fixture") + _, ok = svc.openaiCodexTurnStateOrigins.Load("12\x00sess-nostate") require.False(t, ok) } @@ -205,14 +204,14 @@ func TestGuardOpenAICodexTurnStateEcho(t *testing.T) { t.Run("expired_provenance_strips_echo_and_prunes", func(t *testing.T) { svc := &OpenAIGatewayService{} c, _ := newTurnStateTestContext(t, 7, "sess-g4") - svc.openaiCodexTurnStateOrigins.Store("7\x00sess-g4\x00turn-fixture", openAICodexTurnStateOrigin{ + svc.openaiCodexTurnStateOrigins.Store("7\x00sess-g4", openAICodexTurnStateOrigin{ accountID: 42, expiresAt: time.Now().Add(-time.Minute), }) h := newOutbound("blob-A") svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h) require.Empty(t, h.Get("x-codex-turn-state")) - _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-g4\x00turn-fixture") + _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-g4") require.False(t, ok) }) diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index a5d6f7d30467..604be7530758 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1135,7 +1135,7 @@ func TestForwardAsAnthropic_KeepsInboundHyphenSessionHeaderOverCacheKeyFallback( require.Empty(t, upstream.requests[0].Header.Get("conversation_id")) } -func TestForwardAsAnthropic_ReusesOAuthCodexTurnStateOnlyWithinExplicitTurn(t *testing.T) { +func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -1144,7 +1144,6 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnStateOnlyWithinExplicitTurn(t *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, @@ -1167,7 +1166,6 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnStateOnlyWithinExplicitTurn(t *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) @@ -1175,13 +1173,11 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnStateOnlyWithinExplicitTurn(t *t require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) - // A subsequent request for the same explicit turn may carry its first STATE. - secondBody := firstBody + 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}`) 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) @@ -1192,19 +1188,6 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnStateOnlyWithinExplicitTurn(t *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) { @@ -1258,7 +1241,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { } } -func TestForwardAsAnthropic_OAuthDigestFallbackKeepsSessionWithoutUnprovenTurnState(t *testing.T) { +func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -1309,7 +1292,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackKeepsSessionWithoutUnprovenTurnSt require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) - 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.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state")) 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()) @@ -1367,7 +1350,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.Empty(t, upstream.requests[1].Header.Get("x-codex-turn-state"), "metadata preserves session identity but does not establish a shared logical turn") + require.Equal(t, "turn_state_metadata_first", upstream.requests[1].Header.Get("x-codex-turn-state")) 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()) @@ -1424,7 +1407,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnch require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) - 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.Equal(t, "turn_state_cache_anchor_first", upstream.requests[1].Header.Get("x-codex-turn-state")) 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 043e7af276b8..9cd556e00a25 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -19,7 +19,6 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (_ *OpenAIForwardResult, forwardErr error) { - stageCodexLogicalTurn(c, body) diagnosticIncomingBody := body // Snapshot the client body for the request integrity check before any // rewrite; re-staged on every entry so a failover never reuses a stale copy. @@ -426,10 +425,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco isCompactRequest := compactPath requestedModel := reqModel - billingModel, upstreamModel, modelPolicyErr := resolveOpenAIForwardMappedModelsContext(ctx, account, requestedModel, isCompactRequest) - if modelPolicyErr != nil { - return nil, modelPolicyErr - } + billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest) if isCompactRequest { if compactModel := s.resolveOpenAICompactFallbackModel(account, requestedModel); compactModel != "" { upstreamModel = compactModel @@ -440,7 +436,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() && !responsesLite && !compatMessagesBridge && !nativeCNResponses { + if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { markPatchSet("instructions", defaultCodexSynthInstructions(upstreamModel)) } if billingModel != requestedModel { @@ -560,7 +556,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{ IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, - SkipDefaultInstructions: responsesLite, OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages, }) } @@ -1480,11 +1475,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestPrepared(ctx context.Context, } // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), // 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。 - if _, staged := c.Get(codexLogicalTurnContextKey); !staged { - stageCodexLogicalTurn(c, body) - } s.guardOpenAICodexTurnStateEcho(c, account, req.Header) - req = withCodexExpectedModel(req, codexRequestBodyModel(body)) if account.UsesOpenAICodexProtocol() { compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) // 真实 Codex 只发送连字符形式的 session-id / thread-id;下划线形式的 @@ -1560,7 +1551,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestPrepared(ctx context.Context, if err := applyMappedGPT55LiteCompatibility(req, account, body); err != nil { return nil, err } - return withCodexDownstreamContext(req, c), nil + return req, nil } // codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。 diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 8259a7a89775..2f83dacd0a56 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -33,8 +33,6 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - stageCodexLogicalTurn(c, body) - c.Set(openAICompatTurnStateCommittedContextKey, false) // 工具 Schema 清洗必须先于所有分流:下游每条路径(原生 Anthropic 直通、 // Chat Completions 转换、Responses 转换)都会把 tools 原样带给上游,而 // xAI / Moonshot 等严格校验方会因 input_schema 里的 required:null 或 @@ -151,8 +149,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( compatContinuationDisabled := compatContinuationEnabled && s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, compatSessionSeed) compatTurnState := "" - // Keep OAuth history for prompt caching. Opaque turn state is independently - // scoped to an explicit logical turn; a cache/session key cannot extend it. + // ChatGPT/Codex credentials rely 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. if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() && previousResponseID == "" && !compatContinuationDisabled { compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq) } @@ -413,7 +412,6 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" { upstreamReq.Header.Set("x-codex-turn-state", compatTurnState) } - upstreamReq = withCodexExpectedModel(upstreamReq, upstreamModel) // 7. Send request proxyURL := "" @@ -519,6 +517,12 @@ 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 @@ -541,10 +545,6 @@ 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) } @@ -688,20 +688,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) } c.Header("Content-Type", "application/json; charset=utf-8") - 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) - } + c.JSON(http.StatusOK, anthropicResp) result := &OpenAIForwardResult{ RequestID: requestID, @@ -996,7 +983,6 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( var usage OpenAIUsage responseID := "" terminalEventType := "" - completedTurn := false var firstTokenMs *int firstChunk := true clientDisconnected := false @@ -1090,7 +1076,6 @@ 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 @@ -1226,9 +1211,6 @@ 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 e59e769e0511..3440f65a9111 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -165,8 +165,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } if account != nil && account.UsesOpenAICodexProtocol() { - lite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) - if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" && !lite { + if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" { rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field" MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body) @@ -178,7 +177,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( }) return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason) } - if !lite && isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() { + if 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) @@ -725,13 +724,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), // 剥离后再出站(openai_codex_turn_state.go)。 - if c != nil { - if _, staged := c.Get(codexLogicalTurnContextKey); !staged { - stageCodexLogicalTurn(c, body) - } - } s.guardOpenAICodexTurnStateEcho(c, account, req.Header) - req = withCodexExpectedModel(req, codexRequestBodyModel(body)) // 覆盖入站鉴权残留,并注入上游认证 req.Header.Del("authorization") @@ -838,7 +831,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if err := applyMappedGPT55LiteCompatibility(req, account, body); err != nil { return nil, err } - return withCodexDownstreamContext(req, c), nil + return req, nil } func stripOpenAILegacyResponsesBeta(headers http.Header) { @@ -2037,7 +2030,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) var stagedTurnState http.Header - stageOpenAICodexTurnState(&stagedTurnState, s.codexTurnStateResponseHeaders(c, account, resp.Header)) + stageOpenAICodexTurnState(&stagedTurnState, 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) @@ -2539,7 +2532,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } - s.clearOpenAIProxyStreamDisconnect(account, ctx) + s.clearOpenAIProxyStreamDisconnect(account) recordOpenAIRefusalRecovery(ctx, c, account, "sse", false, refusalEarlyEmitted, openAIRefusalActionRewritten, refusalStream.evidence()) return resultWithUsage(), nil case openAIRefusalStreamHold: @@ -2616,7 +2609,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if clientDisconnected { return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", err) } - s.recordOpenAIProxyStreamDisconnect(account, err, upstreamRequestID, ctx) + s.recordOpenAIProxyStreamDisconnect(account, err, upstreamRequestID) logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v", account.ID, @@ -2645,14 +2638,14 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, "OpenAI stream ended before a terminal event", resp.StatusCode) } s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "stream_error", nil, "OpenAI stream ended before an authoritative terminal event") - s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID, ctx) + s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID) return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event") } if terminalEventType != "response.completed" && terminalEventType != "response.done" { return resultWithUsage(), fmt.Errorf("upstream response terminated with %s", terminalEventType) } if sawTerminalEvent && !sawFailedEvent { - s.clearOpenAIProxyStreamDisconnect(account, ctx) + s.clearOpenAIProxyStreamDisconnect(account) } logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected) @@ -2734,7 +2727,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, s.codexTurnStateResponseHeaders(c, account, resp.Header)) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) contentType := resp.Header.Get("Content-Type") if contentType == "" { @@ -2886,7 +2879,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, s.codexTurnStateResponseHeaders(c, account, resp.Header)) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, 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 5285924c01c5..28f00a7fad2b 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -71,7 +71,7 @@ func (s *OpenAIGatewayService) commitOpenAIHTTPResponseTurnState( ) { if headerApplied { if strings.TrimSpace(staged.Get(openAICodexTurnStateHeader)) != "" && (account == nil || account.ID <= 0) { - s.clearOpenAICodexTurnStateProvenance(c, account) + s.clearOpenAICodexTurnStateProvenance(c) return } s.noteStagedOpenAICodexTurnStateCommitted(c, account, staged) @@ -79,7 +79,7 @@ func (s *OpenAIGatewayService) commitOpenAIHTTPResponseTurnState( } // A successful body after an account-neutral header commit did not deliver // the new state. Clear the previous owner so the next echo fails closed. - s.clearOpenAICodexTurnStateProvenance(c, account) + s.clearOpenAICodexTurnStateProvenance(c) } func writeOpenAIHTTPResponseData(c *gin.Context, statusCode int, contentType string, body []byte) error { @@ -132,7 +132,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // 客户端会在同回合的后续请求中回带(openai_codex_turn_state.go)。 // OpenAI 首个语义输出前只暂存,溯源在 applyAttemptResponseHeaders 真正提交时记录。 if stageFirstOutput { - stageOpenAICodexTurnState(&attemptResponseHeaders, s.codexTurnStateResponseHeaders(c, account, resp.Header)) + stageOpenAICodexTurnState(&attemptResponseHeaders, resp.Header) } else { s.relayOpenAICodexTurnState(c, account, resp.Header) } @@ -182,7 +182,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. // Headers may already have been committed by an account-neutral keepalive. // In that case the staged state was not delivered; invalidate any previous // owner instead of claiming the client received the new account's blob. - s.clearOpenAICodexTurnStateProvenance(c, account) + s.clearOpenAICodexTurnStateProvenance(c) } w := c.Writer @@ -518,7 +518,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. terminalEventType = "response.failed" } if sawTerminalEvent && !sawFailedEvent && terminalResponseErr == nil { - s.clearOpenAIProxyStreamDisconnect(account, ctx) + s.clearOpenAIProxyStreamDisconnect(account) } if !sawTerminalEvent && !openAIStreamClientOutputStarted(c, clientOutputStarted) && !eventShouldFlush { return resultWithUsage(), s.newOpenAIStreamFailoverError( @@ -535,7 +535,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if !sawTerminalEvent { s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "stream_error", nil, "OpenAI stream ended before an authoritative terminal event") if openAIStreamClientOutputStarted(c, clientOutputStarted) && !clientDisconnected { - s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID, ctx) + s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID) } return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event") } @@ -558,17 +558,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if scanErr == nil { return nil, nil, false } - if errors.Is(scanErr, ErrCodexModelMismatch) { - // The model guard stopped a reply that declared another model. - // 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(), ErrCodexModelMismatch, 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( @@ -615,7 +604,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if clientDisconnected { return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true } - s.recordOpenAIProxyStreamDisconnect(account, scanErr, upstreamRequestID, ctx) + s.recordOpenAIProxyStreamDisconnect(account, scanErr, upstreamRequestID) code, message := classifyOpenAIUpstreamStreamReadError(scanErr) sendErrorEvent(code, message) return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true @@ -1648,9 +1637,6 @@ func extractOpenAIResponseIDFromJSONBytes(body []byte) string { } func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) { - if IsCodexQualityRequest(ctx) { - return - } if s == nil || account == nil || account.ID <= 0 { return } @@ -1883,7 +1869,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, s.codexTurnStateResponseHeaders(c, account, resp.Header)) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) contentType := "application/json" if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { @@ -2185,7 +2171,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, s.codexTurnStateResponseHeaders(c, account, resp.Header)) + stagedTurnState, turnStateHeaderApplied := stageOpenAIHTTPResponseTurnState(c, resp.Header) contentType := "application/json; charset=utf-8" if !ok { diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 4459f89f94a2..9f87735105c8 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -790,20 +790,12 @@ func prioritizeOpenAICompactAccounts(accounts []*Account) []*Account { // would be sent for a given request, honoring the legacy compact-only mapping // when the caller is on the /responses/compact path. func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string { - model, _ := resolveOpenAIAccountUpstreamModelForRequestContext(context.Background(), account, requestedModel, requireCompact) - return model -} - -func resolveOpenAIAccountUpstreamModelForRequestContext(ctx context.Context, account *Account, requestedModel string, requireCompact bool) (string, error) { // Forward checks the raw Chat Completions fallback before passthrough. // These API-key accounts therefore apply normal account model_mapping and // upstream normalization, but never compact_model_mapping. if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { - upstreamModel, err := resolveOpenAIForwardModelContext(ctx, account, requestedModel, "") - if err != nil { - return "", err - } - return normalizeOpenAIModelForUpstream(account, upstreamModel), nil + upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "") + return normalizeOpenAIModelForUpstream(account, upstreamModel) } // Passthrough accounts only replace authentication. Their Forward path @@ -814,7 +806,7 @@ func resolveOpenAIAccountUpstreamModelForRequestContext(ctx context.Context, acc if account != nil && account.IsOpenAIPassthroughEnabled() { upstreamModel := strings.TrimSpace(requestedModel) if upstreamModel == "" { - return "", nil + return "" } if requireCompact { // forwardOpenAIPassthrough resolves compact mappings from the client @@ -827,36 +819,33 @@ func resolveOpenAIAccountUpstreamModelForRequestContext(ctx context.Context, acc } if account.IsOpenAIApiKey() { if baseModel, _, accepted := resolveOpenAIModelReasoningAlias(upstreamModel); accepted { - return baseModel, nil + return baseModel } } - return upstreamModel, nil + return upstreamModel } if requireCompact && account != nil { if compactModel, matched := account.ResolveCompactMappedModel(strings.TrimSpace(requestedModel)); matched { if compactModel = strings.TrimSpace(compactModel); compactModel != "" { if account.IsOpenAIApiKey() { - return normalizeOpenAIModelForUpstream(account, compactModel), nil + return normalizeOpenAIModelForUpstream(account, compactModel) } - return compactModel, nil + return compactModel } } } - upstreamModel, err := resolveOpenAIForwardModelContext(ctx, account, requestedModel, "") - if err != nil { - return "", err - } + upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "") if upstreamModel == "" { - return "", nil + return "" } if requireCompact { compactModel := resolveOpenAICompactForwardModel(account, upstreamModel) if compactModel != upstreamModel { - return compactModel, nil + return compactModel } } - return normalizeOpenAIModelForUpstream(account, upstreamModel), nil + return normalizeOpenAIModelForUpstream(account, upstreamModel) } func (s *OpenAIGatewayService) filterOpenAIAccountsForGroupPrivacy(ctx context.Context, groupID *int64, accounts []Account) []Account { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index bcd1f5f4a2f4..925997769223 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -475,7 +475,6 @@ type OpenAIGatewayService struct { userGroupRateResolver *userGroupRateResolver httpUpstream HTTPUpstream pluginManager *PluginManager - nativeCodexRuntime *NativeCodexRuntime deferredService *DeferredService openAITokenProvider *OpenAITokenProvider grokTokenProvider *GrokTokenProvider diff --git a/backend/internal/service/openai_gateway_turn_state_commit_test.go b/backend/internal/service/openai_gateway_turn_state_commit_test.go index 4fa3e0b17bc8..97108b491e9a 100644 --- a/backend/internal/service/openai_gateway_turn_state_commit_test.go +++ b/backend/internal/service/openai_gateway_turn_state_commit_test.go @@ -14,9 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -// The seed follows the destination: API-key accounts keep the per-session -// provenance key, Codex OAuth state is additionally scoped to the turn. -func newOpenAITurnStateCommitContext(t *testing.T, writer gin.ResponseWriter, destination ...*Account) (*gin.Context, string) { +func newOpenAITurnStateCommitContext(t *testing.T, writer gin.ResponseWriter) (*gin.Context, string) { t.Helper() gin.SetMode(gin.TestMode) if writer == nil { @@ -27,9 +25,8 @@ func newOpenAITurnStateCommitContext(t *testing.T, writer gin.ResponseWriter, de c, _ := gin.CreateTestContext(writer) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) c.Request.Header.Set("session_id", "sess-http-commit") - c.Request.Header.Set(openAIWSTurnMetadataHeader, `{"turn_id":"turn-fixture"}`) c.Set("api_key", &APIKey{ID: 801}) - return c, openAICodexTurnStateSeed(c, destination...) + return c, openAICodexTurnStateSeed(c) } func seedOpenAITurnStateOrigin(svc *OpenAIGatewayService, seed string, accountID int64) { @@ -125,7 +122,7 @@ func TestOpenAINonStreamingTurnStateCommitsOnlyAfterDownstreamWrite(t *testing.T failedRecorder := httptest.NewRecorder() failedBase, _ := gin.CreateTestContext(failedRecorder) failedWriter := &failingGinWriter{ResponseWriter: failedBase.Writer, failAfter: 0} - failedContext, seed := newOpenAITurnStateCommitContext(t, failedWriter, accountB) + failedContext, seed := newOpenAITurnStateCommitContext(t, failedWriter) seedOpenAITurnStateOrigin(svc, seed, 301) _, err := svc.handleNonStreamingResponse( @@ -135,7 +132,7 @@ func TestOpenAINonStreamingTurnStateCommitsOnlyAfterDownstreamWrite(t *testing.T require.ErrorContains(t, err, "write downstream OpenAI response") requireOpenAITurnStateOrigin(t, svc, seed, 301) - successContext, successSeed := newOpenAITurnStateCommitContext(t, nil, accountB) + successContext, successSeed := newOpenAITurnStateCommitContext(t, nil) require.Equal(t, seed, successSeed) _, err = svc.handleNonStreamingResponse( context.Background(), openAITurnStateJSONResponse("state-B"), successContext, @@ -145,7 +142,7 @@ func TestOpenAINonStreamingTurnStateCommitsOnlyAfterDownstreamWrite(t *testing.T require.Equal(t, "state-B", successContext.Writer.Header().Get(openAICodexTurnStateHeader)) requireOpenAITurnStateOrigin(t, svc, seed, accountB.ID) - noStateContext, noStateSeed := newOpenAITurnStateCommitContext(t, nil, accountB) + noStateContext, noStateSeed := newOpenAITurnStateCommitContext(t, nil) require.Equal(t, seed, noStateSeed) _, err = svc.handleNonStreamingResponse( context.Background(), openAITurnStateJSONResponse(""), noStateContext, @@ -168,7 +165,7 @@ func TestOpenAIPassthroughTurnStateUsesTheSameDownstreamCommitBoundary(t *testin failedRecorder := httptest.NewRecorder() failedBase, _ := gin.CreateTestContext(failedRecorder) failedWriter := &failingGinWriter{ResponseWriter: failedBase.Writer, failAfter: 0} - failedContext, seed := newOpenAITurnStateCommitContext(t, failedWriter, accountB) + failedContext, seed := newOpenAITurnStateCommitContext(t, failedWriter) seedOpenAITurnStateOrigin(svc, seed, 401) _, err := svc.handleStreamingResponsePassthrough( @@ -178,7 +175,7 @@ func TestOpenAIPassthroughTurnStateUsesTheSameDownstreamCommitBoundary(t *testin require.NoError(t, err) requireOpenAITurnStateOrigin(t, svc, seed, 401) - successContext, successSeed := newOpenAITurnStateCommitContext(t, nil, accountB) + successContext, successSeed := newOpenAITurnStateCommitContext(t, nil) require.Equal(t, seed, successSeed) _, err = svc.handleStreamingResponsePassthrough( context.Background(), openAITurnStateStreamingResponse("state-B"), successContext, @@ -192,7 +189,7 @@ func TestOpenAIPassthroughTurnStateUsesTheSameDownstreamCommitBoundary(t *testin failedRecorder := httptest.NewRecorder() failedBase, _ := gin.CreateTestContext(failedRecorder) failedWriter := &failingGinWriter{ResponseWriter: failedBase.Writer, failAfter: 0} - failedContext, seed := newOpenAITurnStateCommitContext(t, failedWriter, accountB) + failedContext, seed := newOpenAITurnStateCommitContext(t, failedWriter) seedOpenAITurnStateOrigin(svc, seed, 403) _, err := svc.handleNonStreamingResponsePassthrough( @@ -202,7 +199,7 @@ func TestOpenAIPassthroughTurnStateUsesTheSameDownstreamCommitBoundary(t *testin require.ErrorContains(t, err, "write downstream OpenAI passthrough response") requireOpenAITurnStateOrigin(t, svc, seed, 403) - successContext, successSeed := newOpenAITurnStateCommitContext(t, nil, accountB) + successContext, successSeed := newOpenAITurnStateCommitContext(t, nil) require.Equal(t, seed, successSeed) _, err = svc.handleNonStreamingResponsePassthrough( context.Background(), openAITurnStateJSONResponse("state-B"), successContext, diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 75f57d3b45ce..2a747771093c 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -20,9 +20,6 @@ import ( // OpenAIRecordUsageInput input for recording usage type OpenAIRecordUsageInput struct { - // Captured from the authenticated diagnostic context before async billing. - // Accounting is unchanged; diagnostic success cannot recover account health. - CodexQuality bool Result *OpenAIForwardResult APIKey *APIKey User *User @@ -157,7 +154,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if result == nil { return errors.New("openai usage result is nil") } - s.observeOpenAIUsageAccountHealth(ctx, input) + if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI { + s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID) + } apiKey := input.APIKey user := input.User @@ -1178,25 +1177,15 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc return } - qualityObservation := IsCodexQualityRequest(ctx) go func() { updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil && !qualityObservation { + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { notifyOpenAIAutoReset(accountID) } }() } -func (s *OpenAIGatewayService) observeOpenAIUsageAccountHealth(ctx context.Context, input *OpenAIRecordUsageInput) { - if input.CodexQuality { - return - } - if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI { - s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID) - } -} - func (s *OpenAIGatewayService) UpdateCodexUsageSnapshotFromHeaders(ctx context.Context, accountID int64, headers http.Header) { if accountID <= 0 || headers == nil { return diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go index 4554add70949..65bafbd5448c 100644 --- a/backend/internal/service/openai_messages_continuation.go +++ b/backend/internal/service/openai_messages_continuation.go @@ -20,8 +20,6 @@ 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 @@ -294,7 +292,7 @@ func (s *OpenAIGatewayService) getOpenAICompatSessionTurnState(_ context.Context if s == nil { return "" } - key := openAICompatTurnStateKey(c, account, promptCacheKey) + key := openAICompatSessionResponseKey(c, account, promptCacheKey) if key == "" { return "" } @@ -317,7 +315,7 @@ func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Contex if s == nil { return } - key := openAICompatTurnStateKey(c, account, promptCacheKey) + key := openAICompatSessionResponseKey(c, account, promptCacheKey) state := strings.TrimSpace(turnState) if key == "" || state == "" { return @@ -326,20 +324,6 @@ 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 @@ -348,18 +332,3 @@ 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 := codexLogicalTurnID(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" + CodexCredentialOwnerIdentity(account) + "\x00" + turn -} diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 278e84865eef..4c733c434645 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -30,30 +30,6 @@ func resolveOpenAIForwardModelContext(_ context.Context, account *Account, reque return resolveOpenAIForwardModel(account, requestedModel, messagesDispatchMappedModel), nil } -func resolveOpenAIForwardMappedModelsContext(ctx context.Context, account *Account, requestedModel string, requireCompact bool) (billingModel, upstreamModel string, err error) { - requestedModel = strings.TrimSpace(requestedModel) - if account != nil && account.IsOpenAIPassthroughEnabled() { - billingModel = requestedModel - } else if account != nil { - billingModel, err = resolveOpenAIForwardModelContext(ctx, account, requestedModel, "") - if err != nil { - return "", "", err - } - billingModel = strings.TrimSpace(billingModel) - } - if billingModel == "" { - billingModel = requestedModel - } - upstreamModel, err = resolveOpenAIAccountUpstreamModelForRequestContext(ctx, account, requestedModel, requireCompact) - if err != nil { - return "", "", err - } - if strings.TrimSpace(upstreamModel) == "" { - upstreamModel = billingModel - } - return billingModel, upstreamModel, nil -} - // openAIOAuthForeignModelPrefixes 列出明确属于其他厂商家族的模型名前缀。 // Codex 上游不可能服务这些模型:转发阶段 normalizeOpenAIModelForUpstream // 对未知模型原样透传,上游必然返回不可重试的 400。 diff --git a/backend/internal/service/openai_plugin_transport.go b/backend/internal/service/openai_plugin_transport.go index 9cd73d4973cd..bc24feac7646 100644 --- a/backend/internal/service/openai_plugin_transport.go +++ b/backend/internal/service/openai_plugin_transport.go @@ -1,26 +1,6 @@ package service -import ( - "context" - "net/http" -) - -type codexWireObserverKey struct{} - -func withCodexWireObserver(ctx context.Context, observer func(*http.Request)) context.Context { - if ctx == nil { - ctx = context.Background() - } - return context.WithValue(ctx, codexWireObserverKey{}, observer) -} - -func codexWireObserverFromContext(ctx context.Context) func(*http.Request) { - if ctx == nil { - return nil - } - observer, _ := ctx.Value(codexWireObserverKey{}).(func(*http.Request)) - return observer -} +import "net/http" func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) { s.pluginManager = manager @@ -41,7 +21,7 @@ func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL // doOpenAIAccountTestUpstream 让 OpenAI OAuth 账号测试与真实转发使用同一插件路径。 // API Key 和未命中插件的账号保持各自原有的 HTTPUpstream 行为。未命中插件时,发往 // /backend-api/codex/responses 的 OAuth POST 与真实转发(doOpenAICodexUpstream)走同一 -// zstd 压缩准备函数、同一模型声明校验;TLS 指纹 / 代理 / 凭据来源不变。 +// zstd 压缩准备函数;TLS 指纹 / 代理 / 凭据来源不变。 func (s *AccountTestService) doOpenAIAccountTestUpstream( request *http.Request, proxyURL string, @@ -56,14 +36,11 @@ func (s *AccountTestService) doOpenAIAccountTestUpstream( } // 插件 round-trip 有意拿到明文请求;zstd 压缩是网关传输层的事,只在真正经 // HTTPUpstream 发出的副本上做。 - wire, err := prepareCodexTransport(request, account) + wire, err := prepareOpenAICodexWireRequest(request, account) if err != nil { return nil, err } - if observer := codexWireObserverFromContext(wire.Context()); observer != nil { - observer(wire) - } - if useTLSFallback && !account.IsOpenAIOAuthLike() { + if useTLSFallback { return s.httpUpstream.DoWithTLS( wire, proxyURL, @@ -72,12 +49,5 @@ func (s *AccountTestService) doOpenAIAccountTestUpstream( s.tlsFPProfileService.ResolveTLSProfile(account), ) } - response, err := s.httpUpstream.Do(wire, proxyURL, account.ID, account.Concurrency) - if err == nil && isCodexCredentialOwner(account) && s.openAIGatewayService != nil { - s.openAIGatewayService.observeCodexWire(wire.Context(), account, wire, response, "http") - // The same model guard as doOpenAICodexUpstream: the reply must declare - // the model under test. - s.openAIGatewayService.guardCodexResponseModel(wire, account, response) - } - return response, err + return s.httpUpstream.Do(wire, proxyURL, account.ID, account.Concurrency) } diff --git a/backend/internal/service/openai_proxy_stream_circuit.go b/backend/internal/service/openai_proxy_stream_circuit.go index cd8efac91d34..575fc134bb76 100644 --- a/backend/internal/service/openai_proxy_stream_circuit.go +++ b/backend/internal/service/openai_proxy_stream_circuit.go @@ -235,10 +235,7 @@ func openAIProxyStreamCircuitProxyID(account *Account) (int64, bool) { return *account.ProxyID, true } -func (s *OpenAIGatewayService) recordOpenAIProxyStreamDisconnect(account *Account, streamErr error, upstreamRequestID string, contexts ...context.Context) { - if len(contexts) > 0 && IsCodexQualityRequest(contexts[0]) { - return - } +func (s *OpenAIGatewayService) recordOpenAIProxyStreamDisconnect(account *Account, streamErr error, upstreamRequestID string) { proxyID, ok := openAIProxyStreamCircuitProxyID(account) if !ok || streamErr == nil || errors.Is(streamErr, context.Canceled) || errors.Is(streamErr, context.DeadlineExceeded) { return @@ -258,10 +255,7 @@ func (s *OpenAIGatewayService) recordOpenAIProxyStreamDisconnect(account *Accoun ) } -func (s *OpenAIGatewayService) clearOpenAIProxyStreamDisconnect(account *Account, contexts ...context.Context) { - if len(contexts) > 0 && IsCodexQualityRequest(contexts[0]) { - return - } +func (s *OpenAIGatewayService) clearOpenAIProxyStreamDisconnect(account *Account) { proxyID, ok := openAIProxyStreamCircuitProxyID(account) if !ok { return diff --git a/backend/internal/service/openai_reasoning_recovery.go b/backend/internal/service/openai_reasoning_recovery.go index 9fdfc303a4fc..ae5f77af1f76 100644 --- a/backend/internal/service/openai_reasoning_recovery.go +++ b/backend/internal/service/openai_reasoning_recovery.go @@ -81,7 +81,6 @@ func (s *OpenAIGatewayService) newOpenAIReasoningRecoveryState(ctx context.Conte if ctx == nil { ctx = context.Background() } - ctx = withCodexRecoveryScope(ctx, account) enabled, policyErr := openAIReasoningPolicyEnabled(ctx, account, OpenAIReasoningSignatureRecoveryEnabledExtraKey) r := &openAIReasoningRecoveryState{ ctx: ctx, c: c, account: account, token: token, diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 4823e4b71b10..7aa52223b3e0 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 TestOpenAISetupTokenMessagesUsesCodexBridgeWithoutUnprovenTurnState(t *testing.T) { +func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { gin.SetMode(gin.TestMode) firstResp := openAICompatSSECompletedResponse("resp_setup_first", "gpt-5.4") @@ -226,7 +226,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeWithoutUnprovenTurnState(t *test require.NoError(t, err) require.NotNil(t, secondResult) require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx)) - 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, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) 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_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 12e08261543b..3f93709f07cc 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -104,7 +104,7 @@ func isClientCanceledTransportError(ctx context.Context, err error) bool { // handleOpenAIUpstreamTransportError handles a transport-level upstream failure // (Do/DoWithTLS returned a non-HTTP error: proxy/DNS/TCP/TLS). It: // 1. records the failure in Ops error logs (status 0, kind=request_error), -// except for client disconnects and scoped Codex quality requests; +// except when the client disconnected (see isClientCanceledTransportError); // 2. restores immediate durable cooldowns for persistent faults only on the // marked ordinary HTTP path; other paths keep downstream classification; // 3. returns *UpstreamFailoverError for the handler's applicable retry policy, @@ -115,7 +115,7 @@ func isClientCanceledTransportError(ctx context.Context, err error) bool { // // passthrough tags the Ops error event for the OpenAI passthrough forward path. func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error { - if IsCodexQualityRequest(ctx) || isClientCanceledTransportError(ctx, err) { + if isClientCanceledTransportError(ctx, err) { return err } safeErr := sanitizeUpstreamErrorMessage(err.Error()) diff --git a/backend/internal/service/openai_v183_compat.go b/backend/internal/service/openai_v183_compat.go index 128d7741ba07..667d2b62c343 100644 --- a/backend/internal/service/openai_v183_compat.go +++ b/backend/internal/service/openai_v183_compat.go @@ -162,9 +162,9 @@ func openAIAlphaSearchSchedulingModel(account *Account, requestedModel string) s return canonicalOpenAIAccountSchedulingModel(account, requestedModel) } -// resolveOpenAIForwardMappedModels keeps upstream's name and signature for the -// upstream mapping test; no request path calls it. Forwarding uses -// resolveOpenAIForwardMappedModelsContext, which applies the same chain. +// resolveOpenAIForwardMappedModels is the shared account mapping chain for +// Forward callers. billingModel retains the ordinary mapping used for usage +// accounting, while upstreamModel is the model the scheduler has admitted. func resolveOpenAIForwardMappedModels(account *Account, requestedModel string, requireCompact bool) (billingModel, upstreamModel string) { requestedModel = strings.TrimSpace(requestedModel) if account != nil && account.IsOpenAIPassthroughEnabled() { diff --git a/backend/internal/service/openai_ws_client.go b/backend/internal/service/openai_ws_client.go index f6f089dd93c7..dfa66a1f2e60 100644 --- a/backend/internal/service/openai_ws_client.go +++ b/backend/internal/service/openai_ws_client.go @@ -328,14 +328,6 @@ func (c *coderOpenAIWSClientConn) UpstreamPingCount() int64 { var _ openaiwsv2.FrameConn = (*coderOpenAIWSClientConn)(nil) -// SetReadLimit lowers the per-message read limit for a bounded exchange. -func (c *coderOpenAIWSClientConn) SetReadLimit(limit int64) { - if c == nil || c.conn == nil { - return - } - c.conn.SetReadLimit(limit) -} - func (c *coderOpenAIWSClientConn) WriteJSON(ctx context.Context, value any) error { if c == nil || c.conn == nil { return errOpenAIWSConnClosed diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 96d4ab33a887..1cad0c492a1a 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -101,7 +101,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( errors.New("invalid json"), ) } - stageCodexLogicalWSTurn(c, trimmedFirstMessage) // 预取一次 OpenAI Fast Policy settings,绑定到 ctx,让该 WS session // 内所有帧的 evaluateOpenAIFastPolicy 调用复用同一份快照,避免每帧 @@ -128,9 +127,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( forceHTTPBridge := account.Platform == PlatformGrok || (s.pluginManager != nil && s.pluginManager.ShouldRouteOpenAIOAuth(account)) modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled - if modeRouterV2Enabled && account.Platform != PlatformGrok && account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) == OpenAIWSIngressModeOff { - return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "websocket mode is disabled for this account", nil) - } ingressMode := OpenAIWSIngressModeCtxPool if modeRouterV2Enabled && !forceHTTPBridge { ingressMode = account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) @@ -501,7 +497,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, integrityErr.Error(), integrityErr) } ingressSessionOriginalModel = originalModel - stageCodexLogicalWSTurn(c, trimmed) return openAIWSClientPayload{ payloadRaw: normalized, @@ -591,7 +586,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( useHTTPBridge := forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) turnState := strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) - nativeLogicalTurn := codexLogicalTurnID(c) stateStore := s.getOpenAIWSStateStore() groupID := getOpenAIGroupIDFromContext(c) apiKeyID := getAPIKeyIDFromContext(c) @@ -612,9 +606,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( // inherit another connection's native WS turn state or socket binding. return } - turnStateScope := openAIWSTurnStateScope(c, account, sessionHash) - if (turnState == "" || !openAICodexTurnStateUsesSessionContract(account)) && stateStore != nil && turnStateScope != "" { - if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, turnStateScope, account.ID); ok { + if turnState == "" && stateStore != nil && sessionHash != "" { + if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash, account.ID); ok { turnState = savedTurnState } } @@ -652,18 +645,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeReplayInputExists := false bridgeReplayVerified := false bridgeBaselineResponseID := "" - bridgeOwnedTurnState := openAIWSHTTPBridgeTurnState{accountID: account.ID, identity: CodexCredentialOwnerIdentity(account)} - bridgeLogicalTurn := "" + bridgeOwnedTurnState := openAIWSHTTPBridgeTurnState{accountID: account.ID} for turn := 1; ; turn++ { - stageCodexLogicalWSTurn(c, currentBridgePayload.rawForHash) - currentLogicalTurn := codexLogicalTurnID(c) - if bridgeOwnedTurnState.accountID != account.ID || (account.IsOpenAIOAuthLike() && bridgeOwnedTurnState.identity != CodexCredentialOwnerIdentity(account)) || (!openAICodexTurnStateUsesSessionContract(account) && (currentLogicalTurn == "" || currentLogicalTurn != bridgeLogicalTurn)) { - turnState, bridgeOwnedTurnState.value = "", "" - bridgeOwnedTurnState.accountID = account.ID - bridgeOwnedTurnState.identity = CodexCredentialOwnerIdentity(account) - } - bridgeOwnedTurnState.turnID = currentLogicalTurn - bridgeLogicalTurn = currentLogicalTurn if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { if err := hooks.BeforeRequest(turn, currentBridgePayload.rawForHash, currentBridgePayload.originalModel); err != nil { return err @@ -793,14 +776,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeReplayInput = combineOpenAIWSReplayItems(bridgeReplayInput, result.wsReplayInput) bridgeReplayInputExists = true } - // Codex retains the first value within one logical turn, including - // tool continuations with an omitted/different response header. A new - // turn clears it above. API destinations keep their prior contract. + // Replace this bridge's state even when the response omits it. An + // empty value invalidates the prior state without touching shared + // native-WS state or provenance belonging to independent connections. bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)) - if openAICodexTurnStateUsesSessionContract(account) || (bridgeOwnedTurnState.value == "" && !result.ClientDisconnect && isOpenAIWSSuccessTerminalEvent(result.UpstreamTerminalEvent)) { - turnState = bridgeTurnState - bridgeOwnedTurnState.value = bridgeTurnState - } + turnState = bridgeTurnState + bridgeOwnedTurnState.value = bridgeTurnState responseID := strings.TrimSpace(result.RequestID) bridgeBaselineResponseID = responseID if responseID != "" && stateStore != nil { @@ -1074,7 +1055,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState - committedHandshakeConnID := "" sendAndRelay := func(turn int, lease *openAIWSConnLease, cleanPayload, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string, requestedReasoningEffort *string) (*OpenAIForwardResult, error) { mappedModel := strings.TrimSpace(gjson.GetBytes(payload, "model").String()) if originalModel != "" && mappedModel == "" { @@ -1092,20 +1072,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( // actually written. Draining an upstream terminal after client disconnect // must not claim that the client received this attempt's state. handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader)) - if !openAICodexTurnStateUsesSessionContract(account) { - if turnState != "" { - handshakeTurnState = turnState - } else if lease.Reused() || committedHandshakeConnID == lease.ConnID() { - handshakeTurnState = "" - } - } turnStateCommitted := false commitTurnState := func() { if turnStateCommitted { return } turnStateCommitted = true - committedHandshakeConnID = lease.ConnID() turnState = handshakeTurnState updatedHeaders := cloneHeader(baseAcquireReq.Headers) if updatedHeaders == nil { @@ -1142,7 +1114,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( false, ) } - s.observeNativeCodexWS(ctx, account, wsHeaders, lease.HandshakeHeaders(), payload) if debugEnabled { logOpenAIWSModeDebug( "ingress_ws_turn_request_sent account_id=%d turn=%d conn_id=%s payload_bytes=%d", @@ -1947,15 +1918,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if parseErr != nil { return parseErr } - if !openAICodexTurnStateUsesSessionContract(account) { - nextLogicalTurn := codexLogicalTurnID(c) - if nextLogicalTurn == "" || nextLogicalTurn != nativeLogicalTurn { - turnState = "" - baseAcquireReq.Headers.Del(openAIWSTurnStateHeader) - c.Request.Header.Del(openAIWSTurnStateHeader) - } - nativeLogicalTurn = nextLogicalTurn - } nextRoutingFields := gjson.GetManyBytes(nextPayload.payloadRaw, "model", "service_tier") if nextPayload.promptCacheKey != "" { // ingress 会话在整个客户端 WS 生命周期内复用同一上游连接; diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 17d6925fa868..5d6f220a1728 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -62,11 +62,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( if s == nil || account == nil { return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil")) } - if c != nil { - if _, staged := c.Get(codexLogicalTurnContextKey); !staged { - stageCodexLogicalWSTurn(c, codexWSMetadataBody(reqBody)) - } - } refusalRuntime := s.openAIRefusalRecoveryRuntime(ctx) responseModelObserver := &upstreamResponseModelObserver{} @@ -178,9 +173,8 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( if executionScope = strings.TrimSpace(executionScope); executionScope != "" { sessionHash = executionScope } - turnStateScope := openAIWSTurnStateScope(c, account, sessionHash) - if (turnState == "" || !openAICodexTurnStateUsesSessionContract(account)) && stateStore != nil && turnStateScope != "" { - if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, turnStateScope, account.ID); ok { + if turnState == "" && stateStore != nil && sessionHash != "" { + if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash, account.ID); ok { turnState = savedTurnState } } @@ -361,16 +355,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( } handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader)) - if !openAICodexTurnStateUsesSessionContract(account) { - if turnState != "" { - // Once learned, the turn's first value survives reconnects and - // responses that return a different value or omit the header. - handshakeTurnState = turnState - } else if lease.Reused() { - // Reusing a socket is not a new handshake for this logical turn. - handshakeTurnState = "" - } - } logOpenAIWSModeDebug( "handshake account_id=%d conn_id=%s has_turn_state=%v turn_state_len=%d", account.ID, @@ -399,7 +383,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( s.commitOpenAIWSSessionTurnState(c, account, stateStore, groupID, sessionHash, handshakeTurnState) } - s.observeNativeCodexWS(ctx, account, wsHeaders, lease.HandshakeHeaders(), nil) if err := s.performOpenAIWSGeneratePrewarm( ctx, lease, @@ -425,7 +408,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( ) return nil, wrapOpenAIWSFallback("write_request", err) } - s.observeNativeCodexWS(ctx, account, wsHeaders, lease.HandshakeHeaders(), codexWSMetadataBody(payload)) if debugEnabled { logOpenAIWSModeDebug( "write_request_sent account_id=%d conn_id=%s stream=%v payload_bytes=%d previous_response_id_present=%v", diff --git a/backend/internal/service/openai_ws_forwarder_v2_execution_scope_test.go b/backend/internal/service/openai_ws_forwarder_v2_execution_scope_test.go index 111b9f27ede9..da13cd1fcbdf 100644 --- a/backend/internal/service/openai_ws_forwarder_v2_execution_scope_test.go +++ b/backend/internal/service/openai_ws_forwarder_v2_execution_scope_test.go @@ -125,8 +125,12 @@ func TestOpenAIGatewayService_Forward_WSv2_ExecutionScopeUsesOriginalIdentity(t completed := func(id string) []byte { return []byte(`{"type":"response.completed","response":{"id":"` + id + `","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) } + captureConn := &openAIWSCaptureConn{events: [][]byte{completed("resp_a"), completed("resp_b"), completed("resp_c")}} handshake := http.Header{} handshake.Set(openAIWSTurnStateHeader, "turn-state-from-upstream") + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn, handshake: handshake}) + defer pool.Close() stateStore := NewOpenAIWSStateStore(nil) svc := &OpenAIGatewayService{ @@ -135,6 +139,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ExecutionScopeUsesOriginalIdentity(t cache: &stubGatewayCache{}, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, openaiWSStateStore: stateStore, } groupID := int64(9) @@ -156,12 +161,6 @@ func TestOpenAIGatewayService_Forward_WSv2_ExecutionScopeUsesOriginalIdentity(t } forward := func(sessionID, body string) (*gin.Context, []byte) { - // A pooled socket's handshake state belongs to the turn that dialed it and - // is never adopted by another session's turn, so each request dials anew. - pool := newOpenAIWSConnPool(cfg) - pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: &openAIWSCaptureConn{events: [][]byte{completed("resp_" + sessionID)}}, handshake: handshake}) - t.Cleanup(pool.Close) - svc.openaiWSPool = pool rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) @@ -174,7 +173,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ExecutionScopeUsesOriginalIdentity(t return c, raw } - plainBody := `{"model":"gpt-5.1","stream":false,"client_metadata":{"turn_id":"turn-plain"},"input":[{"type":"input_text","text":"hello"}]}` + plainBody := `{"model":"gpt-5.1","stream":false,"input":[{"type":"input_text","text":"hello"}]}` cA, rawA := forward("session-a", plainBody) cB, rawB := forward("session-b", plainBody) @@ -182,22 +181,21 @@ func TestOpenAIGatewayService_Forward_WSv2_ExecutionScopeUsesOriginalIdentity(t scopeB, _ := resolveOpenAIWSExecutionScope(cB, rawB, apiKeyID) require.NotEmpty(t, scopeA) require.NotEqual(t, scopeA, scopeB, "不同 session_id 的会话必须落在不同的作用域") - // Codex OAuth state is keyed by the execution scope plus the logical turn. - _, boundA := stateStore.GetSessionTurnState(groupID, openAIWSTurnStateScope(cA, account, scopeA)) + _, boundA := stateStore.GetSessionTurnState(groupID, scopeA) require.True(t, boundA, "会话 A 的 turn state 应落在按原始 session_id 算出的作用域") - _, boundB := stateStore.GetSessionTurnState(groupID, openAIWSTurnStateScope(cB, account, scopeB)) + _, boundB := stateStore.GetSessionTurnState(groupID, scopeB) require.True(t, boundB, "会话 B 的 turn state 应落在按原始 session_id 算出的作用域") injected := resolveCodexFingerprintIDs(account, "", codexFingerprintFull) require.NotNil(t, injected) injectedScope, _ := deriveOpenAISessionHashes(fmt.Sprintf("openai_ws_exec:%d|thread=%s", apiKeyID, injected.threadID)) - _, boundToInjected := stateStore.GetSessionTurnState(groupID, openAIWSTurnStateScope(cA, account, injectedScope)) + _, boundToInjected := stateStore.GetSessionTurnState(groupID, injectedScope) require.False(t, boundToInjected, "指纹收敛注入的固定 thread_id 不得成为状态键") - threadBody := `{"model":"gpt-5.1","stream":false,"client_metadata":{"thread_id":"child-thread","turn_id":"turn-thread"},"input":[{"type":"input_text","text":"hello"}]}` + threadBody := `{"model":"gpt-5.1","stream":false,"client_metadata":{"thread_id":"child-thread"},"input":[{"type":"input_text","text":"hello"}]}` cC, rawC := forward("session-c", threadBody) scopeC, threadC := resolveOpenAIWSExecutionScope(cC, rawC, apiKeyID) require.Equal(t, "child-thread", threadC) - _, boundC := stateStore.GetSessionTurnState(groupID, openAIWSTurnStateScope(cC, account, scopeC)) + _, boundC := stateStore.GetSessionTurnState(groupID, scopeC) require.True(t, boundC, "客户端自带线程标识时,键必须与按原始报文算出的一致,不受账号 namespace 改写影响") } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 046cae0e1f93..33d635651638 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -35,8 +35,6 @@ 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 } @@ -568,12 +566,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( applyBridgeOwnedTurnState := func(req *http.Request) { if account.Platform != PlatformGrok && len(bridgeStates) > 0 { state := bridgeStates[0] - ownerMatches := state.accountID > 0 && state.accountID == account.ID - if account.IsOpenAIOAuthLike() { - ownerMatches = ownerMatches && state.identity == CodexCredentialOwnerIdentity(account) && - state.turnID != "" && state.turnID == codexLogicalTurnID(c) - } - if ownerMatches && strings.TrimSpace(state.value) != "" { + if state.accountID > 0 && state.accountID == account.ID && 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. @@ -594,7 +587,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( c.Set("openai_passthrough", true) c.Set("openai_ws_http_bridge", true) } - upstreamReq = upstreamReq.WithContext(context.WithValue(upstreamReq.Context(), codexWireIngressKey{}, "ws")) // Bridge turns have no PrepareRequest: compare the frame the bridge received // with the plaintext body just built, before zstd in doOpenAICodexUpstream. @@ -749,7 +741,6 @@ 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_isolation_test.go b/backend/internal/service/openai_ws_http_bridge_isolation_test.go index 6e56fc7006ac..4ca930926305 100644 --- a/backend/internal/service/openai_ws_http_bridge_isolation_test.go +++ b/backend/internal/service/openai_ws_http_bridge_isolation_test.go @@ -199,13 +199,12 @@ func TestOpenAIWSHTTPBridgeSessionIsolationAcrossSameSessionHash(t *testing.T) { return event } clients := make([]*coderws.Conn, 0, 2) - logicalTurns := map[string]string{"alpha": "9c45fb37-1798-40aa-9ce5-3e08aabb6401", "beta": "9c45fb37-1798-40aa-9ce5-3e08aabb6402"} for _, id := range []string{"alpha", "beta"} { conn, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) require.NoError(t, err) defer func() { _ = conn.CloseNow() }() clients = append(clients, conn) - payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","prompt_cache_key":%q,"client_metadata":{"thread_id":%q,"turn_id":%q},"input":%q}`, id+"-cache", id+"-thread", logicalTurns[id], id) + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","prompt_cache_key":%q,"client_metadata":{"thread_id":%q},"input":%q}`, id+"-cache", id+"-thread", id) require.NoError(t, conn.Write(ctx, coderws.MessageText, []byte(payload))) readEvent(conn, "response.created") readEvent(conn, "response.output_item.added") @@ -217,7 +216,7 @@ func TestOpenAIWSHTTPBridgeSessionIsolationAcrossSameSessionHash(t *testing.T) { require.Equal(t, "resp_"+id+"_1", gjson.GetBytes(event, "response.id").String()) } for i, id := range []string{"alpha", "beta"} { - payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","previous_response_id":%q,"client_metadata":{"turn_id":%q},"input":[{"type":"function_call_output","call_id":%q,"output":%q}]}`, "resp_"+id+"_1", logicalTurns[id], "call_"+id, id+"-result") + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","previous_response_id":%q,"input":[{"type":"function_call_output","call_id":%q,"output":%q}]}`, "resp_"+id+"_1", "call_"+id, id+"-result") require.NoError(t, clients[i].Write(ctx, coderws.MessageText, []byte(payload))) readEvent(clients[i], "response.created") event := readEvent(clients[i], "response.completed") @@ -266,13 +265,10 @@ func TestOpenAIWSHTTPBridgeClearsOwnedStateWhenResponseOmitsHeader(t *testing.T) cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 upstream := &httpUpstreamRecorder{} - for turn := 1; turn <= 5; turn++ { + for turn := 1; turn <= 3; turn++ { headers := http.Header{"Content-Type": []string{"text/event-stream"}} - switch turn { - case 1: + if turn == 1 { headers.Set(openAIWSTurnStateHeader, "state-from-first-turn") - case 2: - headers.Set(openAIWSTurnStateHeader, "later-value-must-not-replace-first") } upstream.responses = append(upstream.responses, &http.Response{ StatusCode: http.StatusOK, @@ -300,7 +296,7 @@ func TestOpenAIWSHTTPBridgeClearsOwnedStateWhenResponseOmitsHeader(t *testing.T) seedHash := svc.GenerateSessionHash(seedContext, nil) stateStore.BindSessionTurnState(groupID, seedHash, account.ID, "native-state-sentinel", time.Hour) stateStore.BindSessionConn(groupID, seedHash, "native-conn-sentinel", time.Hour) - newTurnIngressHeader := make(chan string, 1) + thirdTurnIngressHeader := make(chan string, 1) serverResult := make(chan error, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := coderws.Accept(w, r, nil) @@ -316,8 +312,8 @@ func TestOpenAIWSHTTPBridgeClearsOwnedStateWhenResponseOmitsHeader(t *testing.T) } c := newContext(r) hooks := &OpenAIWSIngressHooks{AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { - if turn == 5 { - newTurnIngressHeader <- c.Request.Header.Get(openAIWSTurnStateHeader) + if turn == 3 { + thirdTurnIngressHeader <- c.Request.Header.Get(openAIWSTurnStateHeader) } }} serverResult <- svc.ProxyResponsesWebSocketFromClient(ctx, c, conn, account, "test-token", firstMessage, hooks) @@ -326,15 +322,8 @@ func TestOpenAIWSHTTPBridgeClearsOwnedStateWhenResponseOmitsHeader(t *testing.T) client, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) require.NoError(t, err) defer func() { _ = client.CloseNow() }() - for turn := 1; turn <= 5; turn++ { - logicalTurn := "9c45fb37-1798-40aa-9ce5-3e08aabb6403" - anchor := "" - if turn == 5 { - logicalTurn = "9c45fb37-1798-40aa-9ce5-3e08aabb6404" - } else if turn > 1 { - anchor = fmt.Sprintf(`,"previous_response_id":"resp_clear_%d"`, turn-1) - } - payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","client_metadata":{"turn_id":%q},"input":"turn %d"%s}`, logicalTurn, turn, anchor) + for turn := 1; turn <= 3; turn++ { + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","input":"turn %d"}`, turn) require.NoError(t, client.Write(ctx, coderws.MessageText, []byte(payload))) _, event, readErr := client.Read(ctx) require.NoError(t, readErr) @@ -345,17 +334,15 @@ func TestOpenAIWSHTTPBridgeClearsOwnedStateWhenResponseOmitsHeader(t *testing.T) case err := <-serverResult: require.NoError(t, err) case <-ctx.Done(): - t.Fatal("bridge did not finish the turn-state regression") + t.Fatal("bridge did not finish the three-turn state-clear regression") } - require.Len(t, upstream.requests, 5) - // Continuations retain the first response's value despite a changed or - // absent later header. The next logical turn starts without that value. - for index, want := range []string{"", "state-from-first-turn", "state-from-first-turn", "state-from-first-turn", ""} { + require.Len(t, upstream.requests, 3) + for index, want := range []string{"", "state-from-first-turn", ""} { require.Equal(t, want, upstream.requests[index].Header.Get(openAIWSTurnStateHeader)) } // The HTTP provenance guard could mask a stale ingress header. Observe it - // after the new turn's header synchronization as well as the upstream wire. - require.Empty(t, <-newTurnIngressHeader) + // after third-turn header synchronization as well as the real upstream wire. + require.Empty(t, <-thirdTurnIngressHeader) state, ok := stateStore.GetSessionTurnState(groupID, seedHash, account.ID) require.True(t, ok) require.Equal(t, "native-state-sentinel", state) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 80cbd5c381d8..39183a868d17 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.4\",\"status\":\"completed\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.1\",\"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,9 +594,6 @@ 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()) @@ -617,7 +614,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.4\",\"status\":\"completed\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.1\",\"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"}]`)))}, @@ -695,9 +692,6 @@ 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/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index fb3dcf692117..19aed3a79bc5 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -986,10 +986,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( if !ok { return errors.New("openai ws passthrough upstream connection does not support frame relay") } - s.observeNativeCodexWS(ctx, account, headers, handshakeHeaders, nil) - upstreamFrameConn = &codexObservedNativeFrameConn{FrameConn: upstreamFrameConn, observe: func(frameCtx context.Context, payload []byte) { - s.observeNativeCodexWS(frameCtx, account, headers, handshakeHeaders, payload) - }} relayUpstreamFrameConn := &openAIWSPassthroughFirstOutputFrameConn{ inner: upstreamFrameConn, activeReadTimeout: s.openAIWSPassthroughIdleTimeout(), diff --git a/backend/internal/service/plugin_codex_identity.go b/backend/internal/service/plugin_codex_identity.go deleted file mode 100644 index 7bd205243e11..000000000000 --- a/backend/internal/service/plugin_codex_identity.go +++ /dev/null @@ -1,111 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// Only shared/pre-create policy material may use the account-free domain path. -// An existing account keeps its host-selected type and rollout scope on every -// policy operation, including validation and metadata-only diagnostics. -func invokeCodexIdentityPolicyForAccount(ctx context.Context, account *Account, operation string, query extensionv1.CodexIdentityQuery) (extensionv1.CodexIdentityResult, error) { - if account != nil && !account.IsOpenAIOAuthLike() { - return extensionv1.CodexIdentityResult{}, ErrNativeCodexPolicyDisabled - } - raw, err := json.Marshal(query) - if err != nil || len(raw) > 8192 { - return extensionv1.CodexIdentityResult{}, ErrNativeCodexRuntimeUnavailable - } - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - invocation := extensionv1.Invocation{ - Capability: extensionv1.CapabilityRequest, Operation: operation, Payload: raw, - } - var result extensionv1.Result - if account == nil { - result, err = invokeNativeCodex(call, "", "", invocation) - } else { - invocation.AccountID = account.ID - result, err = invokeNativeCodex(call, account.Platform, account.Type, invocation) - } - if err != nil { - return extensionv1.CodexIdentityResult{}, err - } - var identity extensionv1.CodexIdentityResult - if result.Code != "" || json.Unmarshal(result.Payload, &identity) != nil { - return identity, ErrNativeCodexRuntimeUnavailable - } - return identity, nil -} - -func requireCodexIdentityPolicy(ctx context.Context, account *Account) error { - if account == nil || !account.IsOpenAIOAuthLike() { - return nil - } - _, err := resolveCodexOutboundIdentityForAccountContext(ctx, account, codexAccountIdentityOverrideUA(account)) - return err -} - -func resolveCodexOutboundIdentityForAccountContext(ctx context.Context, account *Account, overrideUA string) (codexOutboundIdentity, error) { - canonical := resolveCodexOutboundIdentity(overrideUA) - if account == nil || !account.IsOpenAIOAuthLike() { - return canonical, nil - } - available, err := codexIdentityPolicyAvailable(ctx, account.Type, account.ID) - if err != nil { - return codexOutboundIdentity{}, err - } - if !available || overrideUA != "" { - return canonical, nil - } - var profile extensionv1.CodexClientProfile - if raw, ok := account.Extra[CodexClientIdentityExtraKey].(string); ok { - _ = json.Unmarshal([]byte(raw), &profile) - } else if raw, err := json.Marshal(account.Extra[CodexClientIdentityExtraKey]); err == nil { - _ = json.Unmarshal(raw, &profile) - } - result, err := invokeCodexIdentityPolicyForAccount(ctx, account, "codex.identity.plan", extensionv1.CodexIdentityQuery{ - Seed: codexClientIdentitySeed(account), Profile: profile, Version: canonical.version, - }) - if err != nil { - if errors.Is(err, ErrNativeCodexPolicyDisabled) { - err = ErrNativeCodexRuntimeUnavailable - } - return codexOutboundIdentity{}, err - } - if !result.Valid { - return canonical, nil - } - if result.UserAgent == "" { - return codexOutboundIdentity{}, ErrNativeCodexRuntimeUnavailable - } - originator, paired, valid := openai.PairCodexClientIdentity(result.UserAgent) - if !valid { - return codexOutboundIdentity{}, ErrNativeCodexRuntimeUnavailable - } - return codexOutboundIdentity{userAgent: paired, originator: originator, version: openai.CodexUserAgentVersion(paired)}, nil -} - -func codexIdentityPolicyAvailable(ctx context.Context, accountType string, accountID int64) (bool, error) { - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - result, err := invokeNativeCodex(call, PlatformOpenAI, accountType, extensionv1.Invocation{ - Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.available", Payload: []byte(`{}`), AccountID: accountID, - }) - if errors.Is(err, ErrNativeCodexPolicyDisabled) { - return false, nil // Explicitly disabled: use the upstream host identity contract. - } - if err != nil { - return false, err - } - var status extensionv1.CodexIdentityResult - if result.Code != "" || json.Unmarshal(result.Payload, &status) != nil || !status.Valid { - return false, ErrNativeCodexRuntimeUnavailable - } - return true, nil -} diff --git a/backend/internal/service/plugin_codex_identity_test.go b/backend/internal/service/plugin_codex_identity_test.go deleted file mode 100644 index 95f542e2b966..000000000000 --- a/backend/internal/service/plugin_codex_identity_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package service - -import ( - "context" - "net/http" - "strings" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/stretchr/testify/require" -) - -type partialCodexIdentityFailure struct{} - -func (partialCodexIdentityFailure) InvokeOperation(_ context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Operation == "codex.identity.available" { - return extensionv1.Result{Payload: []byte(`{"valid":true}`)}, nil - } - return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable -} - -func TestCodexIdentityPlanFailureCannotSendCanonicalFallback(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) - invokeNativeCodex = partialCodexIdentityFailure{}.InvokeOperation - account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Credentials: map[string]any{"refresh_token": "fixture-refresh"}, - Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} - headers := http.Header{"Originator": []string{"fixture-original"}} - err := enforceCodexIdentityHeadersForAccountContext(context.Background(), headers, account, "") - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) - require.Equal(t, "fixture-original", headers.Get("Originator")) - req, err := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"input":"fixture"}`)) - require.NoError(t, err) - _, err = prepareCodexTransport(req, account) - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) - client := &identityRefreshingOAuthClientStub{} - _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) - require.Empty(t, client.userAgent) -} - -func TestCodexIdentityDisabledKeepsDataAndFailureBlocksHTTPAndRefresh(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) - account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Credentials: map[string]any{"refresh_token": "fixture-refresh"}, - Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} - account.Extra = ensureCodexClientIdentityExtra(account.Platform, account.Type, account.Extra, time.Now()) - stored := account.Extra[CodexClientIdentityExtraKey] - require.NotNil(t, stored) - require.Equal(t, codexFingerprintDevice, account.GetCodexFingerprintMode()) - invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - _, found := account.CodexClientIdentity() - require.False(t, found) - require.Equal(t, stored, account.Extra[CodexClientIdentityExtraKey]) - updated := prepareCodexFingerprintExtraForUpdate(account, map[string]any{"operator_field": "changed"}) - require.Equal(t, stored, updated[CodexClientIdentityExtraKey], "editing a disabled account must retain its stored domain data") - require.Equal(t, codexFingerprintOff, account.GetCodexFingerprintMode()) - account.Extra[codexFingerprintModeExtraKey] = "session" - require.Equal(t, codexFingerprintSession, account.GetCodexFingerprintMode(), "upstream explicit policy survives domain disable") - req, err := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", strings.NewReader(`{"input":"fixture"}`)) - require.NoError(t, err) - wire, err := prepareCodexTransport(req, account) - require.NoError(t, err) - require.Same(t, req, wire) - invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, ErrNativeCodexRuntimeUnavailable - } - _, err = prepareCodexTransport(req, account) - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable, "policy failure cannot silently send canonical identity") - client := &identityRefreshingOAuthClientStub{} - _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) - require.Empty(t, client.userAgent) -} - -func TestCodexProfileMetadataDoesNotBroadenAccountBinding(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) - invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if kind != AccountTypeSetupToken { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - return (promptPolicyFixture{}).InvokeOperation(ctx, platform, kind, in) - } - - account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} - _, found := account.CodexClientIdentity() - require.False(t, found) - account.Type = AccountTypeSetupToken - _, found = account.CodexClientIdentity() - require.True(t, found) -} - -func TestCodexIdentitySnapshotReportsTheSelectedFingerprintSource(t *testing.T) { - SetCodexForceCLIEnabled(true) - t.Cleanup(func() { SetCodexForceCLIEnabled(false) }) - account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} - snapshot := resolveCodexIdentitySnapshotContext(context.Background(), account, account, "") - require.Equal(t, "account", snapshot.IdentitySource) - snapshot = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "codex_cli_rs/0.144.0 (Windows 10.0.19045; x86_64) unknown") - require.Equal(t, "override_ua", snapshot.IdentitySource) -} diff --git a/backend/internal/service/plugin_codex_recovery.go b/backend/internal/service/plugin_codex_recovery.go index 1ff3d1a5ecba..857790ed38bb 100644 --- a/backend/internal/service/plugin_codex_recovery.go +++ b/backend/internal/service/plugin_codex_recovery.go @@ -3,80 +3,15 @@ package service import ( "context" "encoding/json" - "errors" "slices" - "time" + codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/tidwall/gjson" ) -type codexRecoveryScopeKey struct{} -type codexRecoveryScope struct { - accountID int64 - platform, accountType string -} - -func withCodexRecoveryScope(ctx context.Context, account *Account) context.Context { - if account == nil { - return ctx - } - return context.WithValue(ctx, codexRecoveryScopeKey{}, codexRecoveryScope{account.ID, account.Platform, account.Type}) -} - -func invokeCodexRecoveryPolicy(ctx context.Context, accountType, operation string, input, output any) error { - raw, err := json.Marshal(input) - if err != nil || len(raw) > extensionv1.MaxPayloadBytes { - return ErrNativeCodexRuntimeUnavailable - } - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - invocation := extensionv1.Invocation{Capability: extensionv1.CapabilityRecovery, Operation: operation, Payload: raw} - if scope, ok := ctx.Value(codexRecoveryScopeKey{}).(codexRecoveryScope); ok { - if scope.platform != PlatformOpenAI { - return ErrNativeCodexPolicyDisabled - } - accountType, invocation.AccountID = scope.accountType, scope.accountID - } - var result extensionv1.Result - if accountType == "" { - result, err = invokeNativeCodex(call, "", "", invocation) - } else { - result, err = invokeNativeCodex(call, PlatformOpenAI, accountType, invocation) - } - if err != nil { - return err - } - if result.Code != "" || json.Unmarshal(result.Payload, output) != nil { - return ErrNativeCodexRuntimeUnavailable - } - return nil -} - func readOpenAIReplayRules(ctx context.Context) (extensionv1.ReplayRules, error) { - var rules extensionv1.ReplayRules - if err := invokeCodexRecoveryPolicy(ctx, "", "codex.replay.rules", struct{}{}, &rules); err != nil { - return rules, err - } - if !rules.Enabled { - return rules, nil - } - if rules.Version == "" || len(rules.Version) > 64 || rules.MaxToolCalls < 1 || rules.MaxToolCalls > 32 || len(rules.ChatFields) > 6 || len(rules.OutputKinds) > 3 { - return rules, ErrNativeCodexRuntimeUnavailable - } - validate := func(values, allowed []string) bool { - seen := map[string]bool{} - for _, value := range values { - if !slices.Contains(allowed, value) || seen[value] { - return false - } - seen[value] = true - } - return true - } - if !validate(rules.ChatFields, []string{"role", "content", "reasoning_content", "reasoning", "tool_calls", "refusal"}) || !validate(rules.OutputKinds, []string{"reasoning", "message", "function_call"}) { - return rules, ErrNativeCodexRuntimeUnavailable - } + rules := codexrecovery.ReplayPolicy() slices.Sort(rules.ChatFields) slices.Sort(rules.OutputKinds) return rules, nil @@ -88,12 +23,7 @@ func openAIReasoningPolicyEnabled(ctx context.Context, account *Account, key str } raw, configured := account.Extra[key] value, valid := raw.(bool) - var enabled bool - err := invokeCodexRecoveryPolicy(withCodexRecoveryScope(ctx, account), account.Type, "codex.recovery.enabled", extensionv1.RecoverySetting{Configured: configured, Valid: valid, Value: value}, &enabled) - if errors.Is(err, ErrNativeCodexPolicyDisabled) { - return false, nil - } - return enabled, err + return codexrecovery.Enabled(extensionv1.RecoverySetting{Configured: configured, Valid: valid, Value: value}), nil } func openAIRecoveryEnvelope(payload []byte) extensionv1.RecoveryEnvelope { @@ -129,9 +59,8 @@ func openAIRecoveryEnvelope(payload []byte) extensionv1.RecoveryEnvelope { } func readOpenAIRecoveryRejection(ctx context.Context, payload []byte) (openAIReasoningRejection, bool, error) { - var result extensionv1.RecoveryRejection - err := invokeCodexRecoveryPolicy(ctx, "", "codex.recovery.rejection", openAIRecoveryEnvelope(payload), &result) - return openAIReasoningRejection{result.Code, result.Param}, result.Recognized, err + result := codexrecovery.Rejection(openAIRecoveryEnvelope(payload)) + return openAIReasoningRejection{result.Code, result.Param}, result.Recognized, nil } func selectOpenAIRecoveryIndices(ctx context.Context, body []byte, param string) (extensionv1.RecoverySelection, error) { @@ -149,20 +78,5 @@ func selectOpenAIRecoveryIndices(ctx context.Context, body []byte, param string) query.EncryptedFields = countOpenAIEncryptedFields(parsed) } } - var selection extensionv1.RecoverySelection - err := invokeCodexRecoveryPolicy(ctx, "", "codex.recovery.select", query, &selection) - if err != nil { - return selection, err - } - allowed := map[int]bool{} - for _, index := range query.CipherIndices { - allowed[index] = true - } - for _, index := range selection.Indices { - if !allowed[index] { - return extensionv1.RecoverySelection{}, ErrNativeCodexRuntimeUnavailable - } - delete(allowed, index) - } - return selection, nil + return codexrecovery.Select(query), nil } diff --git a/backend/internal/service/plugin_codex_recovery_test.go b/backend/internal/service/plugin_codex_recovery_test.go index 1d52420d1cd9..abd9f44c2cde 100644 --- a/backend/internal/service/plugin_codex_recovery_test.go +++ b/backend/internal/service/plugin_codex_recovery_test.go @@ -7,47 +7,12 @@ import ( "net/http" "testing" - codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) -type capturedRecoveryFixture struct { - promptPolicyFixture - payloads []string -} - -func (f *capturedRecoveryFixture) InvokeOperation(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Capability == extensionv1.CapabilityRecovery { - f.payloads = append(f.payloads, string(in.Payload)) - } - return f.promptPolicyFixture.InvokeOperation(ctx, platform, kind, in) -} - -func TestRecoveryPolicyDoesNotReceiveHistoryCiphertextOrCredentialMaterial(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) - fixture := &capturedRecoveryFixture{} - invokeNativeCodex = fixture.InvokeOperation - state, _, _ := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) - _, retry := state.TryRecover(http.StatusBadRequest, nil, []byte(`{"error":{"code":"invalid_encrypted_content","message":"private-upstream-text"}}`), false) - require.True(t, retry) - require.NotEmpty(t, fixture.payloads) - for _, payload := range fixture.payloads { - for _, private := range []string{"constraints", "opaque-old", "source-token", "private-upstream-text", "visible summary", "load_orders"} { - require.NotContains(t, payload, private) - } - } -} - func TestRecoveryDisableAfterFirstAttemptDoesNotSpendAnotherRequest(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) state, _, _ := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) - invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } + state.account.Extra = map[string]any{OpenAIReasoningSignatureRecoveryEnabledExtraKey: false} _, retry := state.TryRecover(http.StatusBadRequest, nil, []byte(`{"error":{"code":"invalid_encrypted_content"}}`), false) require.False(t, retry) require.False(t, state.retryUsed) @@ -55,45 +20,7 @@ func TestRecoveryDisableAfterFirstAttemptDoesNotSpendAnotherRequest(t *testing.T require.Equal(t, reasoningRecoveryFixture, string(state.wire)) } -func TestRecoverySelectionCannotEscapeActualAccountScopeOrRollout(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) - var called []extensionv1.Invocation - enabled := true - invokeNativeCodex = func(ctx context.Context, platform, kind string, in extensionv1.Invocation) (extensionv1.Result, error) { - if platform != PlatformOpenAI || kind != AccountTypeOAuth || !enabled { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } - called = append(called, in) - return codexrecovery.Invoke(ctx, in) - } - - account := &Account{ID: 37, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - ctx := withCodexRecoveryScope(context.Background(), account) - _, _, err := readOpenAIRecoveryRejection(ctx, []byte(`{"error":{"code":"invalid_encrypted_content"}}`)) - require.ErrorIs(t, err, ErrNativeCodexPolicyDisabled) - require.Empty(t, called) - account.Type = AccountTypeOAuth - ctx = withCodexRecoveryScope(context.Background(), account) - _, ok, err := readOpenAIRecoveryRejection(ctx, []byte(`{"error":{"code":"invalid_encrypted_content"}}`)) - require.NoError(t, err) - require.True(t, ok) - _, err = selectOpenAIRecoveryIndices(ctx, []byte(reasoningRecoveryFixture), "") - require.NoError(t, err) - for _, in := range called { - require.EqualValues(t, 37, in.AccountID) - } - enabled = false - _, err = selectOpenAIRecoveryIndices(ctx, []byte(reasoningRecoveryFixture), "") - require.ErrorIs(t, err, ErrNativeCodexPolicyDisabled) -} - func TestSignatureRejectionRemainsRequestScopedWhenPluginIsAbsent(t *testing.T) { - previous := invokeNativeCodex - t.Cleanup(func() { invokeNativeCodex = previous }) - invokeNativeCodex = func(context.Context, string, string, extensionv1.Invocation) (extensionv1.Result, error) { - return extensionv1.Result{}, ErrNativeCodexPolicyDisabled - } _, ok := parseOpenAIReasoningRejection([]byte(`{"error":{"code":"thinking_signature_invalid"}}`)) require.True(t, ok) _, ok = parseOpenAIReasoningRejection([]byte(`{"error":{"status":429,"code":"thinking_signature_invalid"}}`)) diff --git a/backend/internal/service/plugin_codex_scope_test.go b/backend/internal/service/plugin_codex_scope_test.go deleted file mode 100644 index 07b3c3394817..000000000000 --- a/backend/internal/service/plugin_codex_scope_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "github.com/Wei-Shaw/sub2api/internal/config" - "net/http" - "strconv" - "strings" - "testing" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/stretchr/testify/require" -) - -const codexScopeFixtureSeed = "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55" - -func codexScopeFixtureAccount(id int64, kind string, stored bool) *Account { - account := &Account{ID: id, Platform: PlatformOpenAI, Type: kind, - Credentials: map[string]any{"refresh_token": "fixture-refresh"}, - Extra: map[string]any{codexFingerprintSeedExtraKey: codexScopeFixtureSeed}} - if stored { - query, _ := json.Marshal(extensionv1.CodexIdentityQuery{Seed: codexScopeFixtureSeed}) - result, _ := (promptPolicyFixture{}).InvokeOperation(context.Background(), "", "", extensionv1.Invocation{ - Capability: extensionv1.CapabilityRequest, Operation: "codex.identity.derive", Payload: query, - }) - var identity extensionv1.CodexIdentityResult - _ = json.Unmarshal(result.Payload, &identity) - account.Extra[CodexClientIdentityExtraKey] = codexClientIdentityExtraValue(codexClientIdentity(identity.Profile), time.Unix(0, 0)) - } - return account -} - -type nativeCodexScopeFixture struct{ unavailable bool } - -func codexScopeFixtureManager(t *testing.T, percent int, kind string, calls *[]extensionv1.Invocation) *nativeCodexScopeFixture { - t.Helper() - fixture := &nativeCodexScopeFixture{} - oldInvoke, oldBind := invokeNativeCodex, bindNativeCodexContext - t.Cleanup(func() { invokeNativeCodex, bindNativeCodexContext = oldInvoke, oldBind }) - check := func(actual string, in extensionv1.Invocation) error { - if in.AccountID > 0 && (actual != kind || int(stablePluginBucket(in.AccountID)) >= percent) { - return ErrNativeCodexPolicyDisabled - } - if fixture.unavailable { - return ErrNativeCodexRuntimeUnavailable - } - return nil - } - invokeNativeCodex = func(ctx context.Context, platform, actual string, in extensionv1.Invocation) (extensionv1.Result, error) { - if err := check(actual, in); err != nil { - return extensionv1.Result{}, err - } - *calls = append(*calls, in) - return (promptPolicyFixture{}).InvokeOperation(withCodexTransportFixture(ctx, true), platform, actual, in) - } - bindNativeCodexContext = func(ctx context.Context, _ string, actual string, in extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - if err := check(actual, in); err != nil { - return nil, nil, err - } - bound, cancel := context.WithCancel(ctx) - return bound, cancel, nil - } - return fixture -} - -func TestCodexAccountPoliciesCarryActualScope(t *testing.T) { - actions := map[string]func(*testing.T, *Account){ - "outbound-identity": func(t *testing.T, account *Account) { - _, err := resolveCodexOutboundIdentityForAccountContext(context.Background(), account, "") - require.NoError(t, err) - }, - "wire-transport": func(t *testing.T, account *Account) { - req, err := http.NewRequest(http.MethodPost, "https://fixture.invalid/backend-api/codex/responses", strings.NewReader(`{"input":"fixture"}`)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - _, err = prepareCodexTransport(req, account) - require.NoError(t, err) - }, - "persisted-profile": func(_ *testing.T, account *Account) { _, _ = account.CodexClientIdentity() }, - "derived-profile": func(_ *testing.T, account *Account) { - delete(account.Extra, CodexClientIdentityExtraKey) - _, _ = account.CodexClientIdentity() - }, - "fingerprint-default": func(_ *testing.T, account *Account) { _ = account.GetCodexFingerprintMode() }, - "metadata-update": func(_ *testing.T, account *Account) { - _ = prepareCodexFingerprintExtraForUpdate(account, map[string]any{"fixture": true}) - }, - "diagnostic-snapshot": func(_ *testing.T, account *Account) { - _ = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "") - }, - "gateway-sandbox": func(t *testing.T, account *Account) { - account.Extra[codexFingerprintModeExtraKey] = string(codexFingerprintDevice) - gateway := &OpenAIGatewayService{cfg: &config.Config{}} - require.NotNil(t, gateway.resolveStagedCodexFingerprintIDs(nil, account, http.Header{})) - }, - } - for name, action := range actions { - for _, percent := range []int{0, 50, 100} { - for _, kind := range []string{AccountTypeOAuth, AccountTypeSetupToken} { - for _, id := range []int64{2, 3} { - t.Run(name+"/"+kind+"/"+strconv.Itoa(percent)+"/"+strconv.FormatInt(id, 10), func(t *testing.T) { - var calls []extensionv1.Invocation - codexScopeFixtureManager(t, percent, kind, &calls) - account := codexScopeFixtureAccount(id, kind, true) - action(t, account) - if int(stablePluginBucket(id)) >= percent { - require.Empty(t, calls, "outside-rollout account data must not enter a shared policy") - return - } - require.NotEmpty(t, calls) - for _, call := range calls { - require.Equal(t, id, call.AccountID, "operation %s must keep the host's account scope", call.Operation) - } - }) - } - } - } - } -} - -func TestCodexAccountPolicyTypeDoesNotBecomeDomainScope(t *testing.T) { - var calls []extensionv1.Invocation - codexScopeFixtureManager(t, 100, AccountTypeSetupToken, &calls) - account := codexScopeFixtureAccount(7, AccountTypeOAuth, true) - _, err := resolveCodexOutboundIdentityForAccountContext(context.Background(), account, "") - require.NoError(t, err) - _ = prepareCodexFingerprintExtraForUpdate(account, map[string]any{}) - _ = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "") - ids := resolveCodexFingerprintIDs(account, "fixture-session", codexFingerprintDevice) - ids.alignSandboxWithUserAgent("codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown") - require.Empty(t, calls, "a setup-token binding must not validate/derive/interpret an OAuth account profile") -} - -func TestCodexSharedIdentityBeforeAccountCreationKeepsDomainSemantics(t *testing.T) { - var calls []extensionv1.Invocation - codexScopeFixtureManager(t, 0, AccountTypeSetupToken, &calls) - identity := deriveCodexClientIdentity(codexScopeFixtureSeed) - require.True(t, identity.valid()) - require.NotEmpty(t, identity.UserAgent("0.150.0")) - prepared := prepareCodexFingerprintExtraForCreate(PlatformOpenAI, AccountTypeSetupToken, map[string]any{}) - require.NotNil(t, prepared[CodexClientIdentityExtraKey]) - require.NotEmpty(t, calls) - for _, call := range calls { - require.Zero(t, call.AccountID, "shared/pre-create policy has no persisted account to invent") - } -} - -type codexScopeBackfillRepo struct { - AccountRepository - accounts []Account - written []int64 -} - -func (r *codexScopeBackfillRepo) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) { - return r.accounts, nil -} - -func (r *codexScopeBackfillRepo) UpdateExtraIfRevision(_ context.Context, id int64, _ time.Time, _ map[string]any) (bool, error) { - r.written = append(r.written, id) - return true, nil -} - -func TestCodexIdentityBackfillUsesEachAccountScope(t *testing.T) { - for _, percent := range []int{0, 50, 100} { - t.Run(strconv.Itoa(percent), func(t *testing.T) { - var calls []extensionv1.Invocation - codexScopeFixtureManager(t, percent, AccountTypeOAuth, &calls) - repo := &codexScopeBackfillRepo{accounts: []Account{ - *codexScopeFixtureAccount(2, AccountTypeOAuth, false), - *codexScopeFixtureAccount(3, AccountTypeOAuth, false), - *codexScopeFixtureAccount(7, AccountTypeSetupToken, false), - }} - updated, failed, err := NewCodexClientIdentityBackfillService(repo).RunOnce(context.Background()) - require.NoError(t, err) - require.Zero(t, failed) - var expected []int64 - for _, id := range []int64{2, 3} { - if int(stablePluginBucket(id)) < percent { - expected = append(expected, id) - } - } - require.Equal(t, expected, repo.written) - require.Equal(t, len(expected), updated) - for _, call := range calls { - require.Contains(t, expected, call.AccountID, "backfill must not use an account-free domain invocation") - } - }) - } -} - -func TestCodexIdentityBackfillUnavailablePolicyIsAccountScoped(t *testing.T) { - for _, percent := range []int{0, 100} { - t.Run(strconv.Itoa(percent), func(t *testing.T) { - var calls []extensionv1.Invocation - manager := codexScopeFixtureManager(t, percent, AccountTypeOAuth, &calls) - manager.unavailable = true - repo := &codexScopeBackfillRepo{accounts: []Account{*codexScopeFixtureAccount(2, AccountTypeOAuth, false)}} - updated, failed, err := NewCodexClientIdentityBackfillService(repo).RunOnce(context.Background()) - if percent == 0 { - require.NoError(t, err, "an account outside rollout must not bind the unavailable domain runtime") - } else { - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) - } - require.Zero(t, updated) - require.Zero(t, failed) - require.Empty(t, repo.written) - require.Empty(t, calls) - }) - } -} - -func TestCodexRefreshScopeAndUnavailableRuntime(t *testing.T) { - for _, percent := range []int{0, 50, 100} { - for _, id := range []int64{2, 3} { - for _, runtimeAvailable := range []bool{true, false} { - t.Run(strconv.Itoa(percent)+"/"+strconv.FormatInt(id, 10)+"/"+strconv.FormatBool(runtimeAvailable), func(t *testing.T) { - var calls []extensionv1.Invocation - manager := codexScopeFixtureManager(t, percent, AccountTypeOAuth, &calls) - if !runtimeAvailable { - manager.unavailable = true - } - account := codexScopeFixtureAccount(id, AccountTypeOAuth, true) - client := &identityRefreshingOAuthClientStub{} - service := NewOpenAIOAuthService(nil, client) - require.Nil(t, service.privacyClientFactory, "the synthetic refresh fixture must not enrich via network") - _, err := service.RefreshAccountToken(context.Background(), account) - inScope := int(stablePluginBucket(id)) < percent - if inScope && !runtimeAvailable { - require.ErrorIs(t, err, ErrNativeCodexRuntimeUnavailable) - require.Empty(t, client.userAgent) - return - } - require.NoError(t, err) - require.NotEmpty(t, client.userAgent) - if !inScope { - require.Empty(t, calls) - } - for _, call := range calls { - require.Equal(t, id, call.AccountID) - } - }) - } - } - } -} diff --git a/backend/internal/service/plugin_codex_transport.go b/backend/internal/service/plugin_codex_transport.go deleted file mode 100644 index 10a426839ef2..000000000000 --- a/backend/internal/service/plugin_codex_transport.go +++ /dev/null @@ -1,54 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func codexTransportPlan(ctx context.Context, accountType string, accountID int64, query extensionv1.CodexTransportQuery) (extensionv1.CodexTransportPlan, error) { - raw, err := json.Marshal(query) - if err != nil || len(raw) > 16384 { - return extensionv1.CodexTransportPlan{}, ErrNativeCodexRuntimeUnavailable - } - call, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - result, err := invokeNativeCodex(call, PlatformOpenAI, accountType, extensionv1.Invocation{ - Capability: extensionv1.CapabilityRequest, Operation: "codex.transport.plan", Payload: raw, AccountID: accountID, - }) - if errors.Is(err, ErrNativeCodexPolicyDisabled) { - return extensionv1.CodexTransportPlan{}, nil - } - if err != nil { - return extensionv1.CodexTransportPlan{}, err - } - var plan extensionv1.CodexTransportPlan - if result.Code != "" || json.Unmarshal(result.Payload, &plan) != nil || (plan.Compress && !plan.Enabled) { - return plan, ErrNativeCodexRuntimeUnavailable - } - return plan, nil -} - -func prepareCodexTransport(req *http.Request, account *Account) (*http.Request, error) { - if req == nil || req.URL == nil || account == nil || !account.IsOpenAIOAuthLike() { - return req, nil - } - if err := requireCodexIdentityPolicy(req.Context(), account); err != nil { - return nil, err - } - plan, err := codexTransportPlan(req.Context(), account.Type, account.ID, extensionv1.CodexTransportQuery{ - Method: req.Method, Path: req.URL.Path, ContentType: req.Header.Get("Content-Type"), ContentEncoding: req.Header.Get("Content-Encoding"), - BodyPresent: req.Body != nil && req.Body != http.NoBody, - }) - if err != nil { - return nil, err - } - if !plan.Compress { - return req, nil - } - return prepareOpenAICodexWireRequestUngated(req, account) -} diff --git a/backend/internal/service/plugin_prompt_fixture_test.go b/backend/internal/service/plugin_prompt_fixture_test.go deleted file mode 100644 index 9819c94cfa4c..000000000000 --- a/backend/internal/service/plugin_prompt_fixture_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "strings" - - codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" - codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - - accounttools "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -type promptPolicyFixture struct{} - -type codexTransportFixtureContextKey struct{} - -func withCodexTransportFixture(ctx context.Context, enabled bool) context.Context { - return context.WithValue(ctx, codexTransportFixtureContextKey{}, enabled) -} - -func (promptPolicyFixture) InvokeOperation(ctx context.Context, _ string, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Capability == extensionv1.CapabilityRecovery { - return codexrecovery.Invoke(ctx, in) - } - if in.Operation == "codex.transport.plan" { - var query extensionv1.CodexTransportQuery - if err := json.Unmarshal(in.Payload, &query); err != nil { - return extensionv1.Result{}, err - } - enabled, _ := ctx.Value(codexTransportFixtureContextKey{}).(bool) - raw, err := json.Marshal(codexprofile.TransportPlan(query, enabled)) - return extensionv1.Result{Payload: raw}, err - } - if strings.HasPrefix(in.Operation, "codex.identity.") { - return codexprofile.Invoke(ctx, in) - } - if strings.HasPrefix(in.Operation, "taxonomy.") || strings.HasPrefix(in.Operation, "test.") || strings.HasPrefix(in.Operation, "import.") || in.Operation == "tools.describe" { - return accounttools.New().Invoke(ctx, in) - } - return extensionv1.Result{}, ErrExtensionOperationDisabled -} -func init() { - invokeNativeCodex = promptPolicyFixture{}.InvokeOperation - bindNativeCodexContext = func(ctx context.Context, _ string, _ string, _ extensionv1.Invocation) (context.Context, context.CancelFunc, error) { - bound, cancel := context.WithCancel(ctx) - return bound, cancel, nil - } -} diff --git a/backend/internal/service/plugin_provider.go b/backend/internal/service/plugin_provider.go deleted file mode 100644 index fc257b8b61e7..000000000000 --- a/backend/internal/service/plugin_provider.go +++ /dev/null @@ -1,10 +0,0 @@ -package service - -import "github.com/Wei-Shaw/sub2api/internal/config" - -// ProvidePluginManager composes the upstream third-party framework only. -func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kv PluginKVStore, _ *QuotaActivityService, gateway *OpenAIGatewayService, _ AccountTrafficObserveCache) *PluginManager { - manager := NewPluginManager(repo, encryptor, cfg, hostInfo, kv) - manager.SetAccountDirectory(gateway) - return manager -} diff --git a/backend/internal/service/policy_context.go b/backend/internal/service/policy_context.go index 747892b47123..7dc5cdf1e1ff 100644 --- a/backend/internal/service/policy_context.go +++ b/backend/internal/service/policy_context.go @@ -5,10 +5,9 @@ import "context" type policyCancellationSignalsKey struct{} // Policy cancellation is independent from a client disconnect. Upstream IO -// may detach from that disconnect, but must still stop on native policy replacement. +// may detach from that disconnect, but must still stop when a policy signal fires. func detachPolicyContext(parent context.Context) (context.Context, context.CancelFunc) { signals, _ := parent.Value(policyCancellationSignalsKey{}).([]context.Context) - signals = append(append([]context.Context(nil), signals...), nativeCodexPolicySignals(parent)...) if len(signals) == 0 { return context.WithoutCancel(parent), func() {} } diff --git a/backend/internal/service/policy_context_test.go b/backend/internal/service/policy_context_test.go index 72e895972ec2..47ae1eaea4eb 100644 --- a/backend/internal/service/policy_context_test.go +++ b/backend/internal/service/policy_context_test.go @@ -9,23 +9,18 @@ import ( ) func TestNativePolicyCancellationSurvivesDetachedClientContext(t *testing.T) { - for _, native := range []bool{false, true} { - client, cancelClient := context.WithCancel(context.Background()) - policy, cancelPolicy := context.WithCancel(context.Background()) - parent := context.WithValue(client, policyCancellationSignalsKey{}, []context.Context{policy}) - if native { - parent = context.WithValue(client, nativeCodexPolicySignalsKey{}, []context.Context{policy}) - } - detached, release := detachPolicyContext(parent) - cancelClient() - require.NoError(t, detached.Err(), "a client disconnect does not terminate detached work") - cancelPolicy() - select { - case <-detached.Done(): - case <-time.After(time.Second): - t.Fatal("native policy cancellation did not reach detached work") - } - require.ErrorIs(t, detached.Err(), context.Canceled) - release() + client, cancelClient := context.WithCancel(context.Background()) + policy, cancelPolicy := context.WithCancel(context.Background()) + parent := context.WithValue(client, policyCancellationSignalsKey{}, []context.Context{policy}) + detached, release := detachPolicyContext(parent) + cancelClient() + require.NoError(t, detached.Err(), "a client disconnect does not terminate detached work") + cancelPolicy() + select { + case <-detached.Done(): + case <-time.After(time.Second): + t.Fatal("native policy cancellation did not reach detached work") } + require.ErrorIs(t, detached.Err(), context.Canceled) + release() } diff --git a/backend/internal/service/quota_estimate.go b/backend/internal/service/quota_estimate.go index e0ae0bbf7ce6..ac0b45aa7212 100644 --- a/backend/internal/service/quota_estimate.go +++ b/backend/internal/service/quota_estimate.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "math" + "strings" "time" ) @@ -31,13 +32,29 @@ type QuotaEstimateRepository interface { ReadQuotaEstimate(context.Context, int64, string, string) (*QuotaEstimateObservation, *QuotaEstimateObservation, error) } +// quotaEstimateOwnerIdentity identifies the principal behind an account's +// credential. Token refresh changes access/refresh tokens, not the owner; a +// reauthorization to another principal changes it. Stored estimates are bound +// to this value, so its formula must stay stable. +func quotaEstimateOwnerIdentity(a *Account) string { + if a == nil { + return "" + } + parts := []string{a.Platform, a.Type, a.GetCredential("chatgpt_account_id"), a.GetCredential("chatgpt_user_id"), a.GetCredential("organization_id")} + if parts[2] == "" && parts[3] == "" { + parts = append(parts, a.GetCredential("email")) + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:]) +} + func quotaEstimateIdentity(account *Account) string { if account == nil { return "" } // Account-rate changes start a new calibration. The owner identity excludes // access-token refreshes, which do not represent a different subscription. - sum := sha256.Sum256([]byte(fmt.Sprintf("%s/%.17g", CodexCredentialOwnerIdentity(account), account.BillingRateMultiplier()))) + sum := sha256.Sum256([]byte(fmt.Sprintf("%s/%.17g", quotaEstimateOwnerIdentity(account), account.BillingRateMultiplier()))) return hex.EncodeToString(sum[:]) } diff --git a/backend/internal/service/quota_estimate_test.go b/backend/internal/service/quota_estimate_test.go index cd1f4042db31..25bee7f246be 100644 --- a/backend/internal/service/quota_estimate_test.go +++ b/backend/internal/service/quota_estimate_test.go @@ -67,3 +67,23 @@ func TestQuotaEstimateIdentityUsesValueNotPointerAddress(t *testing.T) { rateB = 2 require.NotEqual(t, quotaEstimateIdentity(a), quotaEstimateIdentity(&b)) } + +// Stored quota estimates are bound to the owner hash; these literals pin its +// formula. +func TestQuotaEstimateOwnerIdentityIsStable(t *testing.T) { + for want, account := range map[string]*Account{ + // sha256("openai\x00oauth\x00acc-1\x00user-1\x00org-1") + "ca774e5584e7eba4c7f6866dcb6b5768461c31443d5057edf0428d80c020799e": {Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "rotates", "chatgpt_account_id": "acc-1", "chatgpt_user_id": "user-1", "organization_id": "org-1", "email": "ignored@example.com"}}, + // Without account and user ids the email joins: sha256("openai\x00setup-token\x00\x00\x00\x00owner@example.com") + "b51584109ca9f089aa8eebb7c122ddbefcf3767642ce66dd9a99eb62881a5a57": {Platform: PlatformOpenAI, Type: AccountTypeSetupToken, + Credentials: map[string]any{"email": "owner@example.com"}}, + } { + if got := quotaEstimateOwnerIdentity(account); got != want { + t.Fatalf("quotaEstimateOwnerIdentity(%s) = %s, want %s", account.Type, got, want) + } + } + if quotaEstimateOwnerIdentity(nil) != "" { + t.Fatal("a missing account has an owner identity") + } +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index cd5c0e3fa693..827e0421e35e 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -2808,9 +2808,6 @@ func truncateTempUnschedMessage(body []byte, maxBytes int) string { // 根据系统设置决定是否标记账户为临时不可调度或错误状态 // 返回是否应该停止该账号的调度 func (s *RateLimitService) HandleStreamTimeout(ctx context.Context, account *Account, model string) bool { - if IsCodexQualityRequest(ctx) { - return false - } if account == nil { return false } diff --git a/backend/internal/service/setting_codex_runtime.go b/backend/internal/service/setting_codex_runtime.go new file mode 100644 index 000000000000..e24b416f8121 --- /dev/null +++ b/backend/internal/service/setting_codex_runtime.go @@ -0,0 +1,41 @@ +package service + +import ( + "context" + + extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" +) + +// SettingKeyCodexRuntimeConfig stores the Codex request compression switch. +// Without a stored value gateway.openai_codex_request_zstd applies. +const SettingKeyCodexRuntimeConfig = "codex_runtime_config" + +// EffectiveCodexRuntimeConfig returns the switch this process applies. +func EffectiveCodexRuntimeConfig() extensionv1.CodexRuntimeConfig { + return extensionv1.CodexRuntimeConfig{RequestZstd: codexRequestZstd.Load()} +} + +// LoadCodexRuntimeConfig installs the effective switch for this process at +// startup: the stored value, or gateway.openai_codex_request_zstd when none is +// stored. +func (s *SettingService) LoadCodexRuntimeConfig(ctx context.Context) error { + config := extensionv1.CodexRuntimeConfig{RequestZstd: true} + found, err := s.readNativeSwitchSetting(ctx, SettingKeyCodexRuntimeConfig, &config, "request_zstd") + if err != nil { + return err + } + if !found { + config.RequestZstd = s.cfg != nil && s.cfg.Gateway.OpenAICodexRequestZstd + } + SetCodexRequestZstdEnabled(config.RequestZstd) + return nil +} + +// UpdateCodexRuntimeConfig persists the switch and applies it to this process. +func (s *SettingService) UpdateCodexRuntimeConfig(ctx context.Context, config extensionv1.CodexRuntimeConfig) error { + if err := s.writeJSONSetting(ctx, SettingKeyCodexRuntimeConfig, config); err != nil { + return err + } + SetCodexRequestZstdEnabled(config.RequestZstd) + return nil +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index f7a564f88d8d..efa655dc3a44 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -292,6 +292,11 @@ func ProvideOpenAITokenProvider( } // ProvidePluginManager preserves account-directory wiring when regenerating Wire. +func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore, gateway *OpenAIGatewayService) *PluginManager { + manager := NewPluginManager(repo, encryptor, cfg, hostInfo, kvStore) + manager.SetAccountDirectory(gateway) + return manager +} // ProvideOpenAIQuotaService wires the OpenAI quota query/reset service. // It depends on the OpenAI token provider for refreshed access tokens and the @@ -928,9 +933,9 @@ func ProvideOpsIngressRejectAggregator(opsRepo OpsRepository, opsService *OpsSer // ProvideSettingService wires SettingService with group reader and proxy repo. func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config, _ *NativeFeatureBootstrap) (*SettingService, error) { svc := NewSettingService(settingRepo, cfg) - // Image tool and observability switches are read by package-level gates - // before Image Studio and the gateway start. - for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig} { + // Image tool, observability and Codex request compression switches are read + // by package-level gates before Image Studio and the gateway start. + for _, load := range []func(context.Context) error{svc.LoadImageToolsConfig, svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { if err := load(context.Background()); err != nil { return nil, err } @@ -998,7 +1003,6 @@ func ProvideAPIKeyService( // ProviderSet is the Wire provider set for all services var ProviderSet = wire.NewSet( ProvideNativeFeatureBootstrap, - ProvideNativeCodexRuntime, // Core services ProvideAuthService, NewPasskeyService, diff --git a/backend/internal/testextensions/fixtures.go b/backend/internal/testextensions/fixtures.go index c84752c0e06a..8235fbda7397 100644 --- a/backend/internal/testextensions/fixtures.go +++ b/backend/internal/testextensions/fixtures.go @@ -5,12 +5,8 @@ package testextensions import ( "context" - "encoding/json" "strings" - codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" - codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - accounttools "github.com/Wei-Shaw/sub2api/internal/accounttools/policy" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" @@ -18,21 +14,7 @@ import ( type operations struct{} -func (operations) InvokeOperation(ctx context.Context, _, _ string, in extensionv1.Invocation) (extensionv1.Result, error) { - if in.Capability == extensionv1.CapabilityRecovery { - return codexrecovery.Invoke(ctx, in) - } - if strings.HasPrefix(in.Operation, "codex.identity.") { - return codexprofile.Invoke(ctx, in) - } - if in.Operation == "codex.transport.plan" { - var query extensionv1.CodexTransportQuery - if err := json.Unmarshal(in.Payload, &query); err != nil { - return extensionv1.Result{}, err - } - raw, err := json.Marshal(codexprofile.TransportPlan(query, false)) - return extensionv1.Result{Payload: raw}, err - } +func (operations) InvokeOperation(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) { if strings.HasPrefix(in.Operation, "taxonomy.") || strings.HasPrefix(in.Operation, "test.") || strings.HasPrefix(in.Operation, "import.") || in.Operation == "tools.describe" { return accounttools.New().Invoke(ctx, in) } diff --git a/backend/migrations/264_purge_codex_runtime_plugin_data.sql b/backend/migrations/264_purge_codex_runtime_plugin_data.sql new file mode 100644 index 000000000000..f34604c62f5d --- /dev/null +++ b/backend/migrations/264_purge_codex_runtime_plugin_data.sql @@ -0,0 +1,188 @@ +-- Purge what the Codex runtime plugin (codexrip.codex-runtime) left stored. Its +-- route acquisition, quality diagnostics, outbound observation and runtime host +-- are gone; the application reads nothing this migration deletes. The one +-- setting that lives on, request body compression, moves to a plain settings +-- row. +-- +-- The migration raises instead of guessing, and then changes nothing, when +-- * the plugin's installation exists and settings.deplugin_retired_plugins has +-- no entry for it: the installation was never retired; +-- * a saved configuration exists (1) and the hash recorded for it is missing +-- or is neither value listed there; +-- * sub2api_plugin_state holds a row of another plugin key (2). +-- Starting v0.2.11-codexrip.6, .7 or .8 once retires the installation and +-- records the hash. A new database only loses the unused tables of 2. +-- +-- 1. Compression setting. The saved configuration is ciphertext +-- (settings.codex_native_runtime_source, or the installation's +-- config_encrypted while no source row existed) and is not decrypted here. +-- Every start and every save of v0.2.11-codexrip.6, .7 and .8 recorded the +-- SHA-256 of the normalized configuration in +-- settings.codex_native_runtime_config, and the normalized configuration +-- has two possible values: +-- {"request_zstd":true} 7818a2a138b902a949b9fd77ba6bb62d8d77dcfbfa93ca1a16e778c609172304 +-- {"request_zstd":false} 1ee89db3b3c06e8da7f0524370502e503df48204fa16813ded97b7981b653aaa +-- A saved configuration overrode gateway.openai_codex_request_zstd, so its +-- value is written to settings.codex_runtime_config. Without a saved +-- configuration the deployment value applied and still applies: no row is +-- written and the recorded hash is not read. An existing +-- codex_runtime_config row is kept. Both former settings rows are deleted. +-- 2. sub2api_plugin_state and sub2api_plugin_leases (246), the former with its +-- index sub2api_plugin_state_due and its column next_at: nothing reads or +-- writes them, so both tables are dropped. The state table held this +-- plugin's rows (quality-run.*, validation.*, spent.*, wire.* and anything +-- else). A row of another plugin key has no reader either; it raises so +-- that it is not dropped unseen. +-- 3. The codexrip.codex-runtime installation. A row the runtime host created +-- for itself on a database that never had the plugin (receipt entry +-- native_created) is deleted with its receipt entry. A retired plugin +-- installation keeps its row and its receipt entry, like the other retired +-- plugins, without the plugin's material: the saved configuration (which +-- held the acquisition proxy credential), the package bytes with their +-- hashes and paths, the manifest and description, the capability bindings, +-- a staged update and the bundle bootstrap record. Other installations and +-- their receipt entries are not touched. +-- 4. accounts.extra.codex_client_identity: the selectable client profile +-- (schema 2) is removed, so the identity is derived from the account's +-- fingerprint seed again, and the members source, originator and +-- client_version that came with it are dropped from the remaining +-- identities. +-- 5. accounts.extra.codex_fingerprint_mode: the account creation form could +-- leave the mode unset to follow the plugin's default. An OpenAI OAuth or +-- Setup Token account that is not deleted and stores no mode is written as +-- "device", which is what a missing mode means; a stored mode, "off" +-- included, is kept. +-- +-- Audit logs, account credentials and files in the plugin directory are not +-- touched. An account row is written only when 4 or 5 changes it. accounts has +-- no updated_at trigger; writing extra fires the long-context billing triggers +-- of 175, which keep the stored flag and queue no scheduler_outbox event. Every +-- statement is a no-op once its rows or objects are gone, so the migration can +-- run again. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +DO $$ +DECLARE + receipt jsonb; + entry jsonb; + recorded text; + zstd text; + others text; +BEGIN + SELECT value::jsonb INTO receipt FROM settings WHERE key = 'deplugin_retired_plugins'; + entry := receipt -> 'plugins' -> 'codexrip.codex-runtime'; + + IF EXISTS (SELECT 1 FROM sub2api_plugin_installations WHERE plugin_key = 'codexrip.codex-runtime') + AND COALESCE(jsonb_typeof(entry), '') <> 'object' THEN + RAISE EXCEPTION 'migration 264: the codexrip.codex-runtime plugin installation was never retired; start v0.2.11-codexrip.6, .7 or .8 once, then upgrade'; + END IF; + + IF to_regclass('sub2api_plugin_state') IS NOT NULL THEN + SELECT string_agg(plugin_key, ', ' ORDER BY plugin_key) INTO others + FROM ( + SELECT DISTINCT plugin_key + FROM sub2api_plugin_state + WHERE plugin_key <> 'codexrip.codex-runtime' + ORDER BY plugin_key + LIMIT 5 + ) AS other_keys; + IF others IS NOT NULL THEN + RAISE EXCEPTION 'migration 264: sub2api_plugin_state holds rows of other plugins (%); nothing reads them and the table is dropped, so export or delete them, then upgrade', others; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM settings WHERE key = 'codex_native_runtime_source') + OR EXISTS (SELECT 1 FROM sub2api_plugin_installations + WHERE plugin_key = 'codexrip.codex-runtime' AND config_encrypted <> '') THEN + SELECT value::jsonb ->> 'config_sha256' INTO recorded + FROM settings WHERE key = 'codex_native_runtime_config'; + zstd := CASE recorded + WHEN '7818a2a138b902a949b9fd77ba6bb62d8d77dcfbfa93ca1a16e778c609172304' THEN '{"request_zstd":true}' + WHEN '1ee89db3b3c06e8da7f0524370502e503df48204fa16813ded97b7981b653aaa' THEN '{"request_zstd":false}' + END; + IF zstd IS NULL THEN + RAISE EXCEPTION 'migration 264: a saved Codex runtime configuration exists but its recorded hash (%) is neither known value; start v0.2.11-codexrip.6, .7 or .8 once, then upgrade', COALESCE(recorded, 'none'); + END IF; + INSERT INTO settings (key, value, updated_at) + VALUES ('codex_runtime_config', zstd, NOW()) + ON CONFLICT (key) DO NOTHING; + END IF; + + IF entry -> 'native_created' = 'true'::jsonb THEN + DELETE FROM sub2api_plugin_installations WHERE plugin_key = 'codexrip.codex-runtime'; + UPDATE settings + SET value = (receipt #- '{plugins,codexrip.codex-runtime}')::text, + updated_at = NOW() + WHERE key = 'deplugin_retired_plugins'; + END IF; +END $$; + +DELETE FROM settings +WHERE key IN ('codex_native_runtime_source', 'codex_native_runtime_config'); + +DROP TABLE IF EXISTS sub2api_plugin_state; +DROP TABLE IF EXISTS sub2api_plugin_leases; + +DELETE FROM sub2api_plugin_bindings +WHERE plugin_id IN (SELECT id FROM sub2api_plugin_installations WHERE plugin_key = 'codexrip.codex-runtime'); + +DELETE FROM sub2api_plugin_updates +WHERE plugin_id IN (SELECT id FROM sub2api_plugin_installations WHERE plugin_key = 'codexrip.codex-runtime'); + +DELETE FROM sub2api_plugin_bootstrap +WHERE plugin_key = 'codexrip.codex-runtime'; + +UPDATE sub2api_plugin_installations +SET description = '', + manifest = '{}'::jsonb, + artifact_path = '', + install_path = '', + binary_path = '', + binary_sha256 = '', + signature_status = 'unsigned', + config_encrypted = '', + last_error = '', + artifact_data = NULL, + package_sha256 = '', + update_policy = 'pinned', + updated_at = NOW() +WHERE plugin_key = 'codexrip.codex-runtime' + AND ( + description <> '' + OR manifest <> '{}'::jsonb + OR artifact_path <> '' + OR install_path <> '' + OR binary_path <> '' + OR binary_sha256 <> '' + OR signature_status <> 'unsigned' + OR config_encrypted <> '' + OR last_error <> '' + OR artifact_data IS NOT NULL + OR package_sha256 <> '' + OR update_policy <> 'pinned' + ); + +UPDATE accounts +SET extra = extra - 'codex_client_identity' +WHERE jsonb_typeof(extra) = 'object' + AND jsonb_typeof(extra -> 'codex_client_identity') = 'object' + AND extra -> 'codex_client_identity' -> 'v' = '2'::jsonb; + +UPDATE accounts +SET extra = jsonb_set( + extra, + '{codex_client_identity}', + (extra -> 'codex_client_identity') - ARRAY['source', 'originator', 'client_version'] + ) +WHERE jsonb_typeof(extra) = 'object' + AND jsonb_typeof(extra -> 'codex_client_identity') = 'object' + AND (extra -> 'codex_client_identity') ?| ARRAY['source', 'originator', 'client_version']; + +UPDATE accounts +SET extra = jsonb_set(extra, '{codex_fingerprint_mode}', '"device"'::jsonb, true) +WHERE deleted_at IS NULL + AND platform = 'openai' + AND type IN ('oauth', 'setup-token') + AND jsonb_typeof(extra) = 'object' + AND COALESCE(btrim(extra ->> 'codex_fingerprint_mode'), '') = ''; diff --git a/deploy/.env.example b/deploy/.env.example index 0141adc371f3..456d232a9a2b 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -297,6 +297,7 @@ RATE_LIMIT_OVERLOAD_COOLDOWN_MINUTES=10 GATEWAY_FORCE_CODEX_CLI=false # 对 OpenAI OAuth 账号发往 ChatGPT 后端的流式 /responses 请求体做 zstd 压缩(level 3), # 与官方 Codex 客户端一致;出现兼容问题时置 false 回退为明文 JSON。 +# 管理后台「Codex 运行设置」保存过开关后以保存值为准,本项只在未保存时生效。 # # 默认:true GATEWAY_OPENAI_CODEX_REQUEST_ZSTD=true diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 775c3cc56bc8..566d819ca8f8 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -271,8 +271,10 @@ gateway: # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 force_codex_cli: false # Compress Codex OAuth streaming /responses request bodies with zstd (level 3), like the official Codex client. + # A switch saved on the admin "Codex runtime settings" page takes precedence; this key applies only until one is saved. # 对 OpenAI OAuth 账号发往 ChatGPT 后端的流式 /responses 请求体做 zstd 压缩(level 3),与官方 Codex 客户端一致; # compact 等其他请求不压缩。出现兼容问题时置 false 回退为明文 JSON。 + # 管理后台「Codex 运行设置」保存过开关后以保存值为准,本项只在未保存时生效。 openai_codex_request_zstd: true # Observe-only per-account traffic telemetry for the OpenAI-family gateway (Redis counters only; no scheduling/admission change). # 账号级流量遥测(只观察):仅写 Redis 计数器 account_traffic_observe:{id}:*(24h TTL), diff --git a/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts b/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts index 7419992ce068..ed11b6b0c77e 100644 --- a/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts +++ b/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts @@ -3,26 +3,22 @@ import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { stabilizeAccountOperationKey, settleAccountOperation, clearAccountOperationKeys } from '../accountOperationIdempotency' const request = (key: string, ids = [1]) => ({ method: 'post', url: '/admin/accounts/batch-delete', data: { account_ids: ids }, headers: new AxiosHeaders({ 'Idempotency-Key': key }) }) as InternalAxiosRequestConfig -const pluginRequest = (key: string) => ({ +const fullPathRequest = (key: string) => ({ method: 'post', url: '/api/v1/admin/accounts/batch-test', baseURL: '', data: { items: [{ account_id: 1, model_id: 'fixture-model' }], prompt: '' }, - headers: new AxiosHeaders({ - 'Idempotency-Key': key, - 'X-Sub2API-Plugin': '7', - 'X-Sub2API-Plugin-Package': 'a'.repeat(64), - }), + headers: new AxiosHeaders({ 'Idempotency-Key': key }), }) as InternalAxiosRequestConfig beforeEach(() => { localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) }) afterEach(() => { clearAccountOperationKeys(); localStorage.removeItem('auth_user'); vi.unstubAllGlobals() }) describe('uncertain account operation submission', () => { - it('reuses a lost-response key for full-path plugin account resources', async () => { + it('reuses a lost-response key for full-path account resources', async () => { vi.stubGlobal('crypto', webcrypto) - const first = pluginRequest('first-plugin-key') + const first = fullPathRequest('first-key') await stabilizeAccountOperationKey(first) - const retry = pluginRequest('new-plugin-key') + const retry = fullPathRequest('new-key') await stabilizeAccountOperationKey(retry) - expect(retry.headers.get('Idempotency-Key')).toBe('first-plugin-key') + expect(retry.headers.get('Idempotency-Key')).toBe('first-key') }) it('reuses the key after a lost response, isolates changed targets and releases confirmed submissions', async () => { vi.stubGlobal('crypto', webcrypto) @@ -46,51 +42,48 @@ describe('uncertain account operation submission', () => { expect(unrelated.headers.get('Idempotency-Key')).toBe('unrelated') }) - it('partitions plugin, package, plain request and original destination identities', async () => { + it('partitions requests by path and destination', async () => { vi.stubGlobal('crypto', webcrypto) - await stabilizeAccountOperationKey(pluginRequest('original')) + await stabilizeAccountOperationKey(fullPathRequest('original')) const mutations = [ - (value: InternalAxiosRequestConfig) => { value.headers.set('X-Sub2API-Plugin', '8') }, - (value: InternalAxiosRequestConfig) => { value.headers.set('X-Sub2API-Plugin-Package', 'b'.repeat(64)) }, - (value: InternalAxiosRequestConfig) => { value.headers.delete('X-Sub2API-Plugin'); value.headers.delete('X-Sub2API-Plugin-Package') }, (value: InternalAxiosRequestConfig) => { value.url = '/admin/accounts/batch-test' }, (value: InternalAxiosRequestConfig) => { value.baseURL = 'https://fixture.invalid' }, ] for (const [index, mutate] of mutations.entries()) { - const changed = pluginRequest(`partition-${index}`) + const changed = fullPathRequest(`partition-${index}`) mutate(changed) await stabilizeAccountOperationKey(changed) expect(changed.headers.get('Idempotency-Key')).toBe(`partition-${index}`) - const retry = pluginRequest(`retry-${index}`) + const retry = fullPathRequest(`retry-${index}`) mutate(retry) await stabilizeAccountOperationKey(retry) expect(retry.headers.get('Idempotency-Key')).toBe(`partition-${index}`) } - const originalRetry = pluginRequest('original-retry') + const originalRetry = fullPathRequest('original-retry') await stabilizeAccountOperationKey(originalRetry) expect(originalRetry.headers.get('Idempotency-Key')).toBe('original') }) it('isolates administrators even before the store clears its session', async () => { vi.stubGlobal('crypto', webcrypto) - await stabilizeAccountOperationKey(pluginRequest('user-seven')) + await stabilizeAccountOperationKey(fullPathRequest('user-seven')) localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) - const first = pluginRequest('user-eight') + const first = fullPathRequest('user-eight') await stabilizeAccountOperationKey(first) expect(first.headers.get('Idempotency-Key')).toBe('user-eight') - const retry = pluginRequest('user-eight-retry') + const retry = fullPathRequest('user-eight-retry') await stabilizeAccountOperationKey(retry) expect(retry.headers.get('Idempotency-Key')).toBe('user-eight') }) it('does not cache retry identities without a valid persisted actor', async () => { vi.stubGlobal('crypto', webcrypto) - await stabilizeAccountOperationKey(pluginRequest('authenticated')) + await stabilizeAccountOperationKey(fullPathRequest('authenticated')) for (const raw of [null, '{invalid', JSON.stringify({ id: 0 }), JSON.stringify({ id: '7' }), JSON.stringify({ id: Number.MAX_SAFE_INTEGER + 1 })]) { if (raw === null) localStorage.removeItem('auth_user') else localStorage.setItem('auth_user', raw) - const first = pluginRequest('unidentified-first') - const retry = pluginRequest('unidentified-retry') + const first = fullPathRequest('unidentified-first') + const retry = fullPathRequest('unidentified-retry') await stabilizeAccountOperationKey(first) await stabilizeAccountOperationKey(retry) expect(first.headers.get('Idempotency-Key')).toBe('unidentified-first') @@ -102,7 +95,7 @@ describe('uncertain account operation submission', () => { it.each(['actor', 'epoch'])('rejects an %s change while the digest is pending', async (change) => { let completeDigest!: (value: ArrayBuffer) => void vi.stubGlobal('crypto', { subtle: { digest: () => new Promise(resolve => { completeDigest = resolve }) } }) - const first = pluginRequest('pending') + const first = fullPathRequest('pending') const pending = stabilizeAccountOperationKey(first) if (change === 'actor') localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) else clearAccountOperationKeys() @@ -112,21 +105,21 @@ describe('uncertain account operation submission', () => { expect(first).not.toHaveProperty('accountOperationSignature') expect(first.headers.get('Idempotency-Key')).toBe('pending') vi.stubGlobal('crypto', webcrypto) - const next = pluginRequest('new-context') + const next = fullPathRequest('new-context') await stabilizeAccountOperationKey(next) expect(next.headers.get('Idempotency-Key')).toBe('new-context') }) it('includes full-path account jobs but leaves other route families alone', async () => { vi.stubGlobal('crypto', webcrypto) - const first = pluginRequest('job-first'); first.url = '/api/v1/admin/account-jobs/19/retry' + const first = fullPathRequest('job-first'); first.url = '/api/v1/admin/account-jobs/19/retry' await stabilizeAccountOperationKey(first) - const retry = pluginRequest('job-retry'); retry.url = first.url + const retry = fullPathRequest('job-retry'); retry.url = first.url await stabilizeAccountOperationKey(retry) expect(retry.headers.get('Idempotency-Key')).toBe('job-first') for (const url of ['/api/v1/admin/settings', '/api/v1/admin/accounts-other', '/api/v2/admin/accounts/batch-test']) { - const unrelated = pluginRequest('unrelated-first'); unrelated.url = url - const again = pluginRequest('unrelated-second'); again.url = url + const unrelated = fullPathRequest('unrelated-first'); unrelated.url = url + const again = fullPathRequest('unrelated-second'); again.url = url await stabilizeAccountOperationKey(unrelated) await stabilizeAccountOperationKey(again) expect(again.headers.get('Idempotency-Key')).toBe('unrelated-second') diff --git a/frontend/src/api/accountOperationIdempotency.ts b/frontend/src/api/accountOperationIdempotency.ts index a679fa25dd68..2a91e97b8088 100644 --- a/frontend/src/api/accountOperationIdempotency.ts +++ b/frontend/src/api/accountOperationIdempotency.ts @@ -26,11 +26,9 @@ export async function stabilizeAccountOperationKey(config: OperationConfig): Pro const actor = currentAccountOperationActor() if (actor === null) return const epoch = session - // 仅用于浏览器内重试键分区;服务端权限仍由真实认证和插件绑定校验决定。 + // 仅用于浏览器内重试键分区;服务端权限仍由真实认证决定。 const payload = JSON.stringify([ actor, config.method, url, config.baseURL ?? '', - config.headers.get('X-Sub2API-Plugin') ?? null, - config.headers.get('X-Sub2API-Plugin-Package') ?? null, typeof config.data === 'string' ? config.data : JSON.stringify(config.data), ]) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)) diff --git a/frontend/src/api/admin/codexRuntime.ts b/frontend/src/api/admin/codexRuntime.ts index 527891217575..b2155fb0c744 100644 --- a/frontend/src/api/admin/codexRuntime.ts +++ b/frontend/src/api/admin/codexRuntime.ts @@ -1,21 +1,17 @@ import { apiClient } from '../client' -// Native Codex runtime settings. The server defines only request_zstd; other keys it may -// return are tolerated when read and never sent back. -export interface CodexRuntimeConfig extends Record { - request_zstd?: boolean -} - -export interface CodexRuntimeSettingsUpdate { +export interface CodexRuntimeSettings { request_zstd: boolean } export const codexRuntimeAPI = { - async getConfig(): Promise { - return (await apiClient.get('/admin/settings/codex-runtime', { rawPluginConfig: true })).data + async getSettings(): Promise { + const { data } = await apiClient.get('/admin/settings/codex-runtime') + return data }, - async saveConfig(config: CodexRuntimeSettingsUpdate): Promise { - return (await apiClient.put('/admin/settings/codex-runtime', config, { rawPluginConfig: true })).data + async updateSettings(settings: CodexRuntimeSettings): Promise { + const { data } = await apiClient.put('/admin/settings/codex-runtime', settings) + return data }, async diagnostics(errorId: number, signal?: AbortSignal): Promise { return (await apiClient.get(`/admin/ops/errors/${errorId}/diagnostics`, { signal })).data diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index f1292cb61501..001668cb5720 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -2140,7 +2140,7 @@ const buildUpdatePayload = (): Record | null => { // Create/Edit 那两个表单可以删键,是因为它们提交完整 extra 对象、后端整体 // SetExtra 覆盖;批量接口只合并增量键,两种持久化语义不能共用同一套写法。 // - // 读取侧的缺失键由后端/插件决定默认值,因此显式 off 必须落键才能真正关闭收敛; + // 读取侧对缺失键默认 device,因此显式 off 必须落键才能真正关闭收敛; // ShouldEnsureCodexFingerprintSeedForExtraUpdates 只在显式 device/session/full // 时要种子,off 不会触发。 // diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 76a90f26598d..49f8ebe317b0 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4561,10 +4561,9 @@ const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) -type CodexFingerprintMode = 'default' | 'off' | 'device' | 'session' | 'full' -const codexFingerprintMode = ref('default') +type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' +const codexFingerprintMode = ref('device') const codexFingerprintModeOptions = computed(() => [ - { value: 'default' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDefault') }, { value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') }, { value: 'device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDevice') }, { value: 'session' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintSession') }, @@ -5563,7 +5562,7 @@ const resetForm = () => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false - codexFingerprintMode.value = 'default' + codexFingerprintMode.value = 'device' anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' @@ -5664,10 +5663,8 @@ const buildOpenAIExtra = (base?: Record): Record ({ - accounts: { - create: vi.fn(), - importCodexSession: vi.fn(), - createOpenAICodexPAT: vi.fn(), - generateAuthUrl: vi.fn(), - exchangeCode: vi.fn(), - refreshOpenAIToken: vi.fn(), - probeUpstreamBilling: vi.fn().mockResolvedValue({}), - syncUpstreamModels: vi.fn().mockResolvedValue({ models: [], metadata: {} }), - previewModelContextCapacities: vi.fn().mockResolvedValue({ capacity_rows: [] }), - }, - buildExtraInfo: vi.fn(), - showError: vi.fn(), - unexpectedNetwork: vi.fn(() => { throw new Error('Unexpected network in fingerprint regression') }), -})) - -vi.mock('@/api/admin', () => ({ - adminAPI: { - accounts, - settings: { - getSettings: vi.fn().mockResolvedValue({}), - getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }), - }, - tlsFingerprintProfiles: { list: vi.fn().mockResolvedValue([]) }, - }, -})) -vi.mock('@/api/admin/accounts', () => ({ - getAntigravityDefaultModelMapping: vi.fn().mockResolvedValue([]), -})) -vi.mock('@/stores/app', () => ({ - useAppStore: () => ({ showError, showSuccess: vi.fn(), showWarning: vi.fn() }), -})) -vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isSimpleMode: true }) })) - -vi.mock('vue-i18n', async () => ({ - ...await vi.importActual('vue-i18n'), - useI18n: () => ({ t: (key: string) => key }), -})) -vi.mock('@/composables/useOpenAIOAuth', async () => { - const actual = await vi.importActual('@/composables/useOpenAIOAuth') - return { useOpenAIOAuth: () => ({ ...actual.useOpenAIOAuth(), buildExtraInfo }) } -}) - -import CreateAccountModal from '../CreateAccountModal.vue' - -const AccountOperationDialogStub = defineComponent({ - props: ['show', 'job'], - template: '
', -}) -const SelectStub = defineComponent({ - props: ['modelValue', 'options'], - emits: ['update:modelValue'], - template: ``, -}) -const OAuthFlowStub = defineComponent({ - name: 'OAuthAuthorizationFlow', - data: () => ({ inputMethod: 'manual', authCode: '', oauthState: '' }), - methods: { reset() { this.authCode = ''; this.oauthState = ''; this.inputMethod = 'manual' } }, - emits: ['generate-url', 'validate-refresh-token', 'validate-mobile-refresh-token', 'import-codex-session', 'import-codex-pat'], - template: '
', -}) - -type CreationPath = 'API key' | 'authorization code' | 'refresh-token batch' | 'mobile refresh-token batch' | 'Codex session' | 'Agent Identity' | 'Codex PAT' -const fingerprintSelector = '[data-testid="create-codex-fingerprint-mode-select"]' -const wrappers: VueWrapper[] = [] -let restoreNetwork: () => void - -function mountModal() { - const wrapper = shallowMount(CreateAccountModal, { - props: { show: true, proxies: [], groups: [] }, - global: { stubs: { AccountOperationDialog: AccountOperationDialogStub, Select: SelectStub, OAuthAuthorizationFlow: OAuthFlowStub } }, - }) - wrappers.push(wrapper) - return wrapper -} - -async function clickText(wrapper: VueWrapper, text: string) { - const button = wrapper.findAll('button').find(candidate => - candidate.text() === text || candidate.findAll('span').some(span => span.text() === text)) - expect(button, `button ${text}`).toBeDefined() - await button!.trigger('click') -} - -async function submitPath(wrapper: VueWrapper, path: CreationPath) { - if (path === 'API key') await clickText(wrapper, 'API Key') - await wrapper.get('form#create-account-form input[type="text"]').setValue('fingerprint fixture') - if (path === 'API key') { - await wrapper.get('form#create-account-form input[type="password"]').setValue('fixture-key') - await flushPromises() - } - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await flushPromises() - - if (path !== 'API key') { - const flow = wrapper.getComponent(OAuthFlowStub) - if (path === 'authorization code') { - flow.vm.$emit('generate-url') - await flushPromises() - flow.vm.authCode = 'fixture-auth-code' - await nextTick() - await clickText(wrapper, 'admin.accounts.oauth.completeAuth') - } else if (path === 'refresh-token batch' || path === 'mobile refresh-token batch') { - flow.vm.$emit(path === 'refresh-token batch' ? 'validate-refresh-token' : 'validate-mobile-refresh-token', 'fixture-rt-1\nfixture-rt-2') - } else if (path === 'Codex PAT') { - flow.vm.$emit('import-codex-pat', 'fixture-pat') - } else { - if (path === 'Agent Identity') flow.vm.inputMethod = 'agent_identity' - flow.vm.$emit('import-codex-session', path === 'Agent Identity' - ? JSON.stringify({ auth_mode: 'agentIdentity', agent_identity: { agent_runtime_id: 'fixture-runtime' } }) - : 'fixture-session-json') - } - await flushPromises() - } - - const request = path === 'Codex PAT' ? accounts.createOpenAICodexPAT - : path === 'Codex session' || path === 'Agent Identity' ? accounts.importCodexSession - : accounts.create - expect(request).toHaveBeenCalledTimes(path.includes('batch') ? 2 : 1) - return request.mock.calls.map(([payload]) => payload) -} - -beforeEach(() => { - vi.clearAllMocks() - accounts.create.mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' }) - accounts.importCodexSession.mockResolvedValue({ id: 51, kind: 'account_codex_import', status: 'pending' }) - accounts.createOpenAICodexPAT.mockResolvedValue({}) - accounts.generateAuthUrl.mockResolvedValue({ auth_url: 'https://example.invalid/authorize?state=fixture-state', session_id: 'fixture-session' }) - const tokenInfo = { access_token: 'fixture-access-token', refresh_token: 'fixture-refresh-token', email: 'fixture@example.invalid' } - accounts.exchangeCode.mockResolvedValue(tokenInfo) - accounts.refreshOpenAIToken.mockResolvedValue(tokenInfo) - buildExtraInfo.mockReturnValue({ email: 'fixture@example.invalid' }) - vi.stubGlobal('fetch', unexpectedNetwork) - const send = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(unexpectedNetwork) - restoreNetwork = () => send.mockRestore() -}) - -afterEach(() => { - wrappers.splice(0).forEach(wrapper => wrapper.unmount()) - restoreNetwork() - vi.unstubAllGlobals() - expect(showError).not.toHaveBeenCalled() - expect(unexpectedNetwork).not.toHaveBeenCalled() -}) - -describe('CreateAccountModal Codex fingerprint ownership', () => { - it('offers a distinct backend-default choice alongside every explicit mode', async () => { - const wrapper = mountModal() - await clickText(wrapper, 'OpenAI') - const select = wrapper.get(fingerprintSelector) - expect(select.element.value).toBe('default') - expect(select.findAll('option').map(option => option.attributes('value'))).toEqual(['default', 'off', 'device', 'session', 'full']) - expect(select.get('option[value="default"]').text()).toBe('admin.accounts.openai.codexFingerprintDefault') - }) - - it.each(['API key', 'authorization code', 'refresh-token batch', 'mobile refresh-token batch', 'Codex session', 'Agent Identity', 'Codex PAT'])( - 'omits an untouched fingerprint override for %s creation without a plugin', async path => { - const wrapper = mountModal() - await clickText(wrapper, 'OpenAI') - for (const payload of await submitPath(wrapper, path)) { - expect(payload.extra).not.toHaveProperty('codex_fingerprint_mode') - } - }, - ) - - it.each(['off', 'session', 'device', 'full'])( - 'preserves explicit %s in every account of a refresh-token batch', async mode => { - const wrapper = mountModal() - await clickText(wrapper, 'OpenAI') - await wrapper.get(fingerprintSelector).setValue(mode) - for (const payload of await submitPath(wrapper, 'refresh-token batch')) { - expect(payload.extra.codex_fingerprint_mode).toBe(mode) - } - }, - ) - - it('does not conflate explicit off with backend default on the import path', async () => { - const wrapper = mountModal() - await clickText(wrapper, 'OpenAI') - await wrapper.get(fingerprintSelector).setValue('off') - const [payload] = await submitPath(wrapper, 'Codex PAT') - expect(payload.extra.codex_fingerprint_mode).toBe('off') - }) - - it('removes a stale inherited override when the user returns to default without losing other extra fields', async () => { - const inherited = { codex_fingerprint_mode: 'full', email: 'fixture@example.invalid', privacy_mode: 'training-disabled' } - buildExtraInfo.mockReturnValue(inherited) - const wrapper = mountModal() - await clickText(wrapper, 'OpenAI') - await wrapper.get(fingerprintSelector).setValue('session') - await wrapper.get(fingerprintSelector).setValue('default') - const [payload] = await submitPath(wrapper, 'authorization code') - expect(payload.extra).not.toHaveProperty('codex_fingerprint_mode') - expect(payload.extra).toMatchObject({ email: inherited.email, privacy_mode: inherited.privacy_mode }) - expect(inherited.codex_fingerprint_mode).toBe('full') - }) - - it('resets an explicit choice to omission after closing and reopening the modal', async () => { - const wrapper = mountModal() - await clickText(wrapper, 'OpenAI') - await wrapper.get(fingerprintSelector).setValue('device') - await clickText(wrapper, 'common.cancel') - expect(wrapper.emitted('close')).toHaveLength(1) - await wrapper.setProps({ show: false }) - await wrapper.setProps({ show: true }) - await clickText(wrapper, 'OpenAI') - expect(wrapper.get(fingerprintSelector).element.value).toBe('default') - const [payload] = await submitPath(wrapper, 'Codex session') - expect(payload.extra).not.toHaveProperty('codex_fingerprint_mode') - }) -}) diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 47310d23a026..131a3f84c1dc 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -569,6 +569,28 @@ describe('CreateAccountModal OpenAI long-context billing', () => { wrapper.unmount() }) + it('submits the Codex fingerprint mode of an OAuth-based account, explicit off included, and none for an API-key account', async () => { + const untouched = await openCodexImportStep() + await untouched.get('[data-testid="import-codex-session"]').trigger('click') + await flushPromises() + expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.codex_fingerprint_mode).toBe('device') + untouched.unmount() + + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await wrapper.get('[data-testid="create-codex-fingerprint-mode-select"]').setValue('off') + await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await wrapper.get('[data-testid="import-codex-pat"]').trigger('click') + await flushPromises() + expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.codex_fingerprint_mode).toBe('off') + wrapper.unmount() + + const apiKey = await submitApiKeyAccount('openai') + expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('codex_fingerprint_mode') + apiKey.unmount() + }) + it('creates a Cindy account as an ordinary OpenAI API-key account through the Laxa preset', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') diff --git a/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue index 7e84185e48c7..e485eb310bba 100644 --- a/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue +++ b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue @@ -1,7 +1,7 @@