From b8c14f5dd123e2e2ea7ea5600f625a8ccf82a82f Mon Sep 17 00:00:00 2001 From: zyycn Date: Fri, 19 Jun 2026 22:13:30 +0800 Subject: [PATCH] chore: clean project test layout Co-authored-by: Codex --- Cargo.lock | 9 - Cargo.toml | 19 - README.md | 70 +- crates/adapters/tests/codex.rs | 3398 +------- .../adapters/tests/codex/codex_fingerprint.rs | 179 + crates/adapters/tests/codex/codex_headers.rs | 722 ++ .../adapters/tests/codex/codex_http_client.rs | 208 + .../adapters/tests/codex/codex_websocket.rs | 1308 +++ .../tests/codex/codex_websocket_pool.rs | 1016 +++ crates/adapters/tests/models.rs | 14 +- crates/core/src/accounts/cloudflare.rs | 60 - crates/core/src/accounts/jwt.rs | 44 - crates/core/src/gateway/conversation.rs | 85 - crates/core/src/gateway/installation.rs | 30 - crates/core/src/serving/implicit_resume.rs | 33 - crates/core/src/serving/reasoning_replay.rs | 33 - crates/core/tests/account_pool.rs | 540 +- crates/core/tests/account_pool/quota.rs | 182 + crates/core/tests/account_pool/selection.rs | 191 + .../core/tests/account_pool/usage_window.rs | 165 + crates/core/tests/protocol.rs | 1725 +--- .../protocol/protocol_codex_websocket.rs | 908 ++ .../tests/protocol/protocol_openai_chat.rs | 71 + .../protocol/protocol_openai_responses.rs | 371 + .../protocol/protocol_usage_rate_limits.rs | 372 + crates/platform/src/storage/paths.rs | 64 - crates/platform/tests/config.rs | 61 +- crates/runtime/tests/tasks.rs | 322 +- crates/runtime/tests/tasks/cleanup.rs | 119 + crates/runtime/tests/tasks/coordinator.rs | 32 + crates/runtime/tests/tasks/fingerprint.rs | 143 + crates/runtime/tests/tasks/model_refresh.rs | 25 + crates/runtime/tests/token_refresh.rs | 836 +- .../token_refresh/token_refresh_failures.rs | 337 + .../token_refresh/token_refresh_scheduling.rs | 351 + .../token_refresh/token_refresh_success.rs | 146 + crates/server/src/openai_api/sse.rs | 14 - crates/server/tests/admin_accounts_routes.rs | 3274 +------- .../admin_accounts_import_export.rs | 759 ++ .../admin_accounts_lifecycle.rs | 1148 +++ .../admin_accounts_list.rs | 48 + .../admin_accounts_oauth.rs | 535 ++ .../admin_accounts_quota.rs | 780 ++ .../server/tests/admin_client_keys_routes.rs | 533 +- .../admin_client_keys_routes/authorization.rs | 234 + .../admin_client_keys_routes/import_export.rs | 159 + .../admin_client_keys_routes/lifecycle.rs | 138 + crates/server/tests/fixtures/chat/success.sse | 5 + .../golden/reasoning_replay_request.json | 0 .../fixtures/responses/http_sse/after_401.sse | 5 + .../fixtures/responses/http_sse/after_402.sse | 5 + .../fixtures/responses/http_sse/after_403.sse | 5 + .../responses/http_sse/after_5xx_retry.sse | 5 + .../responses/http_sse/after_cloudflare.sse | 5 + .../http_sse/after_model_unsupported.sse | 5 + .../http_sse/completed_image_usage.sse | 5 + .../responses/http_sse/completed_usage.sse | 5 + .../http_sse/done_item_completed.sse | 5 + .../responses/http_sse/empty_completed.sse | 2 +- .../responses/http_sse/failed_auth.sse | 2 + .../http_sse/failed_model_unsupported.sse | 2 + .../responses/http_sse/failed_quota.sse | 0 .../responses/http_sse/stream_after_401.sse | 5 + .../responses/http_sse/stream_after_402.sse | 5 + .../responses/http_sse/stream_after_403.sse | 5 + .../responses/http_sse/stream_after_429.sse | 5 +- .../http_sse/stream_after_5xx_retry.sse | 4 +- .../http_sse/stream_after_cloudflare.sse | 5 + .../stream_after_model_unsupported.sse | 5 + .../responses/http_sse/stream_usage.sse | 5 + .../fixtures/responses/http_sse/success.sse | 5 + .../http_sse/text_deltas_completed.sse | 7 +- .../responses/http_sse/tuple_object.sse | 0 .../completed_with_reasoning_replay.json | 0 .../websocket/first_account_limited.json | 0 .../websocket/history_rate_limited.json | 0 .../websocket/invalid_encrypted_content.json | 0 .../previous_response_not_found.json | 0 .../responses/websocket/rate_limited.json | 0 .../websocket/second_account_limited.json | 0 .../responses/websocket/token_revoked.json | 0 .../websocket/unanswered_function_call.json | 0 crates/server/tests/openai_chat_upstream.rs | 7289 +---------------- .../openai_chat_routes.rs | 763 ++ .../openai_compact_routes.rs | 243 + .../openai_responses_http.rs | 1097 +++ .../openai_responses_recovery.rs | 2232 +++++ .../openai_responses_websocket.rs | 1960 +++++ .../openai_usage_logging.rs | 676 ++ crates/xtask/src/check_architecture.rs | 9 - crates/xtask/src/main.rs | 4 +- docs/ADMIN_INITIALIZATION_FIX.md | 360 - docs/FINGERPRINT_AUTO_UPDATE.md | 445 - docs/FINGERPRINT_FIX.md | 340 - docs/FINGERPRINT_PROPAGATION_FIX.md | 412 - docs/FINGERPRINT_UPDATE_TEST_REPORT.md | 367 - docs/IMPLEMENTATION_COMPARISON.md | 328 - docs/IMPLEMENTATION_COMPLETE.md | 239 - docs/OPENAI_CHAIN_PARITY_REAUDIT.md | 619 -- docs/OPENAI_CODEX_PARITY_AUDIT.md | 119 - docs/OPENAI_REQUEST_ALIGNMENT.md | 254 - docs/RELIABILITY_ENHANCEMENTS.md | 258 - docs/RESPONSE_CHAIN_ALIGNMENT.md | 238 - docs/api.md | 717 -- docs/architecture-audit.md | 6796 --------------- docs/architecture-capability-plan.md | 245 - docs/architecture-reorganization.md | 1119 --- docs/architecture.md | 891 -- docs/backend-naming-audit.md | 466 -- docs/code-quality-audit.md | 403 - .../codex-proxy-rs-overview-final.pen | 0 docs/database-storage-audit.md | 87 - docs/dependency-policy.md | 16 - docs/design-language.md | 758 -- docs/implementation-status.md | 30 - docs/optimization-roadmap.md | 175 - docs/rust-community-review.md | 446 - docs/scheduler-completion-report.md | 130 - docs/scheduler-implementation.md | 57 - docs/scheduler-usage.md | 204 - .../user-collaboration-standards/SKILL.md | 166 - docs/status-codes.md | 55 - .../plans/2026-06-11-codex-proxy-rs.md | 3091 ------- .../2026-06-12-openai-gpt-codex-parity.md | 222 - .../2026-06-13-utils-module-migration.md | 141 - .../2026-06-14-backend-naming-architecture.md | 214 - .../plans/2026-06-14-logging-refactor.md | 68 - .../2026-06-17-codex-desktop-ws-parity.md | 1172 --- ...26-06-12-openai-gpt-codex-parity-design.md | 48 - ...2026-06-13-architecture-refactor-design.md | 567 -- ...6-14-backend-naming-architecture-design.md | 97 - .../2026-06-14-logging-refactor-design.md | 51 - ...26-06-16-codex-desktop-ws-parity-design.md | 3088 ------- tests/architecture.rs | 14 - tests/architecture/accounts_boundary.rs | 15 - tests/architecture/admin_boundary.rs | 12 - tests/architecture/codex_activity_boundary.rs | 13 - tests/architecture/dependency_direction.rs | 93 - tests/architecture/directory_shape.rs | 368 - tests/architecture/forbidden_imports.rs | 123 - tests/architecture/forbidden_legacy_paths.rs | 54 - tests/architecture/gateway_boundary.rs | 37 - .../placeholder_implementations.rs | 128 - tests/architecture/platform_boundary.rs | 19 - tests/architecture/runtime_tasks_boundary.rs | 10 - tests/architecture/server_startup.rs | 64 - tests/architecture/test_migration.rs | 449 - tests/architecture/workspace_scaffold.rs | 198 - .../chat/golden/no_available_accounts.json | 8 - .../quota_exhausted_fallback_exhausted.json | 8 - .../golden/rate_limit_fallback_exhausted.json | 8 - tests/fixtures/chat/non_stream_text.sse | 6 - .../fixtures/chat/parallel_tools_success.sse | 6 - tests/fixtures/chat/retry_after_success.sse | 6 - tests/fixtures/chat/stream_text.sse | 9 - .../fixtures/chat/tool_reasoning_complete.sse | 18 - tests/fixtures/chat/tool_stream.sse | 12 - tests/fixtures/chat/tuple_object.sse | 5 - .../cloudflare_challenge_no_fallback.json | 8 - .../golden/compact_no_available_accounts.json | 8 - ...compact_rate_limit_fallback_exhausted.json | 8 - .../responses/golden/non_sse_body_event.json | 8 - .../golden/response_failed_auth_recovery.json | 8 - .../golden/sse_pretty_json_events.json | 8 - .../responses/golden/sse_standard_events.json | 8 - .../golden/stream_premature_close.sse | 5 - .../golden/token_invalid_no_fallback.json | 8 - .../golden/tuple_reconvert_response.json | 22 - .../golden/tuple_reconvert_stream.sse | 5 - .../responses/golden/websocket_completed.sse | 2 - .../responses/http_sse/after_401_fallback.sse | 2 - .../fixtures/responses/http_sse/after_402.sse | 3 - .../fixtures/responses/http_sse/after_403.sse | 3 - .../fixtures/responses/http_sse/after_429.sse | 3 - .../responses/http_sse/after_5xx_retry.sse | 2 - .../http_sse/after_cloudflare_403.sse | 3 - .../after_model_unsupported_retry.sse | 2 - .../responses/http_sse/completed_fields.sse | 3 - .../http_sse/completed_image_usage.sse | 3 - .../http_sse/completed_reasoning_include.sse | 3 - .../responses/http_sse/completed_usage.sse | 3 - .../responses/http_sse/default_stream.sse | 6 - .../http_sse/done_item_completed.sse | 6 - .../responses/http_sse/error_event.sse | 3 - .../responses/http_sse/failed_event.sse | 3 - .../http_sse/previous_response_not_found.sse | 3 - .../response_failed_model_unsupported.sse | 2 - .../response_failed_token_invalid.sse | 2 - .../http_sse/stream_after_401_fallback.sse | 5 - .../responses/http_sse/stream_error_event.sse | 3 - .../http_sse/stream_failed_event.sse | 3 - .../http_sse/stream_premature_close.sse | 2 - .../responses/http_sse/stream_usage.sse | 6 - .../http_sse/unanswered_function_call.sse | 3 - .../responses/websocket/completed.json | 12 - .../responses/websocket/partial_delta.json | 1 - tests/fixtures/test-ca.pem | 21 - 197 files changed, 17993 insertions(+), 45589 deletions(-) create mode 100644 crates/adapters/tests/codex/codex_fingerprint.rs create mode 100644 crates/adapters/tests/codex/codex_headers.rs create mode 100644 crates/adapters/tests/codex/codex_http_client.rs create mode 100644 crates/adapters/tests/codex/codex_websocket.rs create mode 100644 crates/adapters/tests/codex/codex_websocket_pool.rs create mode 100644 crates/core/tests/account_pool/quota.rs create mode 100644 crates/core/tests/account_pool/selection.rs create mode 100644 crates/core/tests/account_pool/usage_window.rs create mode 100644 crates/core/tests/protocol/protocol_codex_websocket.rs create mode 100644 crates/core/tests/protocol/protocol_openai_chat.rs create mode 100644 crates/core/tests/protocol/protocol_openai_responses.rs create mode 100644 crates/core/tests/protocol/protocol_usage_rate_limits.rs create mode 100644 crates/runtime/tests/tasks/cleanup.rs create mode 100644 crates/runtime/tests/tasks/coordinator.rs create mode 100644 crates/runtime/tests/tasks/fingerprint.rs create mode 100644 crates/runtime/tests/tasks/model_refresh.rs create mode 100644 crates/runtime/tests/token_refresh/token_refresh_failures.rs create mode 100644 crates/runtime/tests/token_refresh/token_refresh_scheduling.rs create mode 100644 crates/runtime/tests/token_refresh/token_refresh_success.rs create mode 100644 crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs create mode 100644 crates/server/tests/admin_accounts_routes/admin_accounts_lifecycle.rs create mode 100644 crates/server/tests/admin_accounts_routes/admin_accounts_list.rs create mode 100644 crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs create mode 100644 crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs create mode 100644 crates/server/tests/admin_client_keys_routes/authorization.rs create mode 100644 crates/server/tests/admin_client_keys_routes/import_export.rs create mode 100644 crates/server/tests/admin_client_keys_routes/lifecycle.rs create mode 100644 crates/server/tests/fixtures/chat/success.sse rename {tests => crates/server/tests}/fixtures/responses/golden/reasoning_replay_request.json (100%) create mode 100644 crates/server/tests/fixtures/responses/http_sse/after_401.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/after_402.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/after_403.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/after_5xx_retry.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/after_cloudflare.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/after_model_unsupported.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/completed_image_usage.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/completed_usage.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/done_item_completed.sse rename {tests => crates/server/tests}/fixtures/responses/http_sse/empty_completed.sse (55%) create mode 100644 crates/server/tests/fixtures/responses/http_sse/failed_auth.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/failed_model_unsupported.sse rename tests/fixtures/responses/http_sse/response_failed_quota.sse => crates/server/tests/fixtures/responses/http_sse/failed_quota.sse (100%) create mode 100644 crates/server/tests/fixtures/responses/http_sse/stream_after_401.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/stream_after_402.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/stream_after_403.sse rename {tests => crates/server/tests}/fixtures/responses/http_sse/stream_after_429.sse (53%) rename {tests => crates/server/tests}/fixtures/responses/http_sse/stream_after_5xx_retry.sse (52%) create mode 100644 crates/server/tests/fixtures/responses/http_sse/stream_after_cloudflare.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/stream_after_model_unsupported.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/stream_usage.sse create mode 100644 crates/server/tests/fixtures/responses/http_sse/success.sse rename {tests => crates/server/tests}/fixtures/responses/http_sse/text_deltas_completed.sse (52%) rename tests/fixtures/responses/http_sse/completed_tuple_object.sse => crates/server/tests/fixtures/responses/http_sse/tuple_object.sse (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/completed_with_reasoning_replay.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/first_account_limited.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/history_rate_limited.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/invalid_encrypted_content.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/previous_response_not_found.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/rate_limited.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/second_account_limited.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/token_revoked.json (100%) rename {tests => crates/server/tests}/fixtures/responses/websocket/unanswered_function_call.json (100%) create mode 100644 crates/server/tests/openai_chat_upstream/openai_chat_routes.rs create mode 100644 crates/server/tests/openai_chat_upstream/openai_compact_routes.rs create mode 100644 crates/server/tests/openai_chat_upstream/openai_responses_http.rs create mode 100644 crates/server/tests/openai_chat_upstream/openai_responses_recovery.rs create mode 100644 crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs create mode 100644 crates/server/tests/openai_chat_upstream/openai_usage_logging.rs delete mode 100644 crates/xtask/src/check_architecture.rs delete mode 100644 docs/ADMIN_INITIALIZATION_FIX.md delete mode 100644 docs/FINGERPRINT_AUTO_UPDATE.md delete mode 100644 docs/FINGERPRINT_FIX.md delete mode 100644 docs/FINGERPRINT_PROPAGATION_FIX.md delete mode 100644 docs/FINGERPRINT_UPDATE_TEST_REPORT.md delete mode 100644 docs/IMPLEMENTATION_COMPARISON.md delete mode 100644 docs/IMPLEMENTATION_COMPLETE.md delete mode 100644 docs/OPENAI_CHAIN_PARITY_REAUDIT.md delete mode 100644 docs/OPENAI_CODEX_PARITY_AUDIT.md delete mode 100644 docs/OPENAI_REQUEST_ALIGNMENT.md delete mode 100644 docs/RELIABILITY_ENHANCEMENTS.md delete mode 100644 docs/RESPONSE_CHAIN_ALIGNMENT.md delete mode 100644 docs/api.md delete mode 100644 docs/architecture-audit.md delete mode 100644 docs/architecture-capability-plan.md delete mode 100644 docs/architecture-reorganization.md delete mode 100644 docs/architecture.md delete mode 100644 docs/backend-naming-audit.md delete mode 100644 docs/code-quality-audit.md rename {designs => docs}/codex-proxy-rs-overview-final.pen (100%) delete mode 100644 docs/database-storage-audit.md delete mode 100644 docs/dependency-policy.md delete mode 100644 docs/design-language.md delete mode 100644 docs/implementation-status.md delete mode 100644 docs/optimization-roadmap.md delete mode 100644 docs/rust-community-review.md delete mode 100644 docs/scheduler-completion-report.md delete mode 100644 docs/scheduler-implementation.md delete mode 100644 docs/scheduler-usage.md delete mode 100644 docs/skills/user-collaboration-standards/SKILL.md delete mode 100644 docs/status-codes.md delete mode 100644 docs/superpowers/plans/2026-06-11-codex-proxy-rs.md delete mode 100644 docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md delete mode 100644 docs/superpowers/plans/2026-06-13-utils-module-migration.md delete mode 100644 docs/superpowers/plans/2026-06-14-backend-naming-architecture.md delete mode 100644 docs/superpowers/plans/2026-06-14-logging-refactor.md delete mode 100644 docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md delete mode 100644 docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md delete mode 100644 docs/superpowers/specs/2026-06-13-architecture-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-backend-naming-architecture-design.md delete mode 100644 docs/superpowers/specs/2026-06-14-logging-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md delete mode 100644 tests/architecture.rs delete mode 100644 tests/architecture/accounts_boundary.rs delete mode 100644 tests/architecture/admin_boundary.rs delete mode 100644 tests/architecture/codex_activity_boundary.rs delete mode 100644 tests/architecture/dependency_direction.rs delete mode 100644 tests/architecture/directory_shape.rs delete mode 100644 tests/architecture/forbidden_imports.rs delete mode 100644 tests/architecture/forbidden_legacy_paths.rs delete mode 100644 tests/architecture/gateway_boundary.rs delete mode 100644 tests/architecture/placeholder_implementations.rs delete mode 100644 tests/architecture/platform_boundary.rs delete mode 100644 tests/architecture/runtime_tasks_boundary.rs delete mode 100644 tests/architecture/server_startup.rs delete mode 100644 tests/architecture/test_migration.rs delete mode 100644 tests/architecture/workspace_scaffold.rs delete mode 100644 tests/fixtures/chat/golden/no_available_accounts.json delete mode 100644 tests/fixtures/chat/golden/quota_exhausted_fallback_exhausted.json delete mode 100644 tests/fixtures/chat/golden/rate_limit_fallback_exhausted.json delete mode 100644 tests/fixtures/chat/non_stream_text.sse delete mode 100644 tests/fixtures/chat/parallel_tools_success.sse delete mode 100644 tests/fixtures/chat/retry_after_success.sse delete mode 100644 tests/fixtures/chat/stream_text.sse delete mode 100644 tests/fixtures/chat/tool_reasoning_complete.sse delete mode 100644 tests/fixtures/chat/tool_stream.sse delete mode 100644 tests/fixtures/chat/tuple_object.sse delete mode 100644 tests/fixtures/responses/golden/cloudflare_challenge_no_fallback.json delete mode 100644 tests/fixtures/responses/golden/compact_no_available_accounts.json delete mode 100644 tests/fixtures/responses/golden/compact_rate_limit_fallback_exhausted.json delete mode 100644 tests/fixtures/responses/golden/non_sse_body_event.json delete mode 100644 tests/fixtures/responses/golden/response_failed_auth_recovery.json delete mode 100644 tests/fixtures/responses/golden/sse_pretty_json_events.json delete mode 100644 tests/fixtures/responses/golden/sse_standard_events.json delete mode 100644 tests/fixtures/responses/golden/stream_premature_close.sse delete mode 100644 tests/fixtures/responses/golden/token_invalid_no_fallback.json delete mode 100644 tests/fixtures/responses/golden/tuple_reconvert_response.json delete mode 100644 tests/fixtures/responses/golden/tuple_reconvert_stream.sse delete mode 100644 tests/fixtures/responses/golden/websocket_completed.sse delete mode 100644 tests/fixtures/responses/http_sse/after_401_fallback.sse delete mode 100644 tests/fixtures/responses/http_sse/after_402.sse delete mode 100644 tests/fixtures/responses/http_sse/after_403.sse delete mode 100644 tests/fixtures/responses/http_sse/after_429.sse delete mode 100644 tests/fixtures/responses/http_sse/after_5xx_retry.sse delete mode 100644 tests/fixtures/responses/http_sse/after_cloudflare_403.sse delete mode 100644 tests/fixtures/responses/http_sse/after_model_unsupported_retry.sse delete mode 100644 tests/fixtures/responses/http_sse/completed_fields.sse delete mode 100644 tests/fixtures/responses/http_sse/completed_image_usage.sse delete mode 100644 tests/fixtures/responses/http_sse/completed_reasoning_include.sse delete mode 100644 tests/fixtures/responses/http_sse/completed_usage.sse delete mode 100644 tests/fixtures/responses/http_sse/default_stream.sse delete mode 100644 tests/fixtures/responses/http_sse/done_item_completed.sse delete mode 100644 tests/fixtures/responses/http_sse/error_event.sse delete mode 100644 tests/fixtures/responses/http_sse/failed_event.sse delete mode 100644 tests/fixtures/responses/http_sse/previous_response_not_found.sse delete mode 100644 tests/fixtures/responses/http_sse/response_failed_model_unsupported.sse delete mode 100644 tests/fixtures/responses/http_sse/response_failed_token_invalid.sse delete mode 100644 tests/fixtures/responses/http_sse/stream_after_401_fallback.sse delete mode 100644 tests/fixtures/responses/http_sse/stream_error_event.sse delete mode 100644 tests/fixtures/responses/http_sse/stream_failed_event.sse delete mode 100644 tests/fixtures/responses/http_sse/stream_premature_close.sse delete mode 100644 tests/fixtures/responses/http_sse/stream_usage.sse delete mode 100644 tests/fixtures/responses/http_sse/unanswered_function_call.sse delete mode 100644 tests/fixtures/responses/websocket/completed.json delete mode 100644 tests/fixtures/responses/websocket/partial_delta.json delete mode 100644 tests/fixtures/test-ca.pem diff --git a/Cargo.lock b/Cargo.lock index ffe1fa8f1..1be7d5083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,15 +456,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "codex-proxy-rs" -version = "0.1.0" -dependencies = [ - "codex-proxy-core", - "codex-proxy-runtime", - "codex-proxy-server", -] - [[package]] name = "codex-proxy-runtime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0c96fd4f4..83ef82f9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,3 @@ -[package] -name = "codex-proxy-rs" -version.workspace = true -edition.workspace = true -license.workspace = true -publish.workspace = true -rust-version.workspace = true -description = "A lean Rust Codex proxy backed only by ChatGPT/Codex accounts." -repository = "https://example.invalid/codex-proxy-rs" -autobins = false - -[dev-dependencies] -codex-proxy-core = { path = "crates/core" } -codex-proxy-runtime = { path = "crates/runtime" } -codex-proxy-server = { path = "crates/server" } - -[lints] -workspace = true - [workspace] members = ["crates/*"] resolver = "2" diff --git a/README.md b/README.md index e09c10c87..690424e6c 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,58 @@ # codex-proxy-rs -Rust rewrite of Codex Proxy focused only on ChatGPT/Codex accounts and the Codex backend. +面向 ChatGPT/Codex 账号池的 Rust 代理服务,提供 OpenAI 兼容接口,并尽量贴近 Codex Desktop 的上游请求行为。 -## Included +## 特性 -- `/v1/chat/completions` -- `/v1/responses` -- `/v1/models` -- ChatGPT/Codex OAuth and refresh-token based account pool -- Codex Desktop-style TLS, headers, Cookies, and fingerprint updates -- SQLite storage with sqlx-managed schema -- Structured logs with pagination and file rotation +- OpenAI 兼容的 `/v1/chat/completions`、`/v1/responses` 和 `/v1/models` +- ChatGPT/Codex OAuth、device code、refresh token 账号接入 +- 账号池轮转、限流恢复、会话亲和性和 WebSocket 上游 +- Codex Desktop 风格的 headers、TLS、Cookie、fingerprint 和 reasoning replay +- SQLite 存储、密钥加密、结构化日志和管理端 API -## Excluded +## 快速开始 -- OpenAI official API Key upstream -- Anthropic, Gemini, custom providers, OpenRouter-style routing -- Ollama bridge -- Per-account proxy assignment -- Electron app -- Legacy compatibility layers and old-project data migration scripts +```bash +cargo run -p codex-proxy-server +``` -## Auth Model +默认读取根目录 `config.yaml`,并按需合并本地覆盖文件: -- Admin login uses admin password and HttpOnly session Cookie. -- Client API keys call `/v1/*` and start with `cpr_`. -- Codex account tokens are internal upstream credentials. +```text +local.yaml +local.yml +``` -## Development +运行时数据默认写入 `.runtime`: -Runtime config is loaded from `config.yaml` in the project root, then optional -`local.yaml` / `local.yml`. +```text +.runtime/data/ +.runtime/logs/ +``` + +默认监听 `0.0.0.0:8080`。首次启动会初始化管理员账号,默认值来自 `config.yaml`,长期使用请在 `local.yaml` 中覆盖。 + +## 开发 ```bash cargo fmt --check -cargo clippy --all-targets --all-features --locked -- -D warnings -cargo test -cargo run +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo test --workspace --all-targets ``` -Health check: +## 架构 -```bash -curl -s http://127.0.0.1:8080/health +```mermaid +flowchart LR + client[Client] --> server[server
HTTP API] + server --> runtime[runtime
服务编排] + runtime --> core[core
领域模型和协议] + runtime --> adapters[adapters
上游客户端和仓储] + adapters --> platform[platform
配置、存储、加密、日志] + runtime --> platform + server --> assets[assets
静态资源 fallback] ``` + +## License + +MIT diff --git a/crates/adapters/tests/codex.rs b/crates/adapters/tests/codex.rs index bc83ad53e..130fe9755 100644 --- a/crates/adapters/tests/codex.rs +++ b/crates/adapters/tests/codex.rs @@ -1,5 +1,6 @@ use std::{ io::Write, + process::Command, sync::{ atomic::{AtomicUsize, Ordering}, Arc, Mutex, @@ -30,318 +31,16 @@ use tokio_tungstenite::{ }, }; -#[test] -fn endpoints_should_join_backend_paths() { - assert_eq!( - client::endpoint_url("https://api.example.com/", "/codex/responses"), - "https://api.example.com/codex/responses" - ); - assert_eq!( - client::endpoint_request_path("https://api.example.com/backend-api", "/codex/usage"), - "/backend-api/codex/usage" - ); -} - -#[test] -fn ordered_codex_headers_should_preserve_fingerprint_priority_and_request_fields() { - let fingerprint = codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(); - - let headers = client::build_ordered_codex_headers( - &fingerprint, - "access-token", - Some("acct-1"), - Some("turn-1"), - "req-1", - ); - let keys = headers.keys().cloned().collect::>(); - - assert_eq!(headers["authorization"], "Bearer access-token"); - assert_eq!(headers["chatgpt-account-id"], "acct-1"); - assert_eq!(headers["x-client-request-id"], "req-1"); - assert_eq!(headers["x-codex-turn-state"], "turn-1"); - assert_eq!(headers["accept"], "text/event-stream"); - assert_eq!(keys.first().map(String::as_str), Some("authorization")); -} - -#[test] -fn custom_ca_should_report_environment_cache_key_consistently() { - let cache_key = client::custom_ca_env_cache_key(); - - assert_eq!( - cache_key.is_some(), - std::env::var("CODEX_CA_CERTIFICATE").is_ok() || std::env::var("SSL_CERT_FILE").is_ok() - ); -} - -#[test] -fn reqwest_http2_feature_should_be_enabled_for_fallback_parity() { - let builder = reqwest::Client::builder().http2_prior_knowledge(); - - drop(builder); -} - -#[test] -fn websocket_connection_should_preserve_endpoint_and_header_order() { - let connection = codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::new( - "wss://chatgpt.com/backend-api/codex", - vec![ - ("authorization".to_string(), "Bearer token".to_string()), - ("user-agent".to_string(), "Codex Desktop/test".to_string()), - ], - ); - - assert_eq!( - ( - connection.endpoint(), - connection.opening_audit_snapshot().header_order, - ), - ( - "wss://chatgpt.com/backend-api/codex", - vec!["authorization".to_string(), "user-agent".to_string()], - ) - ); -} - -#[test] -fn websocket_connection_opening_audit_should_redact_sensitive_headers() { - let connection = codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::new( - "wss://chatgpt.com/backend-api/codex/responses?source=audit", - vec![ - ( - "authorization".to_string(), - "Bearer access-secret".to_string(), - ), - ("chatgpt-account-id".to_string(), "acct-secret".to_string()), - ("user-agent".to_string(), "Codex Desktop/test".to_string()), - ("x-client-request-id".to_string(), "req-secret".to_string()), - ( - "x-codex-turn-metadata".to_string(), - "{\"secret\":true}".to_string(), - ), - ], - ); - - let snapshot = connection.opening_audit_snapshot(); - - assert_eq!( - snapshot.request_line, - "GET /backend-api/codex/responses?source=audit HTTP/1.1" - ); - assert_eq!( - snapshot - .headers - .iter() - .map(|header| (header.name.as_str(), header.value.as_str())) - .collect::>(), - vec![ - ("authorization", ""), - ("chatgpt-account-id", ""), - ("user-agent", "Codex Desktop/test"), - ("x-client-request-id", ""), - ("x-codex-turn-metadata", ""), - ] - ); -} - -#[tokio::test] -async fn websocket_audit_artifact_should_require_explicit_directory() { - let connection = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses( - "https://chatgpt.com", - "test-websocket-key", - vec![ - ( - "authorization".to_string(), - "Bearer access-token".to_string(), - ), - ("chatgpt-account-id".to_string(), "acct-secret".to_string()), - ("cookie".to_string(), "session=secret".to_string()), - ], - ); - let mut payload_request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "private instructions", - vec![json!({"role": "user", "content": "private prompt"})], - ); - payload_request.prompt_cache_key = Some("cache-secret".to_string()); - payload_request.client_metadata = Some(json!({"thread_id": "thread-secret"})); - let mut artifact = - codex_proxy_core::protocol::codex::websocket::websocket_audit_artifact_from_attempt( - &payload_request, - connection.opening_audit_snapshot(), - codex_proxy_core::protocol::codex::websocket::websocket_payload_audit_snapshot( - &payload_request, - ), - ); - artifact.error = Some( - codex_proxy_core::protocol::codex::websocket::WebSocketAuditErrorSnapshot { - classification: "opening_failed".to_string(), - message: "connection refused".to_string(), - }, - ); - let dir = tempfile::tempdir().expect("temp dir"); - - let disabled = - codex_proxy_adapters::codex::websocket::opening::write_websocket_audit_artifact_for_dir( - None, &artifact, - ) - .await - .expect("disabled audit should be ok"); - - assert!(disabled.is_none()); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); - - let written = - codex_proxy_adapters::codex::websocket::opening::write_websocket_audit_artifact_for_dir( - Some(dir.path()), - &artifact, - ) - .await - .expect("enabled audit should write") - .expect("enabled audit path"); - let body = std::fs::read_to_string(&written).expect("audit file"); - let json = serde_json::from_str::(&body).expect("audit json"); - - assert_eq!(json["transport_mode"], "websocket_preferred"); - assert_eq!( - json["opening"]["request_line"], - "GET /codex/responses HTTP/1.1" - ); - assert_eq!(json["payload"]["top_level_keys"][1], "model"); - assert_eq!(json["error"]["classification"], "opening_failed"); - assert!(!body.contains("access-token")); - assert!(!body.contains("acct-secret")); - assert!(!body.contains("session=secret")); - assert!(!body.contains("private prompt")); - assert!(!body.contains("private instructions")); - assert!(!body.contains("cache-secret")); - assert!(!body.contains("thread-secret")); -} - -#[test] -fn websocket_responses_endpoint_should_convert_http_base_url_to_ws_endpoint() { - assert_eq!( - codex_proxy_adapters::codex::websocket::connect::responses_websocket_endpoint( - "https://chatgpt.com/backend-api" - ), - "wss://chatgpt.com/backend-api/codex/responses" - ); - assert_eq!( - codex_proxy_adapters::codex::websocket::connect::responses_websocket_endpoint( - "http://127.0.0.1:8080" - ), - "ws://127.0.0.1:8080/codex/responses" - ); -} - -#[test] -fn websocket_deflate_should_rewrite_compressed_server_text_frame() { - let payload = json!({ - "type": "response.completed", - "response": { - "id": "resp_deflate", - "object": "response" - } - }) - .to_string(); - let compressed = compressed_permessage_deflate_payload(payload.as_bytes()); - let frame = server_websocket_frame(0x1, true, &compressed); - - let rewritten = - codex_proxy_adapters::codex::websocket::deflate::rewrite_permessage_deflate_server_frame( - &frame, - ) - .expect("compressed frame should rewrite") - .expect("compressed frame should produce rewritten frame"); - - assert_eq!(rewritten[0] & 0x40, 0); - assert_eq!(rewritten[0] & 0x0f, 0x1); - assert_eq!(server_websocket_payload(&rewritten), payload.as_bytes()); -} - -#[test] -fn websocket_deflate_should_leave_uncompressed_server_frame_unchanged() { - let frame = server_websocket_frame(0x1, false, b"plain"); - - let rewritten = - codex_proxy_adapters::codex::websocket::deflate::rewrite_permessage_deflate_server_frame( - &frame, - ) - .expect("plain frame should parse"); - - assert_eq!(rewritten, None); -} - -#[tokio::test] -async fn codex_backend_client_should_decode_live_permessage_deflate_websocket_frame() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - let websocket_key = websocket_opening_header(&request, "sec-websocket-key") - .expect("opening request should include sec-websocket-key"); - let accept_key = derive_accept_key(websocket_key.as_bytes()); - stream - .write_all( - format!( - "HTTP/1.1 101 Switching Protocols\r\n\ - Upgrade: websocket\r\n\ - Connection: Upgrade\r\n\ - Sec-WebSocket-Accept: {accept_key}\r\n\ - Sec-WebSocket-Extensions: permessage-deflate\r\n\ - \r\n" - ) - .as_bytes(), - ) - .await - .unwrap(); - - let completed = json!({ - "type": "response.completed", - "response": { - "id": "resp_live_deflate", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string(); - let compressed = compressed_permessage_deflate_payload(completed.as_bytes()); - stream - .write_all(&server_websocket_frame(0x1, true, &compressed)) - .await - .unwrap(); - stream.flush().await.unwrap(); - }); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - - let response = backend - .create_response( - &request, - request_context("req_live_deflate", Some("chatgpt-account")), - ) - .await - .expect("deflated websocket response should decode"); - server.await.unwrap(); - - assert!(response.body.contains("resp_live_deflate")); -} +#[path = "codex/codex_fingerprint.rs"] +mod codex_fingerprint; +#[path = "codex/codex_headers.rs"] +mod codex_headers; +#[path = "codex/codex_http_client.rs"] +mod codex_http_client; +#[path = "codex/codex_websocket.rs"] +mod codex_websocket; +#[path = "codex/codex_websocket_pool.rs"] +mod codex_websocket_pool; fn compressed_permessage_deflate_payload(payload: &[u8]) -> Vec { let mut encoder = DeflateEncoder::new(Vec::new(), Compression::fast()); @@ -383,3081 +82,6 @@ fn server_websocket_payload(frame: &[u8]) -> &[u8] { &frame[offset..offset + len] } -#[test] -fn websocket_connection_should_build_standard_opening_headers_around_business_headers() { - let connection = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses( - "https://chatgpt.com/backend-api", - "test-websocket-key", - vec![ - ( - "authorization".to_string(), - "Bearer access-token".to_string(), - ), - ("user-agent".to_string(), "Codex Desktop/test".to_string()), - ( - "openai-beta".to_string(), - "responses_websockets=2026-02-06".to_string(), - ), - ], - ); - - let snapshot = connection.opening_audit_snapshot(); - - assert_eq!( - connection.endpoint(), - "wss://chatgpt.com/backend-api/codex/responses" - ); - assert_eq!( - snapshot.header_order, - vec![ - "Host", - "Connection", - "Upgrade", - "Sec-WebSocket-Version", - "Sec-WebSocket-Key", - "authorization", - "user-agent", - "openai-beta", - "sec-websocket-extensions", - ] - ); - assert_eq!( - snapshot - .headers - .iter() - .map(|header| (header.name.as_str(), header.value.as_str())) - .collect::>(), - vec![ - ("Host", "chatgpt.com"), - ("Connection", "Upgrade"), - ("Upgrade", "websocket"), - ("Sec-WebSocket-Version", "13"), - ("Sec-WebSocket-Key", "test-websocket-key"), - ("authorization", ""), - ("user-agent", "Codex Desktop/test"), - ("openai-beta", "responses_websockets=2026-02-06"), - ( - "sec-websocket-extensions", - "permessage-deflate; client_max_window_bits" - ), - ] - ); -} - -#[test] -fn websocket_connection_should_render_raw_opening_bytes_for_capture_parity() { - let connection = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses( - "https://chatgpt.com/backend-api", - "test-websocket-key", - vec![ - ( - "chatgpt-account-id".to_string(), - "chatgpt-account".to_string(), - ), - ( - "authorization".to_string(), - "Bearer access-token".to_string(), - ), - ( - "user-agent".to_string(), - "Codex Desktop/26.519.81530 (darwin; arm64)".to_string(), - ), - ("originator".to_string(), "Codex Desktop".to_string()), - ( - "openai-beta".to_string(), - "responses_websockets=2026-02-06".to_string(), - ), - ( - "x-codex-beta-features".to_string(), - "terminal_resize_reflow,memories,network_proxy,prevent_idle_sleep,remote_compaction_v2".to_string(), - ), - ("x-client-request-id".to_string(), "session-1".to_string()), - ("session-id".to_string(), "session-1".to_string()), - ("thread-id".to_string(), "session-1".to_string()), - ("x-codex-window-id".to_string(), "session-1:0".to_string()), - ( - "x-codex-turn-metadata".to_string(), - r#"{"installation_id":"install-1","session_id":"session-1","thread_id":"session-1","turn_id":"","window_id":"session-1:0","request_kind":"prewarm","sandbox":"seccomp"}"#.to_string(), - ), - ], - ); - - let opening = connection.opening_request_text(); - - assert!(opening.starts_with("GET /backend-api/codex/responses HTTP/1.1\r\n")); - assert_substrings_appear_in_order( - &opening, - &[ - "Host: chatgpt.com\r\n", - "Connection: Upgrade\r\n", - "Upgrade: websocket\r\n", - "Sec-WebSocket-Version: 13\r\n", - "Sec-WebSocket-Key: test-websocket-key\r\n", - "chatgpt-account-id: chatgpt-account\r\n", - "authorization: Bearer access-token\r\n", - "user-agent: Codex Desktop/26.519.81530 (darwin; arm64)\r\n", - "originator: Codex Desktop\r\n", - "openai-beta: responses_websockets=2026-02-06\r\n", - "x-codex-beta-features: terminal_resize_reflow,memories,network_proxy,prevent_idle_sleep,remote_compaction_v2\r\n", - "x-client-request-id: session-1\r\n", - "session-id: session-1\r\n", - "thread-id: session-1\r\n", - "x-codex-window-id: session-1:0\r\n", - r#"x-codex-turn-metadata: {"installation_id":"install-1","session_id":"session-1","thread_id":"session-1","turn_id":"","window_id":"session-1:0","request_kind":"prewarm","sandbox":"seccomp"}"#, - "sec-websocket-extensions: permessage-deflate; client_max_window_bits\r\n", - ], - ); - assert!(opening.ends_with("\r\n\r\n")); -} - -#[test] -fn websocket_connection_should_prepare_response_create_payload_text() { - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - vec![json!({ - "role": "user", - "content": "hello", - })], - ); - - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - "https://chatgpt.com/backend-api", - "test-websocket-key", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - let payload: serde_json::Value = - serde_json::from_str(prepared.payload_text()).expect("payload should be json"); - - assert_eq!( - prepared.connection().endpoint(), - "wss://chatgpt.com/backend-api/codex/responses" - ); - assert_eq!(payload["type"], "response.create"); - assert_eq!(payload["model"], "gpt-5.5"); - assert_eq!(payload["instructions"], "be brief"); - assert_eq!(payload["input"][0]["content"], "hello"); - assert_eq!(payload["stream"], true); -} - -#[test] -fn websocket_connection_should_prepare_capture_payload_with_old_field_order() { - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "private capture instructions", - vec![json!({ - "role": "user", - "content": "private capture prompt", - })], - ); - request.prompt_cache_key = Some("session-1".to_string()); - request.client_metadata = Some(json!({ - "thread_id": "capture-thread-secret", - "safe": "capture", - })); - request.generate = Some(false); - - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - "https://chatgpt.com/backend-api", - "test-websocket-key", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - assert_substrings_appear_in_order( - prepared.payload_text(), - &[ - "\"type\":\"response.create\"", - "\"model\":\"gpt-5.5\"", - "\"instructions\":\"private capture instructions\"", - "\"input\":", - "\"tools\":[]", - "\"tool_choice\":\"auto\"", - "\"parallel_tool_calls\":true", - "\"reasoning\":null", - "\"store\":false", - "\"stream\":true", - "\"include\":[]", - "\"prompt_cache_key\":\"session-1\"", - "\"generate\":false", - "\"client_metadata\":", - ], - ); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_collect_completed_sse() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let message = websocket.next().await.unwrap().unwrap(); - let payload = serde_json::from_str::(&message.into_text().unwrap()) - .expect("client payload should be json"); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_ws_live", - "object": "response", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "total_tokens": 7 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - payload - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let response = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect("websocket exchange should succeed"); - let payload = server.await.unwrap(); - - assert_eq!(payload["type"], "response.create"); - assert!(response.body.contains("event: response.completed")); - assert!(response.body.contains("\"id\":\"resp_ws_live\"")); - assert_eq!(response.usage.expect("usage").input_tokens, 5); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_surface_response_failed_as_upstream_error( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.failed", - "response": { - "error": { - "code": "rate_limit_exceeded", - "message": "Rate limit reached. Please try again in 11.054s." - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("response.failed should be surfaced as upstream error"); - server.await.unwrap(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { - status_code, - retry_after_seconds, - body, - } = error - else { - panic!("expected upstream websocket error"); - }; - assert_eq!(status_code, 429); - assert_eq!(retry_after_seconds, Some(12)); - assert!(body.contains("rate_limit_exceeded")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_preserve_opening_error_status_body_and_retry_after( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - assert!(request.starts_with("GET /codex/responses HTTP/1.1")); - assert!(request.contains("authorization: Bearer access-token")); - let body = r#"{"error":{"message":"rate limited"}}"#; - stream - .write_all( - format!( - "HTTP/1.1 429 Too Many Requests\r\nretry-after: 33\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), - ) - .await - .unwrap(); - }); - let prepared = prepared_websocket_request(&format!("http://{addr}")); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("failed opening should surface upstream status"); - server.await.unwrap(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { - status_code, - retry_after_seconds, - body, - } = error - else { - panic!("expected upstream opening error"); - }; - assert_eq!(status_code, 429); - assert_eq!(retry_after_seconds, Some(33)); - assert!(body.contains("rate limited")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_reject_binary_event() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Binary(b"unexpected-binary".to_vec().into())) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let prepared = prepared_websocket_request(&format!("http://{addr}")); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("binary websocket events should be rejected"); - server.await.unwrap(); - - assert!(matches!( - error, - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::UnexpectedBinaryEvent - )); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_surface_wrapped_error_status_and_retry_after( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "error", - "status": 409, - "headers": { - "retry-after": ["17"] - }, - "error": { - "code": "conflict", - "message": "wrapped conflict" - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let prepared = prepared_websocket_request(&format!("http://{addr}")); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("wrapped error should surface as upstream error"); - server.await.unwrap(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { - status_code, - retry_after_seconds, - body, - } = error - else { - panic!("expected wrapped upstream error"); - }; - assert_eq!(status_code, 409); - assert_eq!(retry_after_seconds, Some(17)); - assert!(body.contains("wrapped conflict")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_surface_connection_limit_as_503() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.failed", - "response": { - "id": "resp_connection_limit", - "error": { - "code": "websocket_connection_limit_reached", - "message": "connection limit reached" - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let prepared = prepared_websocket_request(&format!("http://{addr}")); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("connection limit should surface as upstream error"); - server.await.unwrap(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { - status_code, - body, - .. - } = error - else { - panic!("expected connection limit upstream error"); - }; - assert_eq!(status_code, 503); - assert!(body.contains("websocket_connection_limit_reached")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_skip_invalid_stream_events() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.output_text.delta" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_after_invalid", - "object": "response" - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let response = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect("websocket exchange should succeed"); - server.await.unwrap(); - - assert!(!response.body.contains("response.output_text.delta")); - assert!(response.body.contains("event: response.completed")); - assert!(response.body.contains("\"id\":\"resp_after_invalid\"")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_capture_internal_metadata_and_rate_limit_events( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "codex.rate_limits", - "rate_limits": { - "primary": { - "used_percent": 100, - "window_minutes": 5, - "reset_at": 1893456300 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.metadata", - "headers": { - "x-codex-turn-state": ["turn-from-metadata"] - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_internal_events", - "object": "response", - "usage": { - "input_tokens": 1, - "output_tokens": 1, - "total_tokens": 2 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let prepared = prepared_websocket_request(&format!("http://{addr}")); - - let response = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect("internal events should update metadata without forwarding"); - server.await.unwrap(); - - assert_eq!(response.turn_state.as_deref(), Some("turn-from-metadata")); - assert!(!response.body.contains("codex.rate_limits")); - assert!(!response.body.contains("response.metadata")); - assert!(response.body.contains("event: response.completed")); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-codex-primary-used-percent" && value == "100")); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-codex-primary-reset-at" && value == "1893456300")); -} - -#[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] -async fn websocket_execute_response_create_request_should_capture_handshake_headers() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = - accept_hdr_async(stream, |_request: &WsRequest, mut response: WsResponse| { - response - .headers_mut() - .insert("x-codex-turn-state", "turn-from-handshake".parse().unwrap()); - response.headers_mut().insert( - "set-cookie", - "cf_clearance=ws; Domain=.chatgpt.com; Path=/" - .parse() - .unwrap(), - ); - response - .headers_mut() - .insert("x-ratelimit-remaining-requests", "41".parse().unwrap()); - Ok(response) - }) - .await - .unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_ws_headers", - "object": "response", - "usage": { - "input_tokens": 1, - "output_tokens": 1, - "total_tokens": 2 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let response = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect("websocket exchange should succeed"); - server.await.unwrap(); - - assert_eq!(response.turn_state.as_deref(), Some("turn-from-handshake")); - assert_eq!( - response.set_cookie_headers, - vec!["cf_clearance=ws; Domain=.chatgpt.com; Path=/".to_string()] - ); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "41")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_surface_incomplete_response() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.incomplete", - "response": { - "id": "resp_incomplete", - "incomplete_details": { - "reason": "max_output_tokens" - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("response.incomplete should be surfaced as websocket error"); - server.await.unwrap(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::IncompleteResponse { - reason, - } = error - else { - panic!("expected incomplete websocket response"); - }; - assert_eq!(reason, "max_output_tokens"); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_reject_invalid_completed_response() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_invalid_completed", - "object": "response", - "usage": { - "input_tokens": "bad", - "output_tokens": 1, - "total_tokens": 1 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("invalid response.completed should be rejected"); - server.await.unwrap(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::InvalidCompletedResponse { - message, - } = error - else { - panic!("expected invalid completed websocket response"); - }; - assert!(message.contains("failed to parse ResponseCompleted")); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_ignore_completed_without_response_until_close( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("response.completed without response should not finish the stream"); - server.await.unwrap(); - - assert!(matches!( - error, - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal - )); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_ignore_success_status_error_until_close() -{ - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "error", - "status": 200, - "error": { - "message": "non-terminal informational frame" - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let error = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect_err("unclassified success-status error frame should not finish the stream"); - server.await.unwrap(); - - assert!(matches!( - error, - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal - )); -} - -#[tokio::test(start_paused = true)] -async fn websocket_execute_response_create_request_should_timeout_when_upstream_is_silent() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let (request_seen_tx, request_seen_rx) = tokio::sync::oneshot::channel(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - request_seen_tx.send(()).unwrap(); - futures::future::pending::<()>().await; - }); - let prepared = prepared_websocket_request(&format!("http://{addr}")); - - let response_task = tokio::spawn(async move { - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - }); - request_seen_rx.await.unwrap(); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(20)).await; - tokio::task::yield_now().await; - - let error = response_task - .await - .expect("websocket task should finish") - .expect_err("silent upstream should time out"); - server.abort(); - - let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ReceiveIdleTimeout { - timeout, - } = error - else { - panic!("expected receive idle timeout"); - }; - assert_eq!(timeout, Duration::from_secs(20)); -} - -#[tokio::test] -async fn websocket_execute_response_create_request_should_reply_to_server_ping_before_terminal() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Ping("codex-ping".as_bytes().to_vec().into())) - .await - .unwrap(); - let pong = timeout(Duration::from_secs(1), websocket.next()) - .await - .expect("client should reply to server ping before terminal response") - .expect("client should send a websocket frame") - .expect("client frame should be valid"); - assert_eq!(pong, Message::Pong("codex-ping".as_bytes().to_vec().into())); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_after_ping", - "object": "response", - "usage": { - "input_tokens": 2, - "output_tokens": 1, - "total_tokens": 3 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - let prepared = - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( - &format!("http://{addr}"), - "dGhlIHNhbXBsZSBub25jZQ==", - vec![("authorization".to_string(), "Bearer access-token".to_string())], - &request, - ) - .expect("payload should serialize"); - - let response = - codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) - .await - .expect("websocket exchange should succeed after ping/pong"); - server.await.unwrap(); - - assert!(response.body.contains("event: response.completed")); - assert!(response.body.contains("\"id\":\"resp_after_ping\"")); - assert_eq!(response.usage.expect("usage").input_tokens, 2); -} - -#[tokio::test] -async fn codex_backend_client_stream_should_reject_binary_websocket_event() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Binary(b"unexpected-binary".to_vec().into())) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let mut response = backend - .create_response_stream( - &request, - request_context("req_stream_binary", Some("chatgpt-account")), - ) - .await - .expect("websocket stream should open"); - let error = response - .body - .next() - .await - .expect("stream should yield binary error") - .expect_err("binary event should be rejected"); - server.await.unwrap(); - - assert!(matches!( - error, - client::CodexClientError::WebSocket( - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::UnexpectedBinaryEvent - ) - )); -} - -#[tokio::test] -async fn codex_backend_client_stream_should_error_when_websocket_closes_before_terminal() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.output_text.delta", - "delta": "partial" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let mut response = backend - .create_response_stream( - &request, - request_context("req_stream_mid_close", Some("chatgpt-account")), - ) - .await - .expect("websocket stream should open"); - let first = response - .body - .next() - .await - .expect("stream should yield partial frame") - .expect("partial frame should be valid"); - assert!(std::str::from_utf8(&first).unwrap().contains("partial")); - let error = response - .body - .next() - .await - .expect("stream should yield close-before-terminal error") - .expect_err("close before terminal should be an error"); - server.await.unwrap(); - - assert!(matches!( - error, - client::CodexClientError::WebSocket( - codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal - ) - )); -} - -#[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] -async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_present() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = - accept_hdr_async(stream, |_request: &WsRequest, mut response: WsResponse| { - response - .headers_mut() - .insert("x-codex-turn-state", "turn-ws-client".parse().unwrap()); - response.headers_mut().insert( - "set-cookie", - "cf_clearance=client-ws; Domain=.chatgpt.com; Path=/" - .parse() - .unwrap(), - ); - response - .headers_mut() - .insert("x-ratelimit-remaining-requests", "17".parse().unwrap()); - Ok(response) - }) - .await - .unwrap(); - let message = websocket.next().await.unwrap().unwrap(); - let payload = serde_json::from_str::(&message.into_text().unwrap()) - .expect("client payload should be json"); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_ws_client", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - payload - }); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let response = backend - .create_response( - &request, - client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_ws_client", - turn_state: None, - turn_metadata: None, - beta_features: None, - include_timing_metrics: None, - version: None, - codex_window_id: None, - parent_thread_id: None, - cookie_header: None, - installation_id: None, - session_id: None, - }, - ) - .await - .expect("websocket response should succeed"); - let payload = server.await.unwrap(); - - assert_eq!(payload["type"], "response.create"); - assert_eq!(payload["previous_response_id"], "resp_previous"); - assert!(response.body.contains("event: response.completed")); - assert!(response.body.contains("\"id\":\"resp_ws_client\"")); - assert_eq!(response.usage.expect("usage").input_tokens, 3); - assert_eq!(response.turn_state.as_deref(), Some("turn-ws-client")); - assert_eq!( - response.set_cookie_headers, - vec!["cf_clearance=client-ws; Domain=.chatgpt.com; Path=/".to_string()] - ); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "17")); -} - -#[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] -async fn codex_backend_client_websocket_should_forward_security_chain_headers_and_payload_fields() { - let received_headers = Arc::new(Mutex::new(Vec::<(String, String)>::new())); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let headers_for_server = Arc::clone(&received_headers); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = - accept_hdr_async(stream, move |request: &WsRequest, response: WsResponse| { - let headers = request - .headers() - .iter() - .map(|(name, value)| { - ( - name.as_str().to_string(), - value.to_str().unwrap_or_default().to_string(), - ) - }) - .collect::>(); - *headers_for_server.lock().unwrap() = headers; - Ok(response) - }) - .await - .unwrap(); - let message = websocket.next().await.unwrap().unwrap(); - let payload = serde_json::from_str::(&message.into_text().unwrap()) - .expect("client payload should be json"); - websocket - .send(Message::Text( - completed_websocket_response("resp_ws_security", 1, 1).into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - payload - }); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.use_websocket = true; - request.prompt_cache_key = Some("client-thread".to_string()); - request.client_metadata = Some(json!({ - "safe": "yes", - "x-openai-subagent": "review", - "ignored_non_string": 42 - })); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let response = backend - .create_response( - &request, - client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_ws_security", - turn_state: Some("turn-state"), - turn_metadata: Some("{\"thread_source\":\"subagent\"}"), - beta_features: Some("feature-a"), - include_timing_metrics: Some("true"), - version: Some("26.318.11754"), - codex_window_id: Some("cw_derived"), - parent_thread_id: Some("parent-456"), - cookie_header: None, - installation_id: Some("install-123"), - session_id: Some("cp_derived"), - }, - ) - .await - .expect("websocket response should succeed"); - let payload = server.await.unwrap(); - - assert!(response.body.contains("resp_ws_security")); - assert_eq!(payload["prompt_cache_key"], "cp_derived"); - assert_eq!( - payload["client_metadata"], - json!({ - "safe": "yes", - "x-openai-subagent": "review", - "x-codex-installation-id": "install-123", - "x-codex-window-id": "cw_derived", - "x-codex-turn-metadata": "{\"thread_source\":\"subagent\"}", - "x-codex-parent-thread-id": "parent-456" - }) - ); - - let headers = received_headers.lock().unwrap().clone(); - assert!(headers - .iter() - .any(|(name, value)| name == "x-client-request-id" && value == "cp_derived")); - assert!(headers - .iter() - .any(|(name, value)| name == "x-openai-subagent" && value == "review")); - assert!(headers - .iter() - .any(|(name, value)| name == "session-id" && value == "cp_derived")); - assert!(headers - .iter() - .any(|(name, value)| name == "thread-id" && value == "cp_derived")); - assert!(headers.iter().all(|(name, _)| name != "content-type")); - assert!(headers.iter().all(|(name, _)| name != "accept")); - assert!(headers.iter().all(|(name, _)| name != "session_id")); -} - -#[test] -fn websocket_pool_should_apply_capacity_and_age_policy() { - let pool = codex_proxy_adapters::codex::websocket::pool::CodexWebSocketPool::new( - 2, - Duration::from_secs(60), - ); - - assert_eq!( - ( - pool.permits_new_connection(1), - pool.permits_new_connection(2), - pool.should_recycle(Duration::from_secs(59)), - pool.should_recycle(Duration::from_secs(60)), - ), - (true, false, false, true) - ); -} - -#[tokio::test] -async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and_conversation() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut websocket = accept_async(stream).await.unwrap(); - for response_id in ["resp_pool_first", "resp_pool_second"] { - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": response_id, - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - } - websocket.close(None).await.unwrap(); - }); - let pool = Arc::new( - codex_proxy_adapters::codex::websocket::pool::CodexWebSocketPool::new( - 8, - Duration::from_secs(60), - ), - ); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool".to_string()); - - let first = backend - .create_response( - &request, - request_context("req_pool_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket response should succeed"); - let second = backend - .create_response( - &request, - request_context("req_pool_second", Some("chatgpt-account")), - ) - .await - .expect("second pooled websocket response should succeed"); - server.await.unwrap(); - - assert!(first.body.contains("resp_pool_first")); - assert!(second.body.contains("resp_pool_second")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let (release_first_tx, release_first_rx) = tokio::sync::oneshot::channel(); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - json!({ - "type": "response.output_text.delta", - "delta": "first connection is still busy" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - - let (second_stream, _) = listener.accept().await.unwrap(); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - completed_websocket_response("resp_busy_second", 2, 1).into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - - let (third_stream, _) = listener.accept().await.unwrap(); - let mut third_websocket = accept_async(third_stream).await.unwrap(); - let _third_message = third_websocket.next().await.unwrap().unwrap(); - third_websocket - .send(Message::Text( - completed_websocket_response("resp_busy_third", 2, 1).into(), - )) - .await - .unwrap(); - third_websocket.close(None).await.unwrap(); - - release_first_rx.await.unwrap(); - first_websocket - .send(Message::Text( - completed_websocket_response("resp_busy_first", 2, 1).into(), - )) - .await - .unwrap(); - first_websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_default_max_age()); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(pool); - let request = pooled_websocket_request("conversation-busy"); - - let mut first = backend - .create_response_stream( - &request, - request_context("req_busy_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket stream should start") - .body; - let first_chunk = first - .next() - .await - .expect("first stream should yield an initial chunk") - .expect("first stream chunk should be valid"); - let first_chunk = std::str::from_utf8(&first_chunk).unwrap(); - assert!(first_chunk.contains("first connection is still busy")); - - let second = backend - .create_response( - &request, - request_context("req_busy_second", Some("chatgpt-account")), - ) - .await - .expect("busy key should bypass with a one-shot second connection"); - let third = backend - .create_response( - &request, - request_context("req_busy_third", Some("chatgpt-account")), - ) - .await - .expect("busy key should bypass with a one-shot third connection"); - - release_first_tx.send(()).unwrap(); - while first.next().await.transpose().unwrap().is_some() {} - server.await.unwrap(); - - assert!(second.body.contains("resp_busy_second")); - assert!(third.body.contains("resp_busy_third")); -} - -#[tokio::test] -async fn websocket_pool_should_bypass_new_keys_after_account_cap() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - for response_id in ["resp_cap_first", "resp_cap_second", "resp_cap_third"] { - let (stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut websocket = accept_async(stream).await.unwrap(); - let _message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - completed_websocket_response(response_id, 2, 1).into(), - )) - .await - .unwrap(); - if response_id == "resp_cap_third" { - websocket.close(None).await.unwrap(); - } - } - }); - let pool = Arc::new(CodexWebSocketPool::with_limits(Duration::from_secs(60), 1)); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(pool); - let first_request = pooled_websocket_request("conversation-cap-one"); - let second_request = pooled_websocket_request("conversation-cap-two"); - - let first = backend - .create_response( - &first_request, - request_context("req_cap_first", Some("chatgpt-account")), - ) - .await - .expect("first capped websocket response should succeed"); - let second = backend - .create_response( - &second_request, - request_context("req_cap_second", Some("chatgpt-account")), - ) - .await - .expect("new key over account cap should use one-shot connection"); - let third = backend - .create_response( - &second_request, - request_context("req_cap_third", Some("chatgpt-account")), - ) - .await - .expect("capped key should keep bypassing instead of entering the pool"); - server.await.unwrap(); - - assert!(first.body.contains("resp_cap_first")); - assert!(second.body.contains("resp_cap_second")); - assert!(third.body.contains("resp_cap_third")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 3); -} - -#[tokio::test] -async fn codex_backend_client_should_ping_idle_pooled_websocket_during_maintenance() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let ping_count = Arc::new(AtomicUsize::new(0)); - let ping_count_for_server = Arc::clone(&ping_count); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _first_message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_keepalive_first", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - - let ping = timeout(Duration::from_secs(1), websocket.next()) - .await - .expect("pool maintenance should probe the idle websocket") - .expect("pool maintenance should send a websocket frame") - .expect("pool maintenance frame should be valid"); - let Message::Ping(payload) = ping else { - panic!("expected pool maintenance ping frame, got {ping:?}"); - }; - ping_count_for_server.fetch_add(1, Ordering::SeqCst); - websocket.send(Message::Pong(payload)).await.unwrap(); - - let _second_message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_keepalive_second", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_config( - websocket_pool_config_for_tests(None, Some(Duration::from_millis(1)), None), - )); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool-keepalive".to_string()); - - let first = backend - .create_response( - &request, - request_context("req_pool_keepalive_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket response should succeed"); - pool.maintain_idle_connections().await; - let second = backend - .create_response( - &request, - request_context("req_pool_keepalive_second", Some("chatgpt-account")), - ) - .await - .expect("second pooled websocket response should reuse the probed socket"); - server.await.unwrap(); - - assert!(first.body.contains("resp_pool_keepalive_first")); - assert!(second.body.contains("resp_pool_keepalive_second")); - assert_eq!(ping_count.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn websocket_pool_should_evict_idle_connection_when_ping_times_out() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - completed_websocket_response("resp_no_pong_first", 2, 1).into(), - )) - .await - .unwrap(); - let (second_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - completed_websocket_response("resp_no_pong_second", 2, 1).into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - drop(first_websocket); - }); - let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { - ping_interval: Some(Duration::from_millis(1)), - ping_timeout: Duration::from_millis(20), - maintenance_interval: None, - ..websocket_pool_config_for_tests(None, None, None) - })); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let request = pooled_websocket_request("conversation-no-pong"); - - let first = backend - .create_response( - &request, - request_context("req_no_pong_first", Some("chatgpt-account")), - ) - .await - .expect("first websocket response should succeed"); - pool.maintain_idle_connections().await; - let second = backend - .create_response( - &request, - request_context("req_no_pong_second", Some("chatgpt-account")), - ) - .await - .expect("second websocket response should use a fresh connection"); - server.await.unwrap(); - - assert!(first.body.contains("resp_no_pong_first")); - assert!(second.body.contains("resp_no_pong_second")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn websocket_pool_should_gc_expired_idle_connections() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - completed_websocket_response("resp_gc_first", 2, 1).into(), - )) - .await - .unwrap(); - let close = timeout(Duration::from_secs(1), first_websocket.next()) - .await - .expect("gc sweep should close the expired idle websocket") - .expect("gc sweep should send a close frame") - .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); - - let (second_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - completed_websocket_response("resp_gc_second", 2, 1).into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { - max_age: Duration::from_millis(5), - maintenance_interval: None, - ping_interval: None, - liveness_timeout: None, - ..CodexWebSocketPoolConfig::default() - })); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let request = pooled_websocket_request("conversation-gc"); - - let first = backend - .create_response( - &request, - request_context("req_gc_first", Some("chatgpt-account")), - ) - .await - .expect("first websocket response should succeed"); - tokio::time::sleep(Duration::from_millis(15)).await; - pool.gc_sweep().await; - let second = backend - .create_response( - &request, - request_context("req_gc_second", Some("chatgpt-account")), - ) - .await - .expect("second websocket response should use a fresh connection after gc"); - server.await.unwrap(); - - assert!(first.body.contains("resp_gc_first")); - assert!(second.body.contains("resp_gc_second")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_maintenance() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let ping_count = Arc::new(AtomicUsize::new(0)); - let ping_count_for_server = Arc::clone(&ping_count); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _first_message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_background_first", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - - let ping = timeout(Duration::from_secs(1), websocket.next()) - .await - .expect("background maintenance should probe the idle websocket") - .expect("background maintenance should send a websocket frame") - .expect("background maintenance frame should be valid"); - let Message::Ping(payload) = ping else { - panic!("expected background maintenance ping frame, got {ping:?}"); - }; - ping_count_for_server.fetch_add(1, Ordering::SeqCst); - websocket.send(Message::Pong(payload)).await.unwrap(); - - let _second_message = websocket.next().await.unwrap().unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_background_second", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_config( - websocket_pool_config_for_tests( - Some(Duration::from_millis(20)), - Some(Duration::from_secs(60)), - None, - ), - )); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool-background".to_string()); - - let first = backend - .create_response( - &request, - request_context("req_pool_background_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket response should succeed"); - tokio::time::sleep(Duration::from_millis(80)).await; - let second = backend - .create_response( - &request, - request_context("req_pool_background_second", Some("chatgpt-account")), - ) - .await - .expect("second pooled websocket response should reuse the background-probed socket"); - server.await.unwrap(); - pool.shutdown().await; - - assert!(first.body.contains("resp_pool_background_first")); - assert!(second.body.contains("resp_pool_background_second")); - assert_eq!(ping_count.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn codex_backend_client_should_close_idle_pooled_websocket_when_account_is_evicted() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_evict_first", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - let close = timeout(Duration::from_secs(1), first_websocket.next()) - .await - .expect("evict_account should close the idle websocket") - .expect("evict_account should send a close frame") - .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); - - let (second_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_evict_second", - "object": "response", - "usage": { - "input_tokens": 4, - "output_tokens": 1, - "total_tokens": 5 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_config( - websocket_pool_config_for_tests(None, None, None), - )); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool-evict".to_string()); - - let first = backend - .create_response( - &request, - request_context("req_pool_evict_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket response should succeed"); - pool.evict_account("chatgpt-account").await; - let second = backend - .create_response( - &request, - request_context("req_pool_evict_second", Some("chatgpt-account")), - ) - .await - .expect("second websocket response should open a fresh socket after eviction"); - server.await.unwrap(); - - assert!(first.body.contains("resp_pool_evict_first")); - assert!(second.body.contains("resp_pool_evict_second")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdown() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_shutdown_first", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - let close = timeout(Duration::from_secs(1), first_websocket.next()) - .await - .expect("shutdown should close the idle websocket") - .expect("shutdown should send a close frame") - .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); - - let (second_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_shutdown_second", - "object": "response", - "usage": { - "input_tokens": 4, - "output_tokens": 1, - "total_tokens": 5 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_config( - websocket_pool_config_for_tests(None, None, None), - )); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool-shutdown".to_string()); - - let first = backend - .create_response( - &request, - request_context("req_pool_shutdown_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket response should succeed"); - pool.shutdown().await; - let second = backend - .create_response( - &request, - request_context("req_pool_shutdown_second", Some("chatgpt-account")), - ) - .await - .expect("second websocket response should bypass the shut down pool"); - server.await.unwrap(); - - assert!(first.body.contains("resp_pool_shutdown_first")); - assert!(second.body.contains("resp_pool_shutdown_second")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_timeout() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_liveness_first", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - let close = timeout(Duration::from_secs(1), first_websocket.next()) - .await - .expect("liveness timeout should close the idle websocket") - .expect("liveness timeout should send a close frame") - .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); - - let (second_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_liveness_second", - "object": "response", - "usage": { - "input_tokens": 4, - "output_tokens": 1, - "total_tokens": 5 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - }); - let pool = Arc::new(CodexWebSocketPool::with_config( - websocket_pool_config_for_tests(None, None, Some(Duration::from_millis(1))), - )); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(Arc::clone(&pool)); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool-liveness".to_string()); - - let first = backend - .create_response( - &request, - request_context("req_pool_liveness_first", Some("chatgpt-account")), - ) - .await - .expect("first pooled websocket response should succeed"); - tokio::time::sleep(Duration::from_millis(10)).await; - pool.maintain_idle_connections().await; - let second = backend - .create_response( - &request, - request_context("req_pool_liveness_second", Some("chatgpt-account")), - ) - .await - .expect("second websocket response should open a fresh socket after liveness close"); - server.await.unwrap(); - - assert!(first.body.contains("resp_pool_liveness_first")); - assert!(second.body.contains("resp_pool_liveness_second")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn codex_backend_client_should_discard_pooled_websocket_after_upstream_error() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - json!({ - "type": "response.failed", - "response": { - "id": "resp_pool_rate_limit", - "error": { - "code": "rate_limit_exceeded", - "message": "Rate limit reached. Please try again in 1s." - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - first_websocket.close(None).await.unwrap(); - - let (second_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - json!({ - "type": "response.completed", - "response": { - "id": "resp_pool_after_error", - "object": "response", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "total_tokens": 7 - } - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - }); - let pool = Arc::new( - codex_proxy_adapters::codex::websocket::pool::CodexWebSocketPool::new( - 8, - Duration::from_secs(60), - ), - ); - let backend = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ) - .with_websocket_pool(pool); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - Vec::new(), - ); - request.previous_response_id = Some("resp_previous".to_string()); - request.prompt_cache_key = Some("conversation-pool".to_string()); - - let first_error = backend - .create_response( - &request, - request_context("req_pool_error", Some("chatgpt-account")), - ) - .await - .expect_err("first pooled websocket response should surface upstream error"); - let second = backend - .create_response( - &request, - request_context("req_pool_after_error", Some("chatgpt-account")), - ) - .await - .expect("second pooled websocket response should use a fresh connection"); - server.await.unwrap(); - - let client::CodexClientError::Upstream { status, body, .. } = first_error else { - panic!("expected upstream error from first pooled websocket response"); - }; - assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); - assert!(body.contains("rate_limit_exceeded")); - assert!(second.body.contains("resp_pool_after_error")); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[test] -fn update_manifest_should_extract_version_and_build_number() { - let update = codex_proxy_adapters::codex::fingerprint::parse_update_manifest( - r#"{"version":"26.700.111","build_number":"5002"}"#, - ) - .expect("manifest should parse"); - - assert_eq!(update.app_version, "26.700.111"); - assert_eq!(update.build_number, "5002"); -} - -#[tokio::test] -async fn fingerprint_repository_should_upsert_auto_update_record() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("fingerprints.sqlite"); - let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool.clone()); - - repo.upsert_auto_update("26.800.1", "6001", Some("147")) - .await - .expect("first upsert"); - repo.upsert_auto_update("26.800.2", "6002", None) - .await - .expect("second upsert"); - - let stored = repo - .load_latest_auto_updated() - .await - .expect("load latest") - .expect("stored fingerprint"); - let count: (i64,) = - sqlx::query_as("select count(*) from fingerprints where id = 'auto_updated'") - .fetch_one(&pool) - .await - .expect("count row"); - - assert_eq!(count.0, 1); - assert_eq!(stored.app_version, "26.800.2"); - assert_eq!(stored.build_number, "6002"); - assert_eq!(stored.chromium_version, "146"); -} - -#[tokio::test] -async fn fingerprint_updater_should_fetch_manifest_and_persist_history() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/desktop/update.json")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( - r#"{"version":"26.700.111","build_number":"5002"}"#, - "application/json", - )) - .mount(&server) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("fingerprints.sqlite"); - let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool); - let updater = codex_proxy_adapters::codex::fingerprint::FingerprintUpdater::new( - reqwest::Client::new(), - repo.clone(), - format!("{}/desktop/update.json", server.uri()), - ); - - updater.poll_once().await.expect("poll once"); - - let latest = repo - .latest() - .await - .expect("latest row") - .expect("stored history"); - assert_eq!(latest.app_version, "26.700.111"); - assert_eq!(latest.build_number, "5002"); - assert_eq!( - latest.source, - codex_proxy_adapters::codex::fingerprint::CODEX_DESKTOP_UPDATE_SOURCE - ); -} - -#[tokio::test] -async fn update_checker_should_report_available_update_from_appcast() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/appcast.xml")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( - r#" - - - - - - - - "#, - "application/xml", - )) - .mount(&server) - .await; - - let checker = codex_proxy_adapters::codex::fingerprint::UpdateChecker::with_client( - None, - reqwest::Client::new(), - format!("{}/appcast.xml", server.uri()), - tempfile::tempdir() - .expect("temp dir") - .path() - .join("extracted-fingerprint.json"), - "26.800.1", - "6001", - ); - - let state = checker.check_for_update().await.expect("update state"); - - assert!(state.update_available); - assert_eq!(state.latest_version.as_deref(), Some("26.900.1")); - assert_eq!(state.latest_build.as_deref(), Some("7001")); -} - -#[tokio::test] -async fn update_checker_should_apply_available_update_to_repository() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/appcast.xml")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( - r#" - - - - - - - - "#, - "application/xml", - )) - .mount(&server) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("fingerprints.sqlite"); - let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool); - let extracted_path = dir.path().join("extracted-fingerprint.json"); - std::fs::write( - &extracted_path, - r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#, - ) - .expect("write extracted fingerprint"); - - let checker = codex_proxy_adapters::codex::fingerprint::UpdateChecker::with_client( - Some(repo.clone()), - reqwest::Client::new(), - format!("{}/appcast.xml", server.uri()), - extracted_path, - "26.800.1", - "6001", - ); - - let applied = checker - .check_and_apply_update() - .await - .expect("apply update"); - let stored = repo - .load_latest_auto_updated() - .await - .expect("load latest") - .expect("stored fingerprint"); - - assert!(applied); - assert_eq!(stored.app_version, "26.900.1"); - assert_eq!(stored.build_number, "7001"); - assert_eq!(stored.chromium_version, "147"); -} - -#[tokio::test] -async fn codex_backend_client_should_send_desktop_headers_and_capture_response_metadata() { - let server = wiremock::MockServer::start().await; - let sse_body = concat!( - "event: response.completed\n", - "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n", - "\n", - ); - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/codex/responses")) - .and(wiremock::matchers::header( - "authorization", - "Bearer access-token", - )) - .and(wiremock::matchers::header( - "chatgpt-account-id", - "chatgpt-account", - )) - .and(wiremock::matchers::header("originator", "Codex Desktop")) - .and(wiremock::matchers::header("x-client-request-id", "req_1")) - .and(wiremock::matchers::header("x-codex-turn-state", "turn_1")) - .and(wiremock::matchers::header("cookie", "cf_clearance=old")) - .and(wiremock::matchers::body_json(json!({ - "model": "gpt-5.5", - "instructions": "", - "input": [], - "stream": true, - "store": false - }))) - .respond_with( - wiremock::ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header( - "set-cookie", - "cf_clearance=new; Domain=.chatgpt.com; Path=/", - ) - .insert_header("x-codex-turn-state", "turn_2") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - let client = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - server.uri(), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "", - Vec::new(), - ); - request.force_http_sse = true; - - let response = client - .create_response( - &request, - client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_1", - turn_state: Some("turn_1"), - turn_metadata: None, - beta_features: None, - include_timing_metrics: None, - version: None, - codex_window_id: None, - parent_thread_id: None, - cookie_header: Some("cf_clearance=old"), - installation_id: None, - session_id: None, - }, - ) - .await - .unwrap(); - - assert_eq!( - response.usage, - Some(codex_proxy_core::protocol::codex::events::TokenUsage { - input_tokens: 2, - output_tokens: 3, - cached_tokens: 1, - image_input_tokens: 0, - image_output_tokens: 0, - total_tokens: 5, - }) - ); - assert_eq!(response.turn_state.as_deref(), Some("turn_2")); - assert_eq!( - response.set_cookie_headers, - vec!["cf_clearance=new; Domain=.chatgpt.com; Path=/".to_string()] - ); -} - -#[tokio::test] -async fn codex_backend_client_usage_should_use_original_auxiliary_headers() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/api/codex/usage")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({ - "rate_limit": { - "limit_reached": false - } - }))) - .mount(&server) - .await; - let client = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - server.uri(), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let usage = client - .fetch_usage(client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_aux", - turn_state: Some("turn-state"), - turn_metadata: Some("turn-meta"), - beta_features: Some("feature-a"), - include_timing_metrics: Some("true"), - version: Some("26.318.11754"), - codex_window_id: Some("cw_1"), - parent_thread_id: Some("parent-1"), - cookie_header: Some("cf_clearance=old"), - installation_id: Some("install-1"), - session_id: Some("session-1"), - }) - .await - .unwrap(); - - assert_eq!(usage["rate_limit"]["limit_reached"], false); - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("accept").and_then(|value| value.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers - .get("accept-encoding") - .and_then(|value| value.to_str().ok()), - Some("gzip, deflate") - ); - assert!(headers.get("content-type").is_none()); - assert!(headers.get("openai-beta").is_none()); - assert!(headers.get("x-openai-internal-codex-residency").is_none()); - assert!(headers.get("x-client-request-id").is_none()); - assert_eq!( - headers - .get("x-codex-installation-id") - .and_then(|value| value.to_str().ok()), - Some("install-1") - ); - assert!(headers.get("session_id").is_none()); -} - -#[tokio::test] -async fn codex_backend_client_models_should_use_original_auxiliary_headers() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/codex/models")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({ - "models": [ - {"slug": "gpt-5.5", "title": "GPT 5.5"} - ] - }))) - .mount(&server) - .await; - let client = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - server.uri(), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let models = client - .fetch_models(client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_models", - turn_state: Some("turn-state"), - turn_metadata: None, - beta_features: None, - include_timing_metrics: None, - version: None, - codex_window_id: None, - parent_thread_id: None, - cookie_header: None, - installation_id: Some("install-1"), - session_id: Some("session-1"), - }) - .await - .unwrap(); - - assert_eq!(models.len(), 1); - let requests = server.received_requests().await.unwrap(); - let models_request = requests - .iter() - .find(|request| request.url.path() == "/codex/models") - .unwrap(); - let headers = &models_request.headers; - assert_eq!( - headers.get("accept").and_then(|value| value.to_str().ok()), - Some("application/json") - ); - assert!(headers.get("content-type").is_none()); - assert!(headers.get("openai-beta").is_none()); - assert!(headers.get("x-openai-internal-codex-residency").is_none()); - assert!(headers.get("x-client-request-id").is_none()); - assert_eq!( - headers - .get("x-codex-installation-id") - .and_then(|value| value.to_str().ok()), - Some("install-1") - ); - assert!(headers.get("session_id").is_none()); -} - -#[tokio::test] -async fn codex_backend_client_probe_should_return_models_endpoint_status() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/codex/models")) - .respond_with(wiremock::ResponseTemplate::new(204)) - .mount(&server) - .await; - let client = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - server.uri(), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - let probe = client - .probe_models_endpoint(client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_probe", - turn_state: None, - turn_metadata: None, - beta_features: None, - include_timing_metrics: None, - version: None, - codex_window_id: None, - parent_thread_id: None, - cookie_header: None, - installation_id: None, - session_id: None, - }) - .await - .unwrap(); - - assert_eq!(probe.status, reqwest::StatusCode::NO_CONTENT); - assert!(probe - .endpoint - .ends_with("/codex/models?client_version=26.519.81530")); -} - -#[tokio::test] -async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() { - let server = wiremock::MockServer::start().await; - let large_error_body = "x".repeat(1024 * 1024 + 17); - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/codex/responses")) - .respond_with(wiremock::ResponseTemplate::new(500).set_body_string(large_error_body)) - .mount(&server) - .await; - let client = client::CodexBackendClient::new( - client::build_reqwest_client(false).unwrap(), - server.uri(), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "", - Vec::new(), - ); - request.force_http_sse = true; - - let result = client - .create_response( - &request, - client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_large_error", - turn_state: None, - turn_metadata: None, - beta_features: None, - include_timing_metrics: None, - version: None, - codex_window_id: None, - parent_thread_id: None, - cookie_header: None, - installation_id: None, - session_id: None, - }, - ) - .await; - - let Err(client::CodexClientError::Upstream { status, body, .. }) = result else { - panic!("expected upstream error"); - }; - assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(body.len(), 1024 * 1024); -} - -#[tokio::test] -async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - write_completed_sse_response(&mut stream).await; - request - }); - - let mut request = - codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "", - Vec::new(), - ); - request.force_http_sse = true; - request.turn_metadata = Some("turn-meta".to_string()); - request.beta_features = Some("beta-a".to_string()); - request.include_timing_metrics = Some("true".to_string()); - request.version = Some("26.519.81530".to_string()); - request.codex_window_id = Some("cw_1".to_string()); - request.parent_thread_id = Some("parent-1".to_string()); - let client = client::CodexBackendClient::new( - client::build_reqwest_client(true).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - client - .create_response( - &request, - client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_order", - turn_state: Some("turn-state"), - turn_metadata: request.turn_metadata.as_deref(), - beta_features: request.beta_features.as_deref(), - include_timing_metrics: request.include_timing_metrics.as_deref(), - version: request.version.as_deref(), - codex_window_id: request.codex_window_id.as_deref(), - parent_thread_id: request.parent_thread_id.as_deref(), - cookie_header: Some("cf_clearance=old"), - installation_id: Some("install-1"), - session_id: Some("session-1"), - }, - ) - .await - .unwrap(); - - let raw_request = server.await.unwrap(); - let header_names = read_header_names(&raw_request); - assert_header_subsequence( - &header_names, - &[ - "authorization", - "chatgpt-account-id", - "originator", - "user-agent", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "accept-encoding", - "accept-language", - "sec-fetch-site", - "sec-fetch-mode", - "sec-fetch-dest", - "content-type", - "cookie", - "accept", - "openai-beta", - "x-openai-internal-codex-residency", - "x-client-request-id", - "x-codex-installation-id", - "session_id", - "x-codex-window-id", - "x-codex-turn-state", - "x-codex-turn-metadata", - "x-codex-beta-features", - "x-responsesapi-include-timing-metrics", - "version", - "x-codex-parent-thread-id", - ], - ); -} - -#[tokio::test] -async fn codex_backend_client_should_send_compact_headers_in_fingerprint_order() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut stream).await; - write_compact_json_response(&mut stream).await; - request - }); - let client = client::CodexBackendClient::new( - client::build_reqwest_client(true).unwrap(), - format!("http://{addr}"), - codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), - ); - - client - .create_compact_response( - &codex_proxy_core::protocol::codex::responses::CodexCompactRequest { - model: "gpt-5.5".to_string(), - input: Vec::new(), - instructions: String::new(), - tools: None, - parallel_tool_calls: None, - reasoning: None, - text: None, - }, - client::CodexRequestContext { - access_token: "access-token", - account_id: Some("chatgpt-account"), - request_id: "req_compact", - turn_state: None, - turn_metadata: None, - beta_features: None, - include_timing_metrics: None, - version: None, - codex_window_id: None, - parent_thread_id: None, - cookie_header: Some("cf_clearance=old"), - installation_id: Some("install-1"), - session_id: None, - }, - ) - .await - .unwrap(); - - let raw_request = server.await.unwrap(); - let header_names = read_header_names(&raw_request); - assert_header_subsequence( - &header_names, - &[ - "authorization", - "chatgpt-account-id", - "originator", - "user-agent", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "accept-encoding", - "accept-language", - "sec-fetch-site", - "sec-fetch-mode", - "sec-fetch-dest", - "content-type", - "cookie", - "openai-beta", - "x-openai-internal-codex-residency", - "x-client-request-id", - "x-codex-installation-id", - ], - ); -} - -#[tokio::test] -async fn build_reqwest_client_should_reuse_cached_connection_pool() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (mut first_stream, _) = listener.accept().await.unwrap(); - read_http_request(&mut first_stream).await; - write_empty_http_response(&mut first_stream).await; - - tokio::select! { - request = read_http_request(&mut first_stream) => { - write_empty_http_response(&mut first_stream).await; - !request.is_empty() - } - accepted = listener.accept() => { - let (mut second_stream, _) = accepted.unwrap(); - read_http_request(&mut second_stream).await; - write_empty_http_response(&mut second_stream).await; - false - } - _ = tokio::time::sleep(Duration::from_millis(500)) => false, - } - }); - - let url = format!("http://{addr}/reuse"); - let first_client = client::build_reqwest_client(false).unwrap(); - first_client - .get(&url) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - let second_client = client::build_reqwest_client(false).unwrap(); - second_client - .get(&url) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - - assert!(server.await.unwrap()); -} - fn read_header_names(request: &str) -> Vec { request .lines() diff --git a/crates/adapters/tests/codex/codex_fingerprint.rs b/crates/adapters/tests/codex/codex_fingerprint.rs new file mode 100644 index 000000000..77c6297a2 --- /dev/null +++ b/crates/adapters/tests/codex/codex_fingerprint.rs @@ -0,0 +1,179 @@ +#[test] +fn update_manifest_should_extract_version_and_build_number() { + let update = codex_proxy_adapters::codex::fingerprint::parse_update_manifest( + r#"{"version":"26.700.111","build_number":"5002"}"#, + ) + .expect("manifest should parse"); + + assert_eq!(update.app_version, "26.700.111"); + assert_eq!(update.build_number, "5002"); +} + +#[tokio::test] +async fn fingerprint_repository_should_upsert_auto_update_record() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints.sqlite"); + let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool.clone()); + + repo.upsert_auto_update("26.800.1", "6001", Some("147")) + .await + .expect("first upsert"); + repo.upsert_auto_update("26.800.2", "6002", None) + .await + .expect("second upsert"); + + let stored = repo + .load_latest_auto_updated() + .await + .expect("load latest") + .expect("stored fingerprint"); + let count: (i64,) = + sqlx::query_as("select count(*) from fingerprints where id = 'auto_updated'") + .fetch_one(&pool) + .await + .expect("count row"); + + assert_eq!(count.0, 1); + assert_eq!(stored.app_version, "26.800.2"); + assert_eq!(stored.build_number, "6002"); + assert_eq!(stored.chromium_version, "146"); +} + +#[tokio::test] +async fn fingerprint_updater_should_fetch_manifest_and_persist_history() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/desktop/update.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + r#"{"version":"26.700.111","build_number":"5002"}"#, + "application/json", + )) + .mount(&server) + .await; + + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints.sqlite"); + let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool); + let updater = codex_proxy_adapters::codex::fingerprint::FingerprintUpdater::new( + reqwest::Client::new(), + repo.clone(), + format!("{}/desktop/update.json", server.uri()), + ); + + updater.poll_once().await.expect("poll once"); + + let latest = repo + .latest() + .await + .expect("latest row") + .expect("stored history"); + assert_eq!(latest.app_version, "26.700.111"); + assert_eq!(latest.build_number, "5002"); + assert_eq!( + latest.source, + codex_proxy_adapters::codex::fingerprint::CODEX_DESKTOP_UPDATE_SOURCE + ); +} + +#[tokio::test] +async fn update_checker_should_report_available_update_from_appcast() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/appcast.xml")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + r#" + + + + + + + + "#, + "application/xml", + )) + .mount(&server) + .await; + + let checker = codex_proxy_adapters::codex::fingerprint::UpdateChecker::with_client( + None, + reqwest::Client::new(), + format!("{}/appcast.xml", server.uri()), + tempfile::tempdir() + .expect("temp dir") + .path() + .join("extracted-fingerprint.json"), + "26.800.1", + "6001", + ); + + let state = checker.check_for_update().await.expect("update state"); + + assert!(state.update_available); + assert_eq!(state.latest_version.as_deref(), Some("26.900.1")); + assert_eq!(state.latest_build.as_deref(), Some("7001")); +} + +#[tokio::test] +async fn update_checker_should_apply_available_update_to_repository() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/appcast.xml")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + r#" + + + + + + + + "#, + "application/xml", + )) + .mount(&server) + .await; + + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints.sqlite"); + let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool); + let extracted_path = dir.path().join("extracted-fingerprint.json"); + std::fs::write( + &extracted_path, + r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#, + ) + .expect("write extracted fingerprint"); + + let checker = codex_proxy_adapters::codex::fingerprint::UpdateChecker::with_client( + Some(repo.clone()), + reqwest::Client::new(), + format!("{}/appcast.xml", server.uri()), + extracted_path, + "26.800.1", + "6001", + ); + + let applied = checker + .check_and_apply_update() + .await + .expect("apply update"); + let stored = repo + .load_latest_auto_updated() + .await + .expect("load latest") + .expect("stored fingerprint"); + + assert!(applied); + assert_eq!(stored.app_version, "26.900.1"); + assert_eq!(stored.build_number, "7001"); + assert_eq!(stored.chromium_version, "147"); +} diff --git a/crates/adapters/tests/codex/codex_headers.rs b/crates/adapters/tests/codex/codex_headers.rs new file mode 100644 index 000000000..72ad4196e --- /dev/null +++ b/crates/adapters/tests/codex/codex_headers.rs @@ -0,0 +1,722 @@ +use super::*; + +#[test] +fn ordered_codex_headers_should_preserve_fingerprint_priority_and_request_fields() { + let fingerprint = codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(); + + let headers = client::build_ordered_codex_headers( + &fingerprint, + "access-token", + Some("acct-1"), + Some("turn-1"), + "req-1", + ); + let keys = headers.keys().cloned().collect::>(); + + assert_eq!(headers["authorization"], "Bearer access-token"); + assert_eq!(headers["chatgpt-account-id"], "acct-1"); + assert_eq!(headers["x-client-request-id"], "req-1"); + assert_eq!(headers["x-codex-turn-state"], "turn-1"); + assert_eq!(headers["accept"], "text/event-stream"); + assert_eq!(keys.first().map(String::as_str), Some("authorization")); +} + +#[test] +fn websocket_connection_should_preserve_endpoint_and_header_order() { + let connection = codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::new( + "wss://chatgpt.com/backend-api/codex", + vec![ + ("authorization".to_string(), "Bearer token".to_string()), + ("user-agent".to_string(), "Codex Desktop/test".to_string()), + ], + ); + + assert_eq!( + ( + connection.endpoint(), + connection.opening_audit_snapshot().header_order, + ), + ( + "wss://chatgpt.com/backend-api/codex", + vec!["authorization".to_string(), "user-agent".to_string()], + ) + ); +} + +#[test] +fn websocket_connection_opening_audit_should_redact_sensitive_headers() { + let connection = codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::new( + "wss://chatgpt.com/backend-api/codex/responses?source=audit", + vec![ + ( + "authorization".to_string(), + "Bearer access-secret".to_string(), + ), + ("chatgpt-account-id".to_string(), "acct-secret".to_string()), + ("user-agent".to_string(), "Codex Desktop/test".to_string()), + ("x-client-request-id".to_string(), "req-secret".to_string()), + ( + "x-codex-turn-metadata".to_string(), + "{\"secret\":true}".to_string(), + ), + ], + ); + + let snapshot = connection.opening_audit_snapshot(); + + assert_eq!( + snapshot.request_line, + "GET /backend-api/codex/responses?source=audit HTTP/1.1" + ); + assert_eq!( + snapshot + .headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(), + vec![ + ("authorization", ""), + ("chatgpt-account-id", ""), + ("user-agent", "Codex Desktop/test"), + ("x-client-request-id", ""), + ("x-codex-turn-metadata", ""), + ] + ); +} + +#[test] +fn websocket_connection_should_build_standard_opening_headers_around_business_headers() { + let connection = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses( + "https://chatgpt.com/backend-api", + "test-websocket-key", + vec![ + ( + "authorization".to_string(), + "Bearer access-token".to_string(), + ), + ("user-agent".to_string(), "Codex Desktop/test".to_string()), + ( + "openai-beta".to_string(), + "responses_websockets=2026-02-06".to_string(), + ), + ], + ); + + let snapshot = connection.opening_audit_snapshot(); + + assert_eq!( + connection.endpoint(), + "wss://chatgpt.com/backend-api/codex/responses" + ); + assert_eq!( + snapshot.header_order, + vec![ + "Host", + "Connection", + "Upgrade", + "Sec-WebSocket-Version", + "Sec-WebSocket-Key", + "authorization", + "user-agent", + "openai-beta", + "sec-websocket-extensions", + ] + ); + assert_eq!( + snapshot + .headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(), + vec![ + ("Host", "chatgpt.com"), + ("Connection", "Upgrade"), + ("Upgrade", "websocket"), + ("Sec-WebSocket-Version", "13"), + ("Sec-WebSocket-Key", "test-websocket-key"), + ("authorization", ""), + ("user-agent", "Codex Desktop/test"), + ("openai-beta", "responses_websockets=2026-02-06"), + ( + "sec-websocket-extensions", + "permessage-deflate; client_max_window_bits" + ), + ] + ); +} + +#[tokio::test] +#[expect( + clippy::result_large_err, + reason = "tokio-tungstenite handshake callbacks use a large error response type" +)] +async fn websocket_execute_response_create_request_should_capture_handshake_headers() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = + accept_hdr_async(stream, |_request: &WsRequest, mut response: WsResponse| { + response + .headers_mut() + .insert("x-codex-turn-state", "turn-from-handshake".parse().unwrap()); + response.headers_mut().insert( + "set-cookie", + "cf_clearance=ws; Domain=.chatgpt.com; Path=/" + .parse() + .unwrap(), + ); + response + .headers_mut() + .insert("x-ratelimit-remaining-requests", "41".parse().unwrap()); + Ok(response) + }) + .await + .unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_ws_headers", + "object": "response", + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let response = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect("websocket exchange should succeed"); + server.await.unwrap(); + + assert_eq!(response.turn_state.as_deref(), Some("turn-from-handshake")); + assert_eq!( + response.set_cookie_headers, + vec!["cf_clearance=ws; Domain=.chatgpt.com; Path=/".to_string()] + ); + assert!(response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "41")); +} + +#[tokio::test] +#[expect( + clippy::result_large_err, + reason = "tokio-tungstenite handshake callbacks use a large error response type" +)] +async fn codex_backend_client_websocket_should_forward_security_chain_headers_and_payload_fields() { + let received_headers = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let headers_for_server = Arc::clone(&received_headers); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = + accept_hdr_async(stream, move |request: &WsRequest, response: WsResponse| { + let headers = request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect::>(); + *headers_for_server.lock().unwrap() = headers; + Ok(response) + }) + .await + .unwrap(); + let message = websocket.next().await.unwrap().unwrap(); + let payload = serde_json::from_str::(&message.into_text().unwrap()) + .expect("client payload should be json"); + websocket + .send(Message::Text( + completed_websocket_response("resp_ws_security", 1, 1).into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + payload + }); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.use_websocket = true; + request.prompt_cache_key = Some("client-thread".to_string()); + request.client_metadata = Some(json!({ + "safe": "yes", + "x-openai-subagent": "review", + "ignored_non_string": 42 + })); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let response = backend + .create_response( + &request, + client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_ws_security", + turn_state: Some("turn-state"), + turn_metadata: Some("{\"thread_source\":\"subagent\"}"), + beta_features: Some("feature-a"), + include_timing_metrics: Some("true"), + version: Some("26.318.11754"), + codex_window_id: Some("cw_derived"), + parent_thread_id: Some("parent-456"), + cookie_header: None, + installation_id: Some("install-123"), + session_id: Some("cp_derived"), + }, + ) + .await + .expect("websocket response should succeed"); + let payload = server.await.unwrap(); + + assert!(response.body.contains("resp_ws_security")); + assert_eq!(payload["prompt_cache_key"], "cp_derived"); + assert_eq!( + payload["client_metadata"], + json!({ + "safe": "yes", + "x-openai-subagent": "review", + "x-codex-installation-id": "install-123", + "x-codex-window-id": "cw_derived", + "x-codex-turn-metadata": "{\"thread_source\":\"subagent\"}", + "x-codex-parent-thread-id": "parent-456" + }) + ); + + let headers = received_headers.lock().unwrap().clone(); + assert!(headers + .iter() + .any(|(name, value)| name == "x-client-request-id" && value == "cp_derived")); + assert!(headers + .iter() + .any(|(name, value)| name == "x-openai-subagent" && value == "review")); + assert!(headers + .iter() + .any(|(name, value)| name == "session-id" && value == "cp_derived")); + assert!(headers + .iter() + .any(|(name, value)| name == "thread-id" && value == "cp_derived")); + assert!(headers.iter().all(|(name, _)| name != "content-type")); + assert!(headers.iter().all(|(name, _)| name != "accept")); + assert!(headers.iter().all(|(name, _)| name != "session_id")); +} + +#[tokio::test] +async fn codex_backend_client_should_send_desktop_headers_and_capture_response_metadata() { + let server = wiremock::MockServer::start().await; + let sse_body = concat!( + "event: response.completed\n", + "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n", + "\n", + ); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/codex/responses")) + .and(wiremock::matchers::header( + "authorization", + "Bearer access-token", + )) + .and(wiremock::matchers::header( + "chatgpt-account-id", + "chatgpt-account", + )) + .and(wiremock::matchers::header("originator", "Codex Desktop")) + .and(wiremock::matchers::header("x-client-request-id", "req_1")) + .and(wiremock::matchers::header("x-codex-turn-state", "turn_1")) + .and(wiremock::matchers::header("cookie", "cf_clearance=old")) + .and(wiremock::matchers::body_json(json!({ + "model": "gpt-5.5", + "instructions": "", + "input": [], + "stream": true, + "store": false + }))) + .respond_with( + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header( + "set-cookie", + "cf_clearance=new; Domain=.chatgpt.com; Path=/", + ) + .insert_header("x-codex-turn-state", "turn_2") + .set_body_string(sse_body), + ) + .mount(&server) + .await; + let client = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + server.uri(), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "", + Vec::new(), + ); + request.force_http_sse = true; + + let response = client + .create_response( + &request, + client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_1", + turn_state: Some("turn_1"), + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: Some("cf_clearance=old"), + installation_id: None, + session_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!( + response.usage, + Some(codex_proxy_core::protocol::codex::events::TokenUsage { + input_tokens: 2, + output_tokens: 3, + cached_tokens: 1, + image_input_tokens: 0, + image_output_tokens: 0, + total_tokens: 5, + }) + ); + assert_eq!(response.turn_state.as_deref(), Some("turn_2")); + assert_eq!( + response.set_cookie_headers, + vec!["cf_clearance=new; Domain=.chatgpt.com; Path=/".to_string()] + ); +} + +#[tokio::test] +async fn codex_backend_client_usage_should_use_original_auxiliary_headers() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api/codex/usage")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({ + "rate_limit": { + "limit_reached": false + } + }))) + .mount(&server) + .await; + let client = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + server.uri(), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let usage = client + .fetch_usage(client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_aux", + turn_state: Some("turn-state"), + turn_metadata: Some("turn-meta"), + beta_features: Some("feature-a"), + include_timing_metrics: Some("true"), + version: Some("26.318.11754"), + codex_window_id: Some("cw_1"), + parent_thread_id: Some("parent-1"), + cookie_header: Some("cf_clearance=old"), + installation_id: Some("install-1"), + session_id: Some("session-1"), + }) + .await + .unwrap(); + + assert_eq!(usage["rate_limit"]["limit_reached"], false); + let requests = server.received_requests().await.unwrap(); + let headers = &requests[0].headers; + assert_eq!( + headers.get("accept").and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!( + headers + .get("accept-encoding") + .and_then(|value| value.to_str().ok()), + Some("gzip, deflate") + ); + assert!(headers.get("content-type").is_none()); + assert!(headers.get("openai-beta").is_none()); + assert!(headers.get("x-openai-internal-codex-residency").is_none()); + assert!(headers.get("x-client-request-id").is_none()); + assert_eq!( + headers + .get("x-codex-installation-id") + .and_then(|value| value.to_str().ok()), + Some("install-1") + ); + assert!(headers.get("session_id").is_none()); +} + +#[tokio::test] +async fn codex_backend_client_models_should_use_original_auxiliary_headers() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/codex/models")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({ + "models": [ + {"slug": "gpt-5.5", "title": "GPT 5.5"} + ] + }))) + .mount(&server) + .await; + let client = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + server.uri(), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let models = client + .fetch_models(client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_models", + turn_state: Some("turn-state"), + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: None, + installation_id: Some("install-1"), + session_id: Some("session-1"), + }) + .await + .unwrap(); + + assert_eq!(models.len(), 1); + let requests = server.received_requests().await.unwrap(); + let models_request = requests + .iter() + .find(|request| request.url.path() == "/codex/models") + .unwrap(); + let headers = &models_request.headers; + assert_eq!( + headers.get("accept").and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert!(headers.get("content-type").is_none()); + assert!(headers.get("openai-beta").is_none()); + assert!(headers.get("x-openai-internal-codex-residency").is_none()); + assert!(headers.get("x-client-request-id").is_none()); + assert_eq!( + headers + .get("x-codex-installation-id") + .and_then(|value| value.to_str().ok()), + Some("install-1") + ); + assert!(headers.get("session_id").is_none()); +} + +#[tokio::test] +async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + write_completed_sse_response(&mut stream).await; + request + }); + + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "", + Vec::new(), + ); + request.force_http_sse = true; + request.turn_metadata = Some("turn-meta".to_string()); + request.beta_features = Some("beta-a".to_string()); + request.include_timing_metrics = Some("true".to_string()); + request.version = Some("26.519.81530".to_string()); + request.codex_window_id = Some("cw_1".to_string()); + request.parent_thread_id = Some("parent-1".to_string()); + let client = client::CodexBackendClient::new( + client::build_reqwest_client(true).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + client + .create_response( + &request, + client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_order", + turn_state: Some("turn-state"), + turn_metadata: request.turn_metadata.as_deref(), + beta_features: request.beta_features.as_deref(), + include_timing_metrics: request.include_timing_metrics.as_deref(), + version: request.version.as_deref(), + codex_window_id: request.codex_window_id.as_deref(), + parent_thread_id: request.parent_thread_id.as_deref(), + cookie_header: Some("cf_clearance=old"), + installation_id: Some("install-1"), + session_id: Some("session-1"), + }, + ) + .await + .unwrap(); + + let raw_request = server.await.unwrap(); + let header_names = read_header_names(&raw_request); + assert_header_subsequence( + &header_names, + &[ + "authorization", + "chatgpt-account-id", + "originator", + "user-agent", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "accept-encoding", + "accept-language", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "content-type", + "cookie", + "accept", + "openai-beta", + "x-openai-internal-codex-residency", + "x-client-request-id", + "x-codex-installation-id", + "session_id", + "x-codex-window-id", + "x-codex-turn-state", + "x-codex-turn-metadata", + "x-codex-beta-features", + "x-responsesapi-include-timing-metrics", + "version", + "x-codex-parent-thread-id", + ], + ); +} + +#[tokio::test] +async fn codex_backend_client_should_send_compact_headers_in_fingerprint_order() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + write_compact_json_response(&mut stream).await; + request + }); + let client = client::CodexBackendClient::new( + client::build_reqwest_client(true).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + client + .create_compact_response( + &codex_proxy_core::protocol::codex::responses::CodexCompactRequest { + model: "gpt-5.5".to_string(), + input: Vec::new(), + instructions: String::new(), + tools: None, + parallel_tool_calls: None, + reasoning: None, + text: None, + }, + client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_compact", + turn_state: None, + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: Some("cf_clearance=old"), + installation_id: Some("install-1"), + session_id: None, + }, + ) + .await + .unwrap(); + + let raw_request = server.await.unwrap(); + let header_names = read_header_names(&raw_request); + assert_header_subsequence( + &header_names, + &[ + "authorization", + "chatgpt-account-id", + "originator", + "user-agent", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "accept-encoding", + "accept-language", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "content-type", + "cookie", + "openai-beta", + "x-openai-internal-codex-residency", + "x-client-request-id", + "x-codex-installation-id", + ], + ); +} diff --git a/crates/adapters/tests/codex/codex_http_client.rs b/crates/adapters/tests/codex/codex_http_client.rs new file mode 100644 index 000000000..7e5c7439c --- /dev/null +++ b/crates/adapters/tests/codex/codex_http_client.rs @@ -0,0 +1,208 @@ +use super::*; + +#[test] +fn endpoints_should_join_backend_paths() { + assert_eq!( + client::endpoint_url("https://api.example.com/", "/codex/responses"), + "https://api.example.com/codex/responses" + ); + assert_eq!( + client::endpoint_request_path("https://api.example.com/backend-api", "/codex/usage"), + "/backend-api/codex/usage" + ); +} + +#[test] +fn custom_ca_should_report_environment_cache_key_consistently() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_CUSTOM_CA_CACHE_KEY_CASE"; + const SSL_CERT_PATH: &str = "/tmp/codex-proxy-ssl-cert-file.pem"; + const CODEX_CA_PATH: &str = "/tmp/codex-proxy-codex-ca-certificate.pem"; + + if let Ok(case) = std::env::var(CASE_ENV) { + let expected = match case.as_str() { + "unset" => None, + "ssl_cert_file" => Some(format!("{}={SSL_CERT_PATH}", client::SSL_CERT_FILE_ENV)), + "codex_ca_priority" => Some(format!("{}={CODEX_CA_PATH}", client::CODEX_CA_CERT_ENV)), + _ => panic!("unknown custom CA cache key test case: {case}"), + }; + + assert_eq!(client::custom_ca_env_cache_key(), expected); + return; + } + + let current_exe = std::env::current_exe().expect("current test binary path"); + let cases = [ + ("unset", None, None), + ( + "ssl_cert_file", + None, + Some((client::SSL_CERT_FILE_ENV, SSL_CERT_PATH)), + ), + ( + "codex_ca_priority", + Some((client::CODEX_CA_CERT_ENV, CODEX_CA_PATH)), + Some((client::SSL_CERT_FILE_ENV, SSL_CERT_PATH)), + ), + ]; + + for (case, codex_ca, ssl_cert_file) in cases { + let mut command = Command::new(¤t_exe); + command + .arg("--exact") + .arg("custom_ca_should_report_environment_cache_key_consistently") + .arg("--nocapture") + .env(CASE_ENV, case) + .env_remove(client::CODEX_CA_CERT_ENV) + .env_remove(client::SSL_CERT_FILE_ENV); + if let Some((key, value)) = codex_ca { + command.env(key, value); + } + if let Some((key, value)) = ssl_cert_file { + command.env(key, value); + } + + let output = command.output().expect("run isolated custom CA case"); + assert!( + output.status.success(), + "isolated custom CA case {case} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[tokio::test] +async fn codex_backend_client_probe_should_return_models_endpoint_status() { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/codex/models")) + .respond_with(wiremock::ResponseTemplate::new(204)) + .mount(&server) + .await; + let client = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + server.uri(), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let probe = client + .probe_models_endpoint(client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_probe", + turn_state: None, + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: None, + installation_id: None, + session_id: None, + }) + .await + .unwrap(); + + assert_eq!(probe.status, reqwest::StatusCode::NO_CONTENT); + assert!(probe + .endpoint + .ends_with("/codex/models?client_version=26.519.81530")); +} + +#[tokio::test] +async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() { + let server = wiremock::MockServer::start().await; + let large_error_body = "x".repeat(1024 * 1024 + 17); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/codex/responses")) + .respond_with(wiremock::ResponseTemplate::new(500).set_body_string(large_error_body)) + .mount(&server) + .await; + let client = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + server.uri(), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "", + Vec::new(), + ); + request.force_http_sse = true; + + let result = client + .create_response( + &request, + client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_large_error", + turn_state: None, + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: None, + installation_id: None, + session_id: None, + }, + ) + .await; + + let Err(client::CodexClientError::Upstream { status, body, .. }) = result else { + panic!("expected upstream error"); + }; + assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body.len(), 1024 * 1024); +} + +#[tokio::test] +async fn build_reqwest_client_should_reuse_cached_connection_pool() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut first_stream, _) = listener.accept().await.unwrap(); + read_http_request(&mut first_stream).await; + write_empty_http_response(&mut first_stream).await; + + tokio::select! { + request = read_http_request(&mut first_stream) => { + write_empty_http_response(&mut first_stream).await; + !request.is_empty() + } + accepted = listener.accept() => { + let (mut second_stream, _) = accepted.unwrap(); + read_http_request(&mut second_stream).await; + write_empty_http_response(&mut second_stream).await; + false + } + _ = tokio::time::sleep(Duration::from_millis(500)) => false, + } + }); + + let url = format!("http://{addr}/reuse"); + let first_client = client::build_reqwest_client(false).unwrap(); + first_client + .get(&url) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let second_client = client::build_reqwest_client(false).unwrap(); + second_client + .get(&url) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + assert!(server.await.unwrap()); +} diff --git a/crates/adapters/tests/codex/codex_websocket.rs b/crates/adapters/tests/codex/codex_websocket.rs new file mode 100644 index 000000000..b1471553b --- /dev/null +++ b/crates/adapters/tests/codex/codex_websocket.rs @@ -0,0 +1,1308 @@ +use super::*; + +#[tokio::test] +async fn websocket_audit_artifact_should_require_explicit_directory() { + let connection = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses( + "https://chatgpt.com", + "test-websocket-key", + vec![ + ( + "authorization".to_string(), + "Bearer access-token".to_string(), + ), + ("chatgpt-account-id".to_string(), "acct-secret".to_string()), + ("cookie".to_string(), "session=secret".to_string()), + ], + ); + let mut payload_request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "private instructions", + vec![json!({"role": "user", "content": "private prompt"})], + ); + payload_request.prompt_cache_key = Some("cache-secret".to_string()); + payload_request.client_metadata = Some(json!({"thread_id": "thread-secret"})); + let mut artifact = + codex_proxy_core::protocol::codex::websocket::websocket_audit_artifact_from_attempt( + &payload_request, + connection.opening_audit_snapshot(), + codex_proxy_core::protocol::codex::websocket::websocket_payload_audit_snapshot( + &payload_request, + ), + ); + artifact.error = Some( + codex_proxy_core::protocol::codex::websocket::WebSocketAuditErrorSnapshot { + classification: "opening_failed".to_string(), + message: "connection refused".to_string(), + }, + ); + let dir = tempfile::tempdir().expect("temp dir"); + + let disabled = + codex_proxy_adapters::codex::websocket::opening::write_websocket_audit_artifact_for_dir( + None, &artifact, + ) + .await + .expect("disabled audit should be ok"); + + assert!(disabled.is_none()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + + let written = + codex_proxy_adapters::codex::websocket::opening::write_websocket_audit_artifact_for_dir( + Some(dir.path()), + &artifact, + ) + .await + .expect("enabled audit should write") + .expect("enabled audit path"); + let body = std::fs::read_to_string(&written).expect("audit file"); + let json = serde_json::from_str::(&body).expect("audit json"); + + assert_eq!(json["transport_mode"], "websocket_preferred"); + assert_eq!( + json["opening"]["request_line"], + "GET /codex/responses HTTP/1.1" + ); + assert_eq!(json["payload"]["top_level_keys"][1], "model"); + assert_eq!(json["error"]["classification"], "opening_failed"); + assert!(!body.contains("access-token")); + assert!(!body.contains("acct-secret")); + assert!(!body.contains("session=secret")); + assert!(!body.contains("private prompt")); + assert!(!body.contains("private instructions")); + assert!(!body.contains("cache-secret")); + assert!(!body.contains("thread-secret")); +} + +#[test] +fn websocket_responses_endpoint_should_convert_http_base_url_to_ws_endpoint() { + assert_eq!( + codex_proxy_adapters::codex::websocket::connect::responses_websocket_endpoint( + "https://chatgpt.com/backend-api" + ), + "wss://chatgpt.com/backend-api/codex/responses" + ); + assert_eq!( + codex_proxy_adapters::codex::websocket::connect::responses_websocket_endpoint( + "http://127.0.0.1:8080" + ), + "ws://127.0.0.1:8080/codex/responses" + ); +} + +#[test] +fn websocket_deflate_should_rewrite_compressed_server_text_frame() { + let payload = json!({ + "type": "response.completed", + "response": { + "id": "resp_deflate", + "object": "response" + } + }) + .to_string(); + let compressed = compressed_permessage_deflate_payload(payload.as_bytes()); + let frame = server_websocket_frame(0x1, true, &compressed); + + let rewritten = + codex_proxy_adapters::codex::websocket::deflate::rewrite_permessage_deflate_server_frame( + &frame, + ) + .expect("compressed frame should rewrite") + .expect("compressed frame should produce rewritten frame"); + + assert_eq!(rewritten[0] & 0x40, 0); + assert_eq!(rewritten[0] & 0x0f, 0x1); + assert_eq!(server_websocket_payload(&rewritten), payload.as_bytes()); +} + +#[test] +fn websocket_deflate_should_leave_uncompressed_server_frame_unchanged() { + let frame = server_websocket_frame(0x1, false, b"plain"); + + let rewritten = + codex_proxy_adapters::codex::websocket::deflate::rewrite_permessage_deflate_server_frame( + &frame, + ) + .expect("plain frame should parse"); + + assert_eq!(rewritten, None); +} + +#[tokio::test] +async fn codex_backend_client_should_decode_live_permessage_deflate_websocket_frame() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + let websocket_key = websocket_opening_header(&request, "sec-websocket-key") + .expect("opening request should include sec-websocket-key"); + let accept_key = derive_accept_key(websocket_key.as_bytes()); + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Accept: {accept_key}\r\n\ + Sec-WebSocket-Extensions: permessage-deflate\r\n\ + \r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + + let completed = json!({ + "type": "response.completed", + "response": { + "id": "resp_live_deflate", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string(); + let compressed = compressed_permessage_deflate_payload(completed.as_bytes()); + stream + .write_all(&server_websocket_frame(0x1, true, &compressed)) + .await + .unwrap(); + stream.flush().await.unwrap(); + }); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + + let response = backend + .create_response( + &request, + request_context("req_live_deflate", Some("chatgpt-account")), + ) + .await + .expect("deflated websocket response should decode"); + server.await.unwrap(); + + assert!(response.body.contains("resp_live_deflate")); +} + +#[test] +fn websocket_connection_should_render_raw_opening_bytes_for_capture_parity() { + let connection = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses( + "https://chatgpt.com/backend-api", + "test-websocket-key", + vec![ + ( + "chatgpt-account-id".to_string(), + "chatgpt-account".to_string(), + ), + ( + "authorization".to_string(), + "Bearer access-token".to_string(), + ), + ( + "user-agent".to_string(), + "Codex Desktop/26.519.81530 (darwin; arm64)".to_string(), + ), + ("originator".to_string(), "Codex Desktop".to_string()), + ( + "openai-beta".to_string(), + "responses_websockets=2026-02-06".to_string(), + ), + ( + "x-codex-beta-features".to_string(), + "terminal_resize_reflow,memories,network_proxy,prevent_idle_sleep,remote_compaction_v2".to_string(), + ), + ("x-client-request-id".to_string(), "session-1".to_string()), + ("session-id".to_string(), "session-1".to_string()), + ("thread-id".to_string(), "session-1".to_string()), + ("x-codex-window-id".to_string(), "session-1:0".to_string()), + ( + "x-codex-turn-metadata".to_string(), + r#"{"installation_id":"install-1","session_id":"session-1","thread_id":"session-1","turn_id":"","window_id":"session-1:0","request_kind":"prewarm","sandbox":"seccomp"}"#.to_string(), + ), + ], + ); + + let opening = connection.opening_request_text(); + + assert!(opening.starts_with("GET /backend-api/codex/responses HTTP/1.1\r\n")); + assert_substrings_appear_in_order( + &opening, + &[ + "Host: chatgpt.com\r\n", + "Connection: Upgrade\r\n", + "Upgrade: websocket\r\n", + "Sec-WebSocket-Version: 13\r\n", + "Sec-WebSocket-Key: test-websocket-key\r\n", + "chatgpt-account-id: chatgpt-account\r\n", + "authorization: Bearer access-token\r\n", + "user-agent: Codex Desktop/26.519.81530 (darwin; arm64)\r\n", + "originator: Codex Desktop\r\n", + "openai-beta: responses_websockets=2026-02-06\r\n", + "x-codex-beta-features: terminal_resize_reflow,memories,network_proxy,prevent_idle_sleep,remote_compaction_v2\r\n", + "x-client-request-id: session-1\r\n", + "session-id: session-1\r\n", + "thread-id: session-1\r\n", + "x-codex-window-id: session-1:0\r\n", + r#"x-codex-turn-metadata: {"installation_id":"install-1","session_id":"session-1","thread_id":"session-1","turn_id":"","window_id":"session-1:0","request_kind":"prewarm","sandbox":"seccomp"}"#, + "sec-websocket-extensions: permessage-deflate; client_max_window_bits\r\n", + ], + ); + assert!(opening.ends_with("\r\n\r\n")); +} + +#[test] +fn websocket_connection_should_prepare_response_create_payload_text() { + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + vec![json!({ + "role": "user", + "content": "hello", + })], + ); + + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + "https://chatgpt.com/backend-api", + "test-websocket-key", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + let payload: serde_json::Value = + serde_json::from_str(prepared.payload_text()).expect("payload should be json"); + + assert_eq!( + prepared.connection().endpoint(), + "wss://chatgpt.com/backend-api/codex/responses" + ); + assert_eq!(payload["type"], "response.create"); + assert_eq!(payload["model"], "gpt-5.5"); + assert_eq!(payload["instructions"], "be brief"); + assert_eq!(payload["input"][0]["content"], "hello"); + assert_eq!(payload["stream"], true); +} + +#[test] +fn websocket_connection_should_prepare_capture_payload_with_canonical_field_order() { + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "private capture instructions", + vec![json!({ + "role": "user", + "content": "private capture prompt", + })], + ); + request.prompt_cache_key = Some("session-1".to_string()); + request.client_metadata = Some(json!({ + "thread_id": "capture-thread-secret", + "safe": "capture", + })); + request.generate = Some(false); + + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + "https://chatgpt.com/backend-api", + "test-websocket-key", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + assert_substrings_appear_in_order( + prepared.payload_text(), + &[ + "\"type\":\"response.create\"", + "\"model\":\"gpt-5.5\"", + "\"instructions\":\"private capture instructions\"", + "\"input\":", + "\"tools\":[]", + "\"tool_choice\":\"auto\"", + "\"parallel_tool_calls\":true", + "\"reasoning\":null", + "\"store\":false", + "\"stream\":true", + "\"include\":[]", + "\"prompt_cache_key\":\"session-1\"", + "\"generate\":false", + "\"client_metadata\":", + ], + ); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_collect_completed_sse() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let message = websocket.next().await.unwrap().unwrap(); + let payload = serde_json::from_str::(&message.into_text().unwrap()) + .expect("client payload should be json"); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_ws_live", + "object": "response", + "usage": { + "input_tokens": 5, + "output_tokens": 2, + "total_tokens": 7 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + payload + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let response = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect("websocket exchange should succeed"); + let payload = server.await.unwrap(); + + assert_eq!(payload["type"], "response.create"); + assert!(response.body.contains("event: response.completed")); + assert!(response.body.contains("\"id\":\"resp_ws_live\"")); + assert_eq!(response.usage.expect("usage").input_tokens, 5); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_surface_response_failed_as_upstream_error( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.failed", + "response": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit reached. Please try again in 11.054s." + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("response.failed should be surfaced as upstream error"); + server.await.unwrap(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { + status_code, + retry_after_seconds, + body, + } = error + else { + panic!("expected upstream websocket error"); + }; + assert_eq!(status_code, 429); + assert_eq!(retry_after_seconds, Some(12)); + assert!(body.contains("rate_limit_exceeded")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_preserve_opening_error_status_body_and_retry_after( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + assert!(request.starts_with("GET /codex/responses HTTP/1.1")); + assert!(request.contains("authorization: Bearer access-token")); + let body = r#"{"error":{"message":"rate limited"}}"#; + stream + .write_all( + format!( + "HTTP/1.1 429 Too Many Requests\r\nretry-after: 33\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + }); + let prepared = prepared_websocket_request(&format!("http://{addr}")); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("failed opening should surface upstream status"); + server.await.unwrap(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { + status_code, + retry_after_seconds, + body, + } = error + else { + panic!("expected upstream opening error"); + }; + assert_eq!(status_code, 429); + assert_eq!(retry_after_seconds, Some(33)); + assert!(body.contains("rate limited")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_reject_binary_event() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Binary(b"unexpected-binary".to_vec().into())) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let prepared = prepared_websocket_request(&format!("http://{addr}")); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("binary websocket events should be rejected"); + server.await.unwrap(); + + assert!(matches!( + error, + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::UnexpectedBinaryEvent + )); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_surface_wrapped_error_status_and_retry_after( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "error", + "status": 409, + "headers": { + "retry-after": ["17"] + }, + "error": { + "code": "conflict", + "message": "wrapped conflict" + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let prepared = prepared_websocket_request(&format!("http://{addr}")); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("wrapped error should surface as upstream error"); + server.await.unwrap(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { + status_code, + retry_after_seconds, + body, + } = error + else { + panic!("expected wrapped upstream error"); + }; + assert_eq!(status_code, 409); + assert_eq!(retry_after_seconds, Some(17)); + assert!(body.contains("wrapped conflict")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_surface_connection_limit_as_503() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.failed", + "response": { + "id": "resp_connection_limit", + "error": { + "code": "websocket_connection_limit_reached", + "message": "connection limit reached" + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let prepared = prepared_websocket_request(&format!("http://{addr}")); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("connection limit should surface as upstream error"); + server.await.unwrap(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream { + status_code, + body, + .. + } = error + else { + panic!("expected connection limit upstream error"); + }; + assert_eq!(status_code, 503); + assert!(body.contains("websocket_connection_limit_reached")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_skip_invalid_stream_events() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.output_text.delta" + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_after_invalid", + "object": "response" + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let response = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect("websocket exchange should succeed"); + server.await.unwrap(); + + assert!(!response.body.contains("response.output_text.delta")); + assert!(response.body.contains("event: response.completed")); + assert!(response.body.contains("\"id\":\"resp_after_invalid\"")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_capture_internal_metadata_and_rate_limit_events( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "codex.rate_limits", + "rate_limits": { + "primary": { + "used_percent": 100, + "window_minutes": 5, + "reset_at": 1893456300 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.metadata", + "headers": { + "x-codex-turn-state": ["turn-from-metadata"] + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_internal_events", + "object": "response", + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let prepared = prepared_websocket_request(&format!("http://{addr}")); + + let response = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect("internal events should update metadata without forwarding"); + server.await.unwrap(); + + assert_eq!(response.turn_state.as_deref(), Some("turn-from-metadata")); + assert!(!response.body.contains("codex.rate_limits")); + assert!(!response.body.contains("response.metadata")); + assert!(response.body.contains("event: response.completed")); + assert!(response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-codex-primary-used-percent" && value == "100")); + assert!(response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-codex-primary-reset-at" && value == "1893456300")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_surface_incomplete_response() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.incomplete", + "response": { + "id": "resp_incomplete", + "incomplete_details": { + "reason": "max_output_tokens" + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("response.incomplete should be surfaced as websocket error"); + server.await.unwrap(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::IncompleteResponse { + reason, + } = error + else { + panic!("expected incomplete websocket response"); + }; + assert_eq!(reason, "max_output_tokens"); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_reject_invalid_completed_response() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_invalid_completed", + "object": "response", + "usage": { + "input_tokens": "bad", + "output_tokens": 1, + "total_tokens": 1 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("invalid response.completed should be rejected"); + server.await.unwrap(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::InvalidCompletedResponse { + message, + } = error + else { + panic!("expected invalid completed websocket response"); + }; + assert!(message.contains("failed to parse ResponseCompleted")); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_ignore_completed_without_response_until_close( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed" + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("response.completed without response should not finish the stream"); + server.await.unwrap(); + + assert!(matches!( + error, + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal + )); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_ignore_success_status_error_until_close() +{ + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "error", + "status": 200, + "error": { + "message": "non-terminal informational frame" + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let error = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect_err("unclassified success-status error frame should not finish the stream"); + server.await.unwrap(); + + assert!(matches!( + error, + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal + )); +} + +#[tokio::test(start_paused = true)] +async fn websocket_execute_response_create_request_should_timeout_when_upstream_is_silent() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (request_seen_tx, request_seen_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + request_seen_tx.send(()).unwrap(); + futures::future::pending::<()>().await; + }); + let prepared = prepared_websocket_request(&format!("http://{addr}")); + + let response_task = tokio::spawn(async move { + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + }); + request_seen_rx.await.unwrap(); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(20)).await; + tokio::task::yield_now().await; + + let error = response_task + .await + .expect("websocket task should finish") + .expect_err("silent upstream should time out"); + server.abort(); + + let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ReceiveIdleTimeout { + timeout, + } = error + else { + panic!("expected receive idle timeout"); + }; + assert_eq!(timeout, Duration::from_secs(20)); +} + +#[tokio::test] +async fn websocket_execute_response_create_request_should_reply_to_server_ping_before_terminal() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Ping("codex-ping".as_bytes().to_vec().into())) + .await + .unwrap(); + let pong = timeout(Duration::from_secs(1), websocket.next()) + .await + .expect("client should reply to server ping before terminal response") + .expect("client should send a websocket frame") + .expect("client frame should be valid"); + assert_eq!(pong, Message::Pong("codex-ping".as_bytes().to_vec().into())); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_after_ping", + "object": "response", + "usage": { + "input_tokens": 2, + "output_tokens": 1, + "total_tokens": 3 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + let prepared = + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request( + &format!("http://{addr}"), + "dGhlIHNhbXBsZSBub25jZQ==", + vec![("authorization".to_string(), "Bearer access-token".to_string())], + &request, + ) + .expect("payload should serialize"); + + let response = + codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared) + .await + .expect("websocket exchange should succeed after ping/pong"); + server.await.unwrap(); + + assert!(response.body.contains("event: response.completed")); + assert!(response.body.contains("\"id\":\"resp_after_ping\"")); + assert_eq!(response.usage.expect("usage").input_tokens, 2); +} + +#[tokio::test] +async fn codex_backend_client_stream_should_reject_binary_websocket_event() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Binary(b"unexpected-binary".to_vec().into())) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let mut response = backend + .create_response_stream( + &request, + request_context("req_stream_binary", Some("chatgpt-account")), + ) + .await + .expect("websocket stream should open"); + let error = response + .body + .next() + .await + .expect("stream should yield binary error") + .expect_err("binary event should be rejected"); + server.await.unwrap(); + + assert!(matches!( + error, + client::CodexClientError::WebSocket( + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::UnexpectedBinaryEvent + ) + )); +} + +#[tokio::test] +async fn codex_backend_client_stream_should_error_when_websocket_closes_before_terminal() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.output_text.delta", + "delta": "partial" + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let mut response = backend + .create_response_stream( + &request, + request_context("req_stream_mid_close", Some("chatgpt-account")), + ) + .await + .expect("websocket stream should open"); + let first = response + .body + .next() + .await + .expect("stream should yield partial frame") + .expect("partial frame should be valid"); + assert!(std::str::from_utf8(&first).unwrap().contains("partial")); + let error = response + .body + .next() + .await + .expect("stream should yield close-before-terminal error") + .expect_err("close before terminal should be an error"); + server.await.unwrap(); + + assert!(matches!( + error, + client::CodexClientError::WebSocket( + codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal + ) + )); +} + +#[tokio::test] +#[expect( + clippy::result_large_err, + reason = "tokio-tungstenite handshake callbacks use a large error response type" +)] +async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_present() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = + accept_hdr_async(stream, |_request: &WsRequest, mut response: WsResponse| { + response + .headers_mut() + .insert("x-codex-turn-state", "turn-ws-client".parse().unwrap()); + response.headers_mut().insert( + "set-cookie", + "cf_clearance=client-ws; Domain=.chatgpt.com; Path=/" + .parse() + .unwrap(), + ); + response + .headers_mut() + .insert("x-ratelimit-remaining-requests", "17".parse().unwrap()); + Ok(response) + }) + .await + .unwrap(); + let message = websocket.next().await.unwrap().unwrap(); + let payload = serde_json::from_str::(&message.into_text().unwrap()) + .expect("client payload should be json"); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_ws_client", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + payload + }); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ); + + let response = backend + .create_response( + &request, + client::CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id: "req_ws_client", + turn_state: None, + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: None, + installation_id: None, + session_id: None, + }, + ) + .await + .expect("websocket response should succeed"); + let payload = server.await.unwrap(); + + assert_eq!(payload["type"], "response.create"); + assert_eq!(payload["previous_response_id"], "resp_previous"); + assert!(response.body.contains("event: response.completed")); + assert!(response.body.contains("\"id\":\"resp_ws_client\"")); + assert_eq!(response.usage.expect("usage").input_tokens, 3); + assert_eq!(response.turn_state.as_deref(), Some("turn-ws-client")); + assert_eq!( + response.set_cookie_headers, + vec!["cf_clearance=client-ws; Domain=.chatgpt.com; Path=/".to_string()] + ); + assert!(response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "17")); +} diff --git a/crates/adapters/tests/codex/codex_websocket_pool.rs b/crates/adapters/tests/codex/codex_websocket_pool.rs new file mode 100644 index 000000000..8d5ee54db --- /dev/null +++ b/crates/adapters/tests/codex/codex_websocket_pool.rs @@ -0,0 +1,1016 @@ +use super::*; + +#[test] +fn websocket_pool_should_apply_capacity_and_age_policy() { + let pool = codex_proxy_adapters::codex::websocket::pool::CodexWebSocketPool::new( + 2, + Duration::from_secs(60), + ); + + assert_eq!( + ( + pool.permits_new_connection(1), + pool.permits_new_connection(2), + pool.should_recycle(Duration::from_secs(59)), + pool.should_recycle(Duration::from_secs(60)), + ), + (true, false, false, true) + ); +} + +#[tokio::test] +async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and_conversation() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut websocket = accept_async(stream).await.unwrap(); + for response_id in ["resp_pool_first", "resp_pool_second"] { + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": response_id, + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + } + websocket.close(None).await.unwrap(); + }); + let pool = Arc::new( + codex_proxy_adapters::codex::websocket::pool::CodexWebSocketPool::new( + 8, + Duration::from_secs(60), + ), + ); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool".to_string()); + + let first = backend + .create_response( + &request, + request_context("req_pool_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket response should succeed"); + let second = backend + .create_response( + &request, + request_context("req_pool_second", Some("chatgpt-account")), + ) + .await + .expect("second pooled websocket response should succeed"); + server.await.unwrap(); + + assert!(first.body.contains("resp_pool_first")); + assert!(second.body.contains("resp_pool_second")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (release_first_tx, release_first_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + json!({ + "type": "response.output_text.delta", + "delta": "first connection is still busy" + }) + .to_string() + .into(), + )) + .await + .unwrap(); + + let (second_stream, _) = listener.accept().await.unwrap(); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + completed_websocket_response("resp_busy_second", 2, 1).into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + + let (third_stream, _) = listener.accept().await.unwrap(); + let mut third_websocket = accept_async(third_stream).await.unwrap(); + let _third_message = third_websocket.next().await.unwrap().unwrap(); + third_websocket + .send(Message::Text( + completed_websocket_response("resp_busy_third", 2, 1).into(), + )) + .await + .unwrap(); + third_websocket.close(None).await.unwrap(); + + release_first_rx.await.unwrap(); + first_websocket + .send(Message::Text( + completed_websocket_response("resp_busy_first", 2, 1).into(), + )) + .await + .unwrap(); + first_websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_default_max_age()); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(pool); + let request = pooled_websocket_request("conversation-busy"); + + let mut first = backend + .create_response_stream( + &request, + request_context("req_busy_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket stream should start") + .body; + let first_chunk = first + .next() + .await + .expect("first stream should yield an initial chunk") + .expect("first stream chunk should be valid"); + let first_chunk = std::str::from_utf8(&first_chunk).unwrap(); + assert!(first_chunk.contains("first connection is still busy")); + + let second = backend + .create_response( + &request, + request_context("req_busy_second", Some("chatgpt-account")), + ) + .await + .expect("busy key should bypass with a one-shot second connection"); + let third = backend + .create_response( + &request, + request_context("req_busy_third", Some("chatgpt-account")), + ) + .await + .expect("busy key should bypass with a one-shot third connection"); + + release_first_tx.send(()).unwrap(); + while first.next().await.transpose().unwrap().is_some() {} + server.await.unwrap(); + + assert!(second.body.contains("resp_busy_second")); + assert!(third.body.contains("resp_busy_third")); +} + +#[tokio::test] +async fn websocket_pool_should_bypass_new_keys_after_account_cap() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + for response_id in ["resp_cap_first", "resp_cap_second", "resp_cap_third"] { + let (stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut websocket = accept_async(stream).await.unwrap(); + let _message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + completed_websocket_response(response_id, 2, 1).into(), + )) + .await + .unwrap(); + if response_id == "resp_cap_third" { + websocket.close(None).await.unwrap(); + } + } + }); + let pool = Arc::new(CodexWebSocketPool::with_limits(Duration::from_secs(60), 1)); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(pool); + let first_request = pooled_websocket_request("conversation-cap-one"); + let second_request = pooled_websocket_request("conversation-cap-two"); + + let first = backend + .create_response( + &first_request, + request_context("req_cap_first", Some("chatgpt-account")), + ) + .await + .expect("first capped websocket response should succeed"); + let second = backend + .create_response( + &second_request, + request_context("req_cap_second", Some("chatgpt-account")), + ) + .await + .expect("new key over account cap should use one-shot connection"); + let third = backend + .create_response( + &second_request, + request_context("req_cap_third", Some("chatgpt-account")), + ) + .await + .expect("capped key should keep bypassing instead of entering the pool"); + server.await.unwrap(); + + assert!(first.body.contains("resp_cap_first")); + assert!(second.body.contains("resp_cap_second")); + assert!(third.body.contains("resp_cap_third")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn codex_backend_client_should_ping_idle_pooled_websocket_during_maintenance() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let ping_count = Arc::new(AtomicUsize::new(0)); + let ping_count_for_server = Arc::clone(&ping_count); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _first_message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_keepalive_first", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + + let ping = timeout(Duration::from_secs(1), websocket.next()) + .await + .expect("pool maintenance should probe the idle websocket") + .expect("pool maintenance should send a websocket frame") + .expect("pool maintenance frame should be valid"); + let Message::Ping(payload) = ping else { + panic!("expected pool maintenance ping frame, got {ping:?}"); + }; + ping_count_for_server.fetch_add(1, Ordering::SeqCst); + websocket.send(Message::Pong(payload)).await.unwrap(); + + let _second_message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_keepalive_second", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_config( + websocket_pool_config_for_tests(None, Some(Duration::from_millis(1)), None), + )); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool-keepalive".to_string()); + + let first = backend + .create_response( + &request, + request_context("req_pool_keepalive_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket response should succeed"); + pool.maintain_idle_connections().await; + let second = backend + .create_response( + &request, + request_context("req_pool_keepalive_second", Some("chatgpt-account")), + ) + .await + .expect("second pooled websocket response should reuse the probed socket"); + server.await.unwrap(); + + assert!(first.body.contains("resp_pool_keepalive_first")); + assert!(second.body.contains("resp_pool_keepalive_second")); + assert_eq!(ping_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn websocket_pool_should_evict_idle_connection_when_ping_times_out() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + completed_websocket_response("resp_no_pong_first", 2, 1).into(), + )) + .await + .unwrap(); + let (second_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + completed_websocket_response("resp_no_pong_second", 2, 1).into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + drop(first_websocket); + }); + let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { + ping_interval: Some(Duration::from_millis(1)), + ping_timeout: Duration::from_millis(20), + maintenance_interval: None, + ..websocket_pool_config_for_tests(None, None, None) + })); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let request = pooled_websocket_request("conversation-no-pong"); + + let first = backend + .create_response( + &request, + request_context("req_no_pong_first", Some("chatgpt-account")), + ) + .await + .expect("first websocket response should succeed"); + pool.maintain_idle_connections().await; + let second = backend + .create_response( + &request, + request_context("req_no_pong_second", Some("chatgpt-account")), + ) + .await + .expect("second websocket response should use a fresh connection"); + server.await.unwrap(); + + assert!(first.body.contains("resp_no_pong_first")); + assert!(second.body.contains("resp_no_pong_second")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn websocket_pool_should_gc_expired_idle_connections() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + completed_websocket_response("resp_gc_first", 2, 1).into(), + )) + .await + .unwrap(); + let close = timeout(Duration::from_secs(1), first_websocket.next()) + .await + .expect("gc sweep should close the expired idle websocket") + .expect("gc sweep should send a close frame") + .expect("close frame should be valid"); + assert!(matches!(close, Message::Close(_))); + + let (second_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + completed_websocket_response("resp_gc_second", 2, 1).into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { + max_age: Duration::from_millis(5), + maintenance_interval: None, + ping_interval: None, + liveness_timeout: None, + ..CodexWebSocketPoolConfig::default() + })); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let request = pooled_websocket_request("conversation-gc"); + + let first = backend + .create_response( + &request, + request_context("req_gc_first", Some("chatgpt-account")), + ) + .await + .expect("first websocket response should succeed"); + tokio::time::sleep(Duration::from_millis(15)).await; + pool.gc_sweep().await; + let second = backend + .create_response( + &request, + request_context("req_gc_second", Some("chatgpt-account")), + ) + .await + .expect("second websocket response should use a fresh connection after gc"); + server.await.unwrap(); + + assert!(first.body.contains("resp_gc_first")); + assert!(second.body.contains("resp_gc_second")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_maintenance() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let ping_count = Arc::new(AtomicUsize::new(0)); + let ping_count_for_server = Arc::clone(&ping_count); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _first_message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_background_first", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + + let ping = timeout(Duration::from_secs(1), websocket.next()) + .await + .expect("background maintenance should probe the idle websocket") + .expect("background maintenance should send a websocket frame") + .expect("background maintenance frame should be valid"); + let Message::Ping(payload) = ping else { + panic!("expected background maintenance ping frame, got {ping:?}"); + }; + ping_count_for_server.fetch_add(1, Ordering::SeqCst); + websocket.send(Message::Pong(payload)).await.unwrap(); + + let _second_message = websocket.next().await.unwrap().unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_background_second", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_config( + websocket_pool_config_for_tests( + Some(Duration::from_millis(20)), + Some(Duration::from_secs(60)), + None, + ), + )); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool-background".to_string()); + + let first = backend + .create_response( + &request, + request_context("req_pool_background_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket response should succeed"); + tokio::time::sleep(Duration::from_millis(80)).await; + let second = backend + .create_response( + &request, + request_context("req_pool_background_second", Some("chatgpt-account")), + ) + .await + .expect("second pooled websocket response should reuse the background-probed socket"); + server.await.unwrap(); + pool.shutdown().await; + + assert!(first.body.contains("resp_pool_background_first")); + assert!(second.body.contains("resp_pool_background_second")); + assert_eq!(ping_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn codex_backend_client_should_close_idle_pooled_websocket_when_account_is_evicted() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_evict_first", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + let close = timeout(Duration::from_secs(1), first_websocket.next()) + .await + .expect("evict_account should close the idle websocket") + .expect("evict_account should send a close frame") + .expect("close frame should be valid"); + assert!(matches!(close, Message::Close(_))); + + let (second_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_evict_second", + "object": "response", + "usage": { + "input_tokens": 4, + "output_tokens": 1, + "total_tokens": 5 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_config( + websocket_pool_config_for_tests(None, None, None), + )); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool-evict".to_string()); + + let first = backend + .create_response( + &request, + request_context("req_pool_evict_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket response should succeed"); + pool.evict_account("chatgpt-account").await; + let second = backend + .create_response( + &request, + request_context("req_pool_evict_second", Some("chatgpt-account")), + ) + .await + .expect("second websocket response should open a fresh socket after eviction"); + server.await.unwrap(); + + assert!(first.body.contains("resp_pool_evict_first")); + assert!(second.body.contains("resp_pool_evict_second")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdown() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_shutdown_first", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + let close = timeout(Duration::from_secs(1), first_websocket.next()) + .await + .expect("shutdown should close the idle websocket") + .expect("shutdown should send a close frame") + .expect("close frame should be valid"); + assert!(matches!(close, Message::Close(_))); + + let (second_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_shutdown_second", + "object": "response", + "usage": { + "input_tokens": 4, + "output_tokens": 1, + "total_tokens": 5 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_config( + websocket_pool_config_for_tests(None, None, None), + )); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool-shutdown".to_string()); + + let first = backend + .create_response( + &request, + request_context("req_pool_shutdown_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket response should succeed"); + pool.shutdown().await; + let second = backend + .create_response( + &request, + request_context("req_pool_shutdown_second", Some("chatgpt-account")), + ) + .await + .expect("second websocket response should bypass the shut down pool"); + server.await.unwrap(); + + assert!(first.body.contains("resp_pool_shutdown_first")); + assert!(second.body.contains("resp_pool_shutdown_second")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_liveness_first", + "object": "response", + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + let close = timeout(Duration::from_secs(1), first_websocket.next()) + .await + .expect("liveness timeout should close the idle websocket") + .expect("liveness timeout should send a close frame") + .expect("close frame should be valid"); + assert!(matches!(close, Message::Close(_))); + + let (second_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_liveness_second", + "object": "response", + "usage": { + "input_tokens": 4, + "output_tokens": 1, + "total_tokens": 5 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + }); + let pool = Arc::new(CodexWebSocketPool::with_config( + websocket_pool_config_for_tests(None, None, Some(Duration::from_millis(1))), + )); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(Arc::clone(&pool)); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool-liveness".to_string()); + + let first = backend + .create_response( + &request, + request_context("req_pool_liveness_first", Some("chatgpt-account")), + ) + .await + .expect("first pooled websocket response should succeed"); + tokio::time::sleep(Duration::from_millis(10)).await; + pool.maintain_idle_connections().await; + let second = backend + .create_response( + &request, + request_context("req_pool_liveness_second", Some("chatgpt-account")), + ) + .await + .expect("second websocket response should open a fresh socket after liveness close"); + server.await.unwrap(); + + assert!(first.body.contains("resp_pool_liveness_first")); + assert!(second.body.contains("resp_pool_liveness_second")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn codex_backend_client_should_discard_pooled_websocket_after_upstream_error() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + json!({ + "type": "response.failed", + "response": { + "id": "resp_pool_rate_limit", + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit reached. Please try again in 1s." + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + first_websocket.close(None).await.unwrap(); + + let (second_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_pool_after_error", + "object": "response", + "usage": { + "input_tokens": 5, + "output_tokens": 2, + "total_tokens": 7 + } + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + }); + let pool = Arc::new( + codex_proxy_adapters::codex::websocket::pool::CodexWebSocketPool::new( + 8, + Duration::from_secs(60), + ), + ); + let backend = client::CodexBackendClient::new( + client::build_reqwest_client(false).unwrap(), + format!("http://{addr}"), + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .with_websocket_pool(pool); + let mut request = + codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "be brief", + Vec::new(), + ); + request.previous_response_id = Some("resp_previous".to_string()); + request.prompt_cache_key = Some("conversation-pool".to_string()); + + let first_error = backend + .create_response( + &request, + request_context("req_pool_error", Some("chatgpt-account")), + ) + .await + .expect_err("first pooled websocket response should surface upstream error"); + let second = backend + .create_response( + &request, + request_context("req_pool_after_error", Some("chatgpt-account")), + ) + .await + .expect("second pooled websocket response should use a fresh connection"); + server.await.unwrap(); + + let client::CodexClientError::Upstream { status, body, .. } = first_error else { + panic!("expected upstream error from first pooled websocket response"); + }; + assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); + assert!(body.contains("rate_limit_exceeded")); + assert!(second.body.contains("resp_pool_after_error")); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} diff --git a/crates/adapters/tests/models.rs b/crates/adapters/tests/models.rs index a50910308..458ea271d 100644 --- a/crates/adapters/tests/models.rs +++ b/crates/adapters/tests/models.rs @@ -1,7 +1,7 @@ use chrono::Utc; use codex_proxy_adapters::sqlite::{accounts::SqliteAccountStore, models::ModelSnapshotRepository}; use codex_proxy_core::accounts::{model::AccountStatus, ports::AccountStore}; -use codex_proxy_core::models::model::{BackendModelEntry, ModelConfig, ModelPlanSnapshot}; +use codex_proxy_core::models::model::{BackendModelEntry, ModelPlanSnapshot}; use codex_proxy_platform::crypto::SecretBox; use secrecy::SecretString; @@ -31,18 +31,6 @@ async fn model_snapshot_repository_should_replace_and_load_plan_snapshots() { assert_eq!(loaded[0].models[0].source, "backend"); } -#[test] -fn model_config_should_be_usable_from_core_models() { - let config = ModelConfig { - default_model: "gpt-5.5".to_string(), - default_reasoning_effort: None, - service_tier: None, - aliases: Default::default(), - }; - - assert_eq!(config.default_model, "gpt-5.5"); -} - #[tokio::test] async fn sqlite_account_store_should_list_pool_accounts() { let dir = tempfile::tempdir().expect("temp dir"); diff --git a/crates/core/src/accounts/cloudflare.rs b/crates/core/src/accounts/cloudflare.rs index 7da32a882..c32a02adf 100644 --- a/crates/core/src/accounts/cloudflare.rs +++ b/crates/core/src/accounts/cloudflare.rs @@ -65,63 +65,3 @@ impl CloudflarePathBlockTracker { self.count(account_id, now).await >= PATH_BLOCK_THRESHOLD } } - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn record_path_block_should_disable_after_three_recent_failures() { - let tracker = CloudflarePathBlockTracker::new(); - let now = Utc::now(); - - assert_eq!(tracker.record_path_block("acct_1", now).await, 1); - assert_eq!( - tracker - .record_path_block("acct_1", now + Duration::seconds(1)) - .await, - 2 - ); - assert_eq!( - tracker - .record_path_block("acct_1", now + Duration::seconds(2)) - .await, - 3 - ); - - assert!( - tracker - .should_disable("acct_1", now + Duration::seconds(2)) - .await - ); - } - - #[tokio::test] - async fn record_path_block_should_ignore_stale_failures() { - let tracker = CloudflarePathBlockTracker::new(); - let now = Utc::now(); - - assert_eq!(tracker.record_path_block("acct_1", now).await, 1); - - assert_eq!( - tracker - .record_path_block("acct_1", now + Duration::hours(2)) - .await, - 1 - ); - } - - #[tokio::test] - async fn reset_should_clear_account_count() { - let tracker = CloudflarePathBlockTracker::new(); - let now = Utc::now(); - tracker.record_path_block("acct_1", now).await; - tracker - .record_path_block("acct_1", now + Duration::seconds(1)) - .await; - - tracker.reset("acct_1").await; - - assert_eq!(tracker.count("acct_1", now + Duration::seconds(2)).await, 0); - } -} diff --git a/crates/core/src/accounts/jwt.rs b/crates/core/src/accounts/jwt.rs index 0307fe7bb..21bb8b670 100644 --- a/crates/core/src/accounts/jwt.rs +++ b/crates/core/src/accounts/jwt.rs @@ -45,47 +45,3 @@ fn jwt_exp(token: &str) -> Option { let value = serde_json::from_slice::(&decoded).ok()?; value.get("exp")?.as_i64() } - -#[cfg(test)] -mod tests { - use base64::Engine; - use chrono::{TimeZone, Utc}; - use serde_json::json; - - use super::{jwt_expiration, jwt_expiry, JwtExpiry}; - - #[test] - fn jwt_expiry_should_detect_expired_valid_and_invalid_tokens() { - let now = Utc.with_ymd_and_hms(2026, 6, 14, 8, 0, 0).unwrap(); - - assert_eq!( - jwt_expiry(&test_jwt(now.timestamp() - 1), now), - JwtExpiry::Expired - ); - assert_eq!( - jwt_expiry(&test_jwt(now.timestamp() + 1), now), - JwtExpiry::Valid - ); - assert_eq!(jwt_expiry("not-a-jwt", now), JwtExpiry::MissingOrInvalid); - } - - #[test] - fn jwt_expiration_should_return_exp_as_utc_datetime() { - let expires_at = Utc.with_ymd_and_hms(2026, 6, 14, 8, 0, 0).unwrap(); - - assert_eq!( - jwt_expiration(&test_jwt(expires_at.timestamp())), - Some(expires_at) - ); - } - - fn test_jwt(exp: i64) -> String { - let header = json!({"alg": "none", "typ": "JWT"}); - let payload = json!({ "exp": exp }); - format!("{}.{}.", jwt_part(&header), jwt_part(&payload)) - } - - fn jwt_part(value: &serde_json::Value) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).unwrap()) - } -} diff --git a/crates/core/src/gateway/conversation.rs b/crates/core/src/gateway/conversation.rs index d051e511c..47b22f3a0 100644 --- a/crates/core/src/gateway/conversation.rs +++ b/crates/core/src/gateway/conversation.rs @@ -166,88 +166,3 @@ fn build_account_scoped_identity(kind: &str, account_scope: &str, client_value: format!("{}_{}", prefix, truncated) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_conversation_identity_full() { - let identity = build_conversation_identity( - Some("client_conv_123"), - Some("client_win_456"), - "entry_789", - ); - - assert!(identity.conversation_id.is_some()); - assert!(identity.window_id.is_some()); - - let conv_id = identity.conversation_id.unwrap(); - assert!(conv_id.starts_with("cp_")); - assert_eq!(conv_id.len(), 3 + 32); - - let win_id = identity.window_id.unwrap(); - assert!(win_id.starts_with("cw_")); - } - - #[test] - fn test_build_conversation_identity_fallback_window() { - let identity = build_conversation_identity(Some("client_conv_123"), None, "entry_789"); - - assert!(identity.conversation_id.is_some()); - assert!(identity.window_id.is_some()); - - let conv_id = identity.conversation_id.as_ref().unwrap(); - let win_id = identity.window_id.as_ref().unwrap(); - - assert_eq!(win_id, &format!("{}:0", conv_id)); - } - - #[test] - fn test_build_conversation_identity_empty() { - let identity = build_conversation_identity(None, None, "entry_789"); - - assert!(identity.conversation_id.is_none()); - assert!(identity.window_id.is_none()); - } - - #[test] - fn test_build_account_scoped_identity_deterministic() { - let id1 = build_account_scoped_identity("conversation", "account_1", "client_key"); - let id2 = build_account_scoped_identity("conversation", "account_1", "client_key"); - - assert_eq!(id1, id2); - assert!(id1.starts_with("cp_")); - } - - #[test] - fn test_build_account_scoped_identity_different_accounts() { - let id1 = build_account_scoped_identity("conversation", "account_1", "client_key"); - let id2 = build_account_scoped_identity("conversation", "account_2", "client_key"); - - assert_ne!(id1, id2); - } - - #[test] - fn test_build_account_scoped_identity_window_prefix() { - let id = build_account_scoped_identity("window", "account_1", "win_123"); - assert!(id.starts_with("cw_")); - } - - #[test] - fn derive_stable_conversation_key_uses_first_user_text_after_system_reminder() { - let request = CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "be brief", - vec![serde_json::json!({ - "role": "user", - "content": "ignore\nhello" - })], - ); - - let key = derive_stable_conversation_key(&request).unwrap(); - - assert_eq!(key.len(), 36); - assert_eq!(key.chars().filter(|ch| *ch == '-').count(), 4); - } -} diff --git a/crates/core/src/gateway/installation.rs b/crates/core/src/gateway/installation.rs index 33eb49eae..903764705 100644 --- a/crates/core/src/gateway/installation.rs +++ b/crates/core/src/gateway/installation.rs @@ -18,33 +18,3 @@ pub fn parse_installation_id(raw: &str) -> Option { Uuid::parse_str(trimmed).ok()?; Some(trimmed.to_string()) } - -#[cfg(test)] -mod tests { - use uuid::Uuid; - - use super::*; - - #[test] - fn parse_installation_id_should_trim_and_accept_uuid() { - let id = Uuid::new_v4().to_string(); - - let parsed = parse_installation_id(&format!(" {id}\n")); - - assert_eq!(parsed, Some(id)); - } - - #[test] - fn parse_installation_id_should_reject_non_uuid() { - let parsed = parse_installation_id("not-a-uuid"); - - assert_eq!(parsed, None); - } - - #[test] - fn generate_installation_id_should_return_uuid() { - let generated = generate_installation_id(); - - assert!(Uuid::parse_str(&generated).is_ok()); - } -} diff --git a/crates/core/src/serving/implicit_resume.rs b/crates/core/src/serving/implicit_resume.rs index d55954d18..28aff2488 100644 --- a/crates/core/src/serving/implicit_resume.rs +++ b/crates/core/src/serving/implicit_resume.rs @@ -108,36 +108,3 @@ fn inline_function_call_ids(input: &[Value]) -> HashSet { .map(ToString::to_string) .collect() } - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn continuation_input_start_should_follow_last_assistant_message() { - let input = vec![ - json!({"role": "user", "content": "first"}), - json!({"role": "assistant", "content": "cached"}), - json!({"role": "user", "content": "continue"}), - ]; - - assert_eq!(continuation_input_start(&input), 2); - } - - #[test] - fn implicit_resume_allowed_should_reject_unmatched_function_call_output() { - let input = vec![json!({ - "type": "function_call_output", - "call_id": "missing", - "output": "tool output" - })]; - - assert!(!implicit_resume_allowed( - &input, - &input, - &["expected".to_string()] - )); - } -} diff --git a/crates/core/src/serving/reasoning_replay.rs b/crates/core/src/serving/reasoning_replay.rs index 88e59f5f5..4ed670e51 100644 --- a/crates/core/src/serving/reasoning_replay.rs +++ b/crates/core/src/serving/reasoning_replay.rs @@ -295,36 +295,3 @@ fn estimate_items_bytes(items: &[Value]) -> usize { fn entry_expired(created_at: DateTime, ttl: Duration, now: DateTime) -> bool { now.signed_duration_since(created_at) >= ttl } - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn reasoning_replay_cache_should_sanitize_reasoning_items() { - let mut cache = ReasoningReplayCache::new(Duration::minutes(55)); - let saved = cache.record( - "resp_1".to_string(), - "acct_1", - "conv_1", - "variant_1", - &[json!({ - "type": "reasoning", - "id": "rs_1", - "status": "completed", - "summary": [{"type": "summary_text", "text": "kept"}], - "encrypted_content": "enc", - "extra": "drop" - })], - Utc::now(), - ); - - let replay = cache.lookup("resp_1", "acct_1", "conv_1", "variant_1", Utc::now()); - - assert_eq!(saved, 1); - assert_eq!(replay[0].get("extra"), None); - assert_eq!(replay[0]["encrypted_content"], "enc"); - } -} diff --git a/crates/core/tests/account_pool.rs b/crates/core/tests/account_pool.rs index 09be14821..217bd2e95 100644 --- a/crates/core/tests/account_pool.rs +++ b/crates/core/tests/account_pool.rs @@ -11,540 +11,12 @@ use codex_proxy_core::accounts::{ }; use serde_json::{json, Value}; -#[test] -fn account_pool_should_respect_max_concurrent_slots_per_account() { - let mut pool = AccountPool::with_options(AccountPoolOptions { - max_concurrent_per_account: 1, - ..AccountPoolOptions::default() - }); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - pool.insert(Account::test("acct_b", AccountStatus::Active)); - - let first = pool.acquire("gpt-5.5").unwrap(); - let second = pool.acquire("gpt-5.5").unwrap(); - let third = pool.acquire("gpt-5.5"); - - assert_ne!(first.id, second.id); - assert!(third.is_none()); - - pool.release(&first.id); - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, first.id); -} - -#[test] -fn account_pool_should_rotate_round_robin_across_candidates() { - let mut pool = AccountPool::with_options(AccountPoolOptions { - max_concurrent_per_account: 1, - rotation_strategy: RotationStrategy::RoundRobin, - ..AccountPoolOptions::default() - }); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - pool.insert(Account::test("acct_b", AccountStatus::Active)); - pool.insert(Account::test("acct_c", AccountStatus::Active)); - - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_a"); - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_c"); - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_b"); - assert!(pool.acquire("gpt-5.5").is_none()); -} - -#[test] -fn account_pool_should_skip_accounts_with_cached_quota_limit() { - let mut limited = Account::test("limited", AccountStatus::Active); - limited.quota_limit_reached = true; - let mut pool = AccountPool::default(); - pool.insert(limited); - pool.insert(Account::test("usable", AccountStatus::Active)); - - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "usable"); -} - -#[test] -fn account_pool_should_reuse_quota_limited_accounts_after_cooldown() { - let now = fixed_time(); - let mut pool = AccountPool::default(); - pool.insert(Account::test("limited", AccountStatus::Active)); - pool.mark_quota_limited_until("limited", now + Duration::seconds(30)); - - assert!(pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .is_none()); - assert_eq!( - pool.acquire_with(AccountAcquireRequest::new( - "gpt-5.5", - now + Duration::seconds(31) - )) - .unwrap() - .account - .id, - "limited" - ); -} - -#[test] -fn account_pool_should_not_shorten_existing_quota_cooldown() { - let now = fixed_time(); - let mut pool = AccountPool::default(); - pool.insert(Account::test("limited", AccountStatus::Active)); - - pool.mark_quota_limited_until("limited", now + Duration::seconds(180)); - pool.mark_quota_limited_until("limited", now + Duration::seconds(60)); - - assert!(pool - .acquire_with(AccountAcquireRequest::new( - "gpt-5.5", - now + Duration::seconds(90) - )) - .is_none()); -} - -#[test] -fn reset_usage_should_clear_runtime_counters_and_preserve_window_reset() { - let now = fixed_time(); - let mut account = Account::test("acct_a", AccountStatus::Active); - account.request_count = 7; - account.empty_response_count = 2; - account.image_input_tokens = 11; - account.image_output_tokens = 13; - account.image_request_count = 3; - account.image_request_failed_count = 1; - account.window_request_count = 5; - account.window_input_tokens = 19; - account.window_output_tokens = 23; - account.window_cached_tokens = 29; - account.window_image_input_tokens = 31; - account.window_image_output_tokens = 37; - account.window_image_request_count = 2; - account.window_image_request_failed_count = 1; - account.window_reset_at = Some(now + Duration::seconds(300)); - account.limit_window_seconds = Some(300); - account.last_used_at = Some("2026-06-12T12:00:00Z".to_string()); - let mut pool = AccountPool::default(); - pool.insert(account); - - assert!(pool.reset_usage("acct_a")); - assert!(!pool.reset_usage("missing")); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap() - .account; - assert_eq!(acquired.request_count, 1); - assert_eq!(acquired.empty_response_count, 0); - assert_eq!(acquired.image_input_tokens, 0); - assert_eq!(acquired.image_output_tokens, 0); - assert_eq!(acquired.image_request_count, 0); - assert_eq!(acquired.image_request_failed_count, 0); - assert_eq!(acquired.window_request_count, 1); - assert_eq!(acquired.window_input_tokens, 0); - assert_eq!(acquired.window_output_tokens, 0); - assert_eq!(acquired.window_cached_tokens, 0); - assert_eq!(acquired.window_image_input_tokens, 0); - assert_eq!(acquired.window_image_output_tokens, 0); - assert_eq!(acquired.window_image_request_count, 0); - assert_eq!(acquired.window_image_request_failed_count, 0); - assert_eq!(acquired.window_reset_at, Some(now + Duration::seconds(300))); - assert_ne!( - acquired.last_used_at.as_deref(), - Some("2026-06-12T12:00:00Z") - ); -} - -#[test] -fn account_pool_should_not_replace_known_window_length_with_cooldown_seconds() { - let now = Utc::now(); - let mut account = Account::test("limited", AccountStatus::Active); - account.limit_window_seconds = Some(300); - let mut pool = AccountPool::default(); - pool.insert(account); - - pool.mark_quota_limited_until("limited", now + Duration::seconds(60)); - let acquired = pool - .acquire_with(AccountAcquireRequest::new( - "gpt-5.5", - now + Duration::seconds(61), - )) - .unwrap(); - - assert_eq!(acquired.account.limit_window_seconds, Some(300)); -} - -#[test] -fn acquire_should_skip_accounts_with_expired_token_metadata() { - let now = fixed_time(); - let mut expired = Account::test("expired", AccountStatus::Active); - expired.access_token_expires_at = Some(now - Duration::seconds(1)); - let mut pool = AccountPool::default(); - pool.insert(expired); - - let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now)); - - assert!(acquired.is_none()); -} - -#[test] -fn acquire_should_skip_accounts_with_expired_jwt_when_metadata_is_missing() { - let now = Utc::now(); - let mut expired = Account::test("expired", AccountStatus::Active); - expired.access_token = test_jwt(-60); - expired.access_token_expires_at = None; - let mut pool = AccountPool::default(); - pool.insert(expired); - - let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now)); - - assert!(acquired.is_none()); -} - -#[test] -fn acquire_should_refresh_expired_cooldowns_before_selecting_account() { - let now = fixed_time(); - let mut account = Account::test("acct_a", AccountStatus::Active); - account.quota_limit_reached = true; - account.quota_cooldown_until = Some(now - Duration::seconds(1)); - account.cloudflare_cooldown_until = Some(now - Duration::seconds(1)); - account.window_request_count = 7; - account.window_reset_at = Some(now - Duration::seconds(1)); - account.limit_window_seconds = Some(60); - let mut pool = AccountPool::default(); - pool.insert(account); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert!(!acquired.account.quota_limit_reached); - assert!(acquired.account.quota_verify_required); - assert!(acquired.account.quota_cooldown_until.is_none()); - assert!(acquired.account.cloudflare_cooldown_until.is_none()); - assert_eq!(acquired.account.window_request_count, 1); - assert!(acquired - .account - .window_reset_at - .is_some_and(|reset| reset > now)); -} - -#[test] -fn account_pool_should_prefer_configured_tier_priority() { - let mut pool = AccountPool::with_options(AccountPoolOptions { - tier_priority: vec!["team".to_string(), "plus".to_string()], - ..AccountPoolOptions::default() - }); - let mut free = Account::test("free", AccountStatus::Active); - free.plan_type = Some("free".to_string()); - let mut team = Account::test("team", AccountStatus::Active); - team.plan_type = Some("team".to_string()); - pool.insert(free); - pool.insert(team); - - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "team"); -} - -#[test] -fn account_pool_should_filter_by_model_plan_allowlist() { - let mut model_plans = BTreeMap::new(); - model_plans.insert("gpt-5.5".to_string(), vec!["plus".to_string()]); - let mut pool = AccountPool::with_options(AccountPoolOptions { - model_plan_allowlist: model_plans, - ..AccountPoolOptions::default() - }); - let mut free = Account::test("free", AccountStatus::Active); - free.plan_type = Some("free".to_string()); - let mut plus = Account::test("plus", AccountStatus::Active); - plus.plan_type = Some("plus".to_string()); - pool.insert(free); - pool.insert(plus); - - assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "plus"); -} - -#[test] -fn account_pool_should_exclude_requested_account_ids() { - let mut pool = AccountPool::default(); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - pool.insert(Account::test("acct_b", AccountStatus::Active)); - - let acquired = pool - .acquire_with( - AccountAcquireRequest::new("gpt-5.5", fixed_time()) - .with_exclude_account_ids(["acct_a"]), - ) - .unwrap(); - - assert_eq!(acquired.account.id, "acct_b"); -} - -#[test] -fn account_pool_should_prefer_session_affinity_account_when_available() { - let mut pool = AccountPool::default(); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - pool.insert(Account::test("acct_b", AccountStatus::Active)); - - let acquired = pool - .acquire_with( - AccountAcquireRequest::new("gpt-5.5", fixed_time()).with_preferred_account_id("acct_b"), - ) - .unwrap(); - - assert_eq!(acquired.account.id, "acct_b"); -} - -#[test] -fn account_pool_should_skip_accounts_in_cloudflare_cooldown() { - let now = fixed_time(); - let mut cooling = Account::test("cooling", AccountStatus::Active); - cooling.cloudflare_cooldown_until = Some(now + Duration::seconds(30)); - let mut pool = AccountPool::default(); - pool.insert(cooling); - pool.insert(Account::test("usable", AccountStatus::Active)); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.id, "usable"); -} - -#[test] -fn account_pool_should_cleanup_stale_slots_before_acquire() { - let now = fixed_time(); - let mut pool = AccountPool::with_options(AccountPoolOptions { - max_concurrent_per_account: 1, - stale_slot_ttl: Duration::minutes(5), - ..AccountPoolOptions::default() - }); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - - assert!(pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .is_some()); - let acquired = pool - .acquire_with(AccountAcquireRequest::new( - "gpt-5.5", - now + Duration::minutes(5) + Duration::seconds(1), - )) - .unwrap(); - - assert_eq!(acquired.account.id, "acct_a"); -} - -#[test] -fn account_pool_should_return_previous_slot_time_for_request_staggering() { - let now = fixed_time(); - let mut pool = AccountPool::with_options(AccountPoolOptions { - max_concurrent_per_account: 2, - ..AccountPoolOptions::default() - }); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - - let first = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - let second = pool - .acquire_with(AccountAcquireRequest::new( - "gpt-5.5", - now + Duration::milliseconds(250), - )) - .unwrap(); - - assert!(first.previous_slot_at.is_none()); - assert_eq!(second.previous_slot_at, Some(now)); -} - -#[test] -fn account_pool_should_report_capacity_summary() { - let now = fixed_time(); - let mut pool = AccountPool::with_options(AccountPoolOptions { - max_concurrent_per_account: 2, - ..AccountPoolOptions::default() - }); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - pool.insert(Account::test("acct_b", AccountStatus::Disabled)); - - pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now)); - let summary = pool.capacity_summary(now); - - assert_eq!(summary.total_slots, 2); - assert_eq!(summary.used_slots, 1); - assert_eq!(summary.available_slots, 1); -} - -#[test] -fn account_pool_should_rotate_tied_least_used_accounts() { - let mut pool = AccountPool::with_options(AccountPoolOptions { - max_concurrent_per_account: 1, - rotation_strategy: RotationStrategy::LeastUsed, - ..AccountPoolOptions::default() - }); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - pool.insert(Account::test("acct_b", AccountStatus::Active)); - - let first = pool.acquire("gpt-5.5").unwrap(); - pool.release(&first.id); - let second = pool.acquire("gpt-5.5").unwrap(); - - assert_ne!(first.id, second.id); -} - -#[test] -fn least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() { - let mut pool = AccountPool::with_options(AccountPoolOptions { - skip_quota_limited: false, - rotation_strategy: RotationStrategy::LeastUsed, - ..AccountPoolOptions::default() - }); - let mut limited = Account::test("limited", AccountStatus::Active); - limited.quota_limit_reached = true; - let mut usable = Account::test("usable", AccountStatus::Active); - usable.request_count = 100; - pool.insert(limited); - pool.insert(usable); - - let acquired = pool.acquire("gpt-5.5").unwrap(); - - assert_eq!(acquired.id, "usable"); -} - -#[test] -fn least_used_should_prefer_earlier_rate_limit_window_reset() { - let now = fixed_time(); - let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, - ..AccountPoolOptions::default() - }); - let mut soon = Account::test("soon", AccountStatus::Active); - soon.window_reset_at = Some(now + Duration::seconds(30)); - let mut later = Account::test("later", AccountStatus::Active); - later.window_reset_at = Some(now + Duration::seconds(300)); - pool.insert(later); - pool.insert(soon); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.id, "soon"); -} - -#[test] -fn least_used_should_compare_request_count_when_only_one_account_has_window_reset() { - let now = fixed_time(); - let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, - ..AccountPoolOptions::default() - }); - let mut reset_known = Account::test("reset_known", AccountStatus::Active); - reset_known.window_reset_at = Some(now + Duration::seconds(30)); - reset_known.request_count = 10; - let mut reset_unknown = Account::test("reset_unknown", AccountStatus::Active); - reset_unknown.request_count = 2; - pool.insert(reset_known); - pool.insert(reset_unknown); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.id, "reset_unknown"); -} - -#[test] -fn least_used_should_prefer_lower_runtime_request_count() { - let now = fixed_time(); - let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, - ..AccountPoolOptions::default() - }); - let mut busy = Account::test("busy", AccountStatus::Active); - busy.request_count = 10; - let mut quiet = Account::test("quiet", AccountStatus::Active); - quiet.request_count = 2; - pool.insert(busy); - pool.insert(quiet); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.id, "quiet"); -} - -#[test] -fn acquire_should_mark_runtime_usage_window() { - let now = fixed_time(); - let mut pool = AccountPool::default(); - let mut account = Account::test("acct_a", AccountStatus::Active); - account.limit_window_seconds = Some(60); - pool.insert(account); - - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.request_count, 1); - assert_eq!(acquired.account.window_request_count, 1); - assert_eq!(acquired.account.window_started_at, Some(now)); - assert_eq!( - acquired.account.window_reset_at, - Some(now + Duration::seconds(60)) - ); - assert_eq!( - acquired.account.last_used_at.as_deref(), - Some(now.to_rfc3339().as_str()) - ); -} - -#[test] -fn sync_rate_limit_window_should_reset_window_counters_when_drift_crosses_threshold() { - let now = fixed_time(); - let mut account = Account::test("acct_a", AccountStatus::Active); - account.window_request_count = 5; - account.window_input_tokens = 100; - account.window_output_tokens = 40; - account.window_cached_tokens = 20; - account.window_reset_at = Some(now + Duration::seconds(60)); - account.limit_window_seconds = Some(60); - let mut pool = AccountPool::default(); - pool.insert(account); - - pool.sync_rate_limit_window("acct_a", now + Duration::seconds(121), Some(60)); - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.window_request_count, 1); - assert_eq!(acquired.account.window_input_tokens, 0); - assert_eq!(acquired.account.window_output_tokens, 0); - assert_eq!(acquired.account.window_cached_tokens, 0); - assert_eq!( - acquired.account.window_reset_at, - Some(now + Duration::seconds(121)) - ); -} - -#[test] -fn record_window_token_usage_should_accumulate_runtime_window_tokens() { - let now = fixed_time(); - let mut pool = AccountPool::default(); - pool.insert(Account::test("acct_a", AccountStatus::Active)); - - pool.record_window_token_usage( - "acct_a", - AccountWindowUsageDelta { - input_tokens: 7, - output_tokens: 4, - cached_tokens: 2, - ..AccountWindowUsageDelta::default() - }, - ); - let acquired = pool - .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) - .unwrap(); - - assert_eq!(acquired.account.window_input_tokens, 7); - assert_eq!(acquired.account.window_output_tokens, 4); - assert_eq!(acquired.account.window_cached_tokens, 2); -} +#[path = "account_pool/quota.rs"] +mod quota; +#[path = "account_pool/selection.rs"] +mod selection; +#[path = "account_pool/usage_window.rs"] +mod usage_window; fn fixed_time() -> chrono::DateTime { Utc.with_ymd_and_hms(2026, 6, 11, 8, 0, 0).unwrap() diff --git a/crates/core/tests/account_pool/quota.rs b/crates/core/tests/account_pool/quota.rs new file mode 100644 index 000000000..56a32931a --- /dev/null +++ b/crates/core/tests/account_pool/quota.rs @@ -0,0 +1,182 @@ +use super::*; + +#[test] +fn account_pool_should_skip_accounts_with_cached_quota_limit() { + let mut limited = Account::test("limited", AccountStatus::Active); + limited.quota_limit_reached = true; + let mut pool = AccountPool::default(); + pool.insert(limited); + pool.insert(Account::test("usable", AccountStatus::Active)); + + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "usable"); +} + +#[test] +fn account_pool_should_reuse_quota_limited_accounts_after_cooldown() { + let now = fixed_time(); + let mut pool = AccountPool::default(); + pool.insert(Account::test("limited", AccountStatus::Active)); + pool.mark_quota_limited_until("limited", now + Duration::seconds(30)); + + assert!(pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .is_none()); + assert_eq!( + pool.acquire_with(AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::seconds(31) + )) + .unwrap() + .account + .id, + "limited" + ); +} + +#[test] +fn account_pool_should_not_shorten_existing_quota_cooldown() { + let now = fixed_time(); + let mut pool = AccountPool::default(); + pool.insert(Account::test("limited", AccountStatus::Active)); + + pool.mark_quota_limited_until("limited", now + Duration::seconds(180)); + pool.mark_quota_limited_until("limited", now + Duration::seconds(60)); + + assert!(pool + .acquire_with(AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::seconds(90) + )) + .is_none()); +} + +#[test] +fn account_pool_should_not_replace_known_window_length_with_cooldown_seconds() { + let now = Utc::now(); + let mut account = Account::test("limited", AccountStatus::Active); + account.limit_window_seconds = Some(300); + let mut pool = AccountPool::default(); + pool.insert(account); + + pool.mark_quota_limited_until("limited", now + Duration::seconds(60)); + let acquired = pool + .acquire_with(AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::seconds(61), + )) + .unwrap(); + + assert_eq!(acquired.account.limit_window_seconds, Some(300)); +} + +#[test] +fn acquire_should_refresh_expired_cooldowns_before_selecting_account() { + let now = fixed_time(); + let mut account = Account::test("acct_a", AccountStatus::Active); + account.quota_limit_reached = true; + account.quota_cooldown_until = Some(now - Duration::seconds(1)); + account.cloudflare_cooldown_until = Some(now - Duration::seconds(1)); + account.window_request_count = 7; + account.window_reset_at = Some(now - Duration::seconds(1)); + account.limit_window_seconds = Some(60); + let mut pool = AccountPool::default(); + pool.insert(account); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert!(!acquired.account.quota_limit_reached); + assert!(acquired.account.quota_verify_required); + assert!(acquired.account.quota_cooldown_until.is_none()); + assert!(acquired.account.cloudflare_cooldown_until.is_none()); + assert_eq!(acquired.account.window_request_count, 1); + assert!(acquired + .account + .window_reset_at + .is_some_and(|reset| reset > now)); +} + +#[test] +fn account_pool_should_skip_accounts_in_cloudflare_cooldown() { + let now = fixed_time(); + let mut cooling = Account::test("cooling", AccountStatus::Active); + cooling.cloudflare_cooldown_until = Some(now + Duration::seconds(30)); + let mut pool = AccountPool::default(); + pool.insert(cooling); + pool.insert(Account::test("usable", AccountStatus::Active)); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "usable"); +} + +#[test] +fn least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() { + let mut pool = AccountPool::with_options(AccountPoolOptions { + skip_quota_limited: false, + rotation_strategy: RotationStrategy::LeastUsed, + ..AccountPoolOptions::default() + }); + let mut limited = Account::test("limited", AccountStatus::Active); + limited.quota_limit_reached = true; + let mut usable = Account::test("usable", AccountStatus::Active); + usable.request_count = 100; + pool.insert(limited); + pool.insert(usable); + + let acquired = pool.acquire("gpt-5.5").unwrap(); + + assert_eq!(acquired.id, "usable"); +} + +#[test] +fn least_used_should_prefer_earlier_rate_limit_window_reset() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::LeastUsed, + ..AccountPoolOptions::default() + }); + let mut soon = Account::test("soon", AccountStatus::Active); + soon.window_reset_at = Some(now + Duration::seconds(30)); + let mut later = Account::test("later", AccountStatus::Active); + later.window_reset_at = Some(now + Duration::seconds(300)); + pool.insert(later); + pool.insert(soon); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "soon"); +} + +#[test] +fn sync_rate_limit_window_should_reset_window_counters_when_drift_crosses_threshold() { + let now = fixed_time(); + let mut account = Account::test("acct_a", AccountStatus::Active); + account.window_request_count = 5; + account.window_input_tokens = 100; + account.window_output_tokens = 40; + account.window_cached_tokens = 20; + account.window_reset_at = Some(now + Duration::seconds(60)); + account.limit_window_seconds = Some(60); + let mut pool = AccountPool::default(); + pool.insert(account); + + pool.sync_rate_limit_window("acct_a", now + Duration::seconds(121), Some(60)); + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.window_request_count, 1); + assert_eq!(acquired.account.window_input_tokens, 0); + assert_eq!(acquired.account.window_output_tokens, 0); + assert_eq!(acquired.account.window_cached_tokens, 0); + assert_eq!( + acquired.account.window_reset_at, + Some(now + Duration::seconds(121)) + ); +} diff --git a/crates/core/tests/account_pool/selection.rs b/crates/core/tests/account_pool/selection.rs new file mode 100644 index 000000000..d3886c4b5 --- /dev/null +++ b/crates/core/tests/account_pool/selection.rs @@ -0,0 +1,191 @@ +use super::*; + +#[test] +fn account_pool_should_respect_max_concurrent_slots_per_account() { + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 1, + ..AccountPoolOptions::default() + }); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + pool.insert(Account::test("acct_b", AccountStatus::Active)); + + let first = pool.acquire("gpt-5.5").unwrap(); + let second = pool.acquire("gpt-5.5").unwrap(); + let third = pool.acquire("gpt-5.5"); + + assert_ne!(first.id, second.id); + assert!(third.is_none()); + + pool.release(&first.id); + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, first.id); +} + +#[test] +fn account_pool_should_rotate_round_robin_across_candidates() { + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 1, + rotation_strategy: RotationStrategy::RoundRobin, + ..AccountPoolOptions::default() + }); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + pool.insert(Account::test("acct_b", AccountStatus::Active)); + pool.insert(Account::test("acct_c", AccountStatus::Active)); + + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_a"); + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_c"); + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_b"); + assert!(pool.acquire("gpt-5.5").is_none()); +} + +#[test] +fn acquire_should_skip_accounts_with_expired_token_metadata() { + let now = fixed_time(); + let mut expired = Account::test("expired", AccountStatus::Active); + expired.access_token_expires_at = Some(now - Duration::seconds(1)); + let mut pool = AccountPool::default(); + pool.insert(expired); + + let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now)); + + assert!(acquired.is_none()); +} + +#[test] +fn acquire_should_skip_accounts_with_expired_jwt_when_metadata_is_missing() { + let now = Utc::now(); + let mut expired = Account::test("expired", AccountStatus::Active); + expired.access_token = test_jwt(-60); + expired.access_token_expires_at = None; + let mut pool = AccountPool::default(); + pool.insert(expired); + + let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now)); + + assert!(acquired.is_none()); +} + +#[test] +fn account_pool_should_prefer_configured_tier_priority() { + let mut pool = AccountPool::with_options(AccountPoolOptions { + tier_priority: vec!["team".to_string(), "plus".to_string()], + ..AccountPoolOptions::default() + }); + let mut free = Account::test("free", AccountStatus::Active); + free.plan_type = Some("free".to_string()); + let mut team = Account::test("team", AccountStatus::Active); + team.plan_type = Some("team".to_string()); + pool.insert(free); + pool.insert(team); + + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "team"); +} + +#[test] +fn account_pool_should_filter_by_model_plan_allowlist() { + let mut model_plans = BTreeMap::new(); + model_plans.insert("gpt-5.5".to_string(), vec!["plus".to_string()]); + let mut pool = AccountPool::with_options(AccountPoolOptions { + model_plan_allowlist: model_plans, + ..AccountPoolOptions::default() + }); + let mut free = Account::test("free", AccountStatus::Active); + free.plan_type = Some("free".to_string()); + let mut plus = Account::test("plus", AccountStatus::Active); + plus.plan_type = Some("plus".to_string()); + pool.insert(free); + pool.insert(plus); + + assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "plus"); +} + +#[test] +fn account_pool_should_exclude_requested_account_ids() { + let mut pool = AccountPool::default(); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + pool.insert(Account::test("acct_b", AccountStatus::Active)); + + let acquired = pool + .acquire_with( + AccountAcquireRequest::new("gpt-5.5", fixed_time()) + .with_exclude_account_ids(["acct_a"]), + ) + .unwrap(); + + assert_eq!(acquired.account.id, "acct_b"); +} + +#[test] +fn account_pool_should_prefer_session_affinity_account_when_available() { + let mut pool = AccountPool::default(); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + pool.insert(Account::test("acct_b", AccountStatus::Active)); + + let acquired = pool + .acquire_with( + AccountAcquireRequest::new("gpt-5.5", fixed_time()).with_preferred_account_id("acct_b"), + ) + .unwrap(); + + assert_eq!(acquired.account.id, "acct_b"); +} + +#[test] +fn account_pool_should_cleanup_stale_slots_before_acquire() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 1, + stale_slot_ttl: Duration::minutes(5), + ..AccountPoolOptions::default() + }); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + + assert!(pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .is_some()); + let acquired = pool + .acquire_with(AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::minutes(5) + Duration::seconds(1), + )) + .unwrap(); + + assert_eq!(acquired.account.id, "acct_a"); +} + +#[test] +fn account_pool_should_rotate_tied_least_used_accounts() { + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 1, + rotation_strategy: RotationStrategy::LeastUsed, + ..AccountPoolOptions::default() + }); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + pool.insert(Account::test("acct_b", AccountStatus::Active)); + + let first = pool.acquire("gpt-5.5").unwrap(); + pool.release(&first.id); + let second = pool.acquire("gpt-5.5").unwrap(); + + assert_ne!(first.id, second.id); +} + +#[test] +fn least_used_should_prefer_lower_runtime_request_count() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::LeastUsed, + ..AccountPoolOptions::default() + }); + let mut busy = Account::test("busy", AccountStatus::Active); + busy.request_count = 10; + let mut quiet = Account::test("quiet", AccountStatus::Active); + quiet.request_count = 2; + pool.insert(busy); + pool.insert(quiet); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "quiet"); +} diff --git a/crates/core/tests/account_pool/usage_window.rs b/crates/core/tests/account_pool/usage_window.rs new file mode 100644 index 000000000..b8fd75c37 --- /dev/null +++ b/crates/core/tests/account_pool/usage_window.rs @@ -0,0 +1,165 @@ +use super::*; + +#[test] +fn reset_usage_should_clear_runtime_counters_and_preserve_window_reset() { + let now = fixed_time(); + let mut account = Account::test("acct_a", AccountStatus::Active); + account.request_count = 7; + account.empty_response_count = 2; + account.image_input_tokens = 11; + account.image_output_tokens = 13; + account.image_request_count = 3; + account.image_request_failed_count = 1; + account.window_request_count = 5; + account.window_input_tokens = 19; + account.window_output_tokens = 23; + account.window_cached_tokens = 29; + account.window_image_input_tokens = 31; + account.window_image_output_tokens = 37; + account.window_image_request_count = 2; + account.window_image_request_failed_count = 1; + account.window_reset_at = Some(now + Duration::seconds(300)); + account.limit_window_seconds = Some(300); + account.last_used_at = Some("2026-06-12T12:00:00Z".to_string()); + let mut pool = AccountPool::default(); + pool.insert(account); + + assert!(pool.reset_usage("acct_a")); + assert!(!pool.reset_usage("missing")); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap() + .account; + assert_eq!(acquired.request_count, 1); + assert_eq!(acquired.empty_response_count, 0); + assert_eq!(acquired.image_input_tokens, 0); + assert_eq!(acquired.image_output_tokens, 0); + assert_eq!(acquired.image_request_count, 0); + assert_eq!(acquired.image_request_failed_count, 0); + assert_eq!(acquired.window_request_count, 1); + assert_eq!(acquired.window_input_tokens, 0); + assert_eq!(acquired.window_output_tokens, 0); + assert_eq!(acquired.window_cached_tokens, 0); + assert_eq!(acquired.window_image_input_tokens, 0); + assert_eq!(acquired.window_image_output_tokens, 0); + assert_eq!(acquired.window_image_request_count, 0); + assert_eq!(acquired.window_image_request_failed_count, 0); + assert_eq!(acquired.window_reset_at, Some(now + Duration::seconds(300))); + assert_ne!( + acquired.last_used_at.as_deref(), + Some("2026-06-12T12:00:00Z") + ); +} + +#[test] +fn account_pool_should_return_previous_slot_time_for_request_staggering() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 2, + ..AccountPoolOptions::default() + }); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + + let first = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + let second = pool + .acquire_with(AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::milliseconds(250), + )) + .unwrap(); + + assert!(first.previous_slot_at.is_none()); + assert_eq!(second.previous_slot_at, Some(now)); +} + +#[test] +fn account_pool_should_report_capacity_summary() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 2, + ..AccountPoolOptions::default() + }); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + pool.insert(Account::test("acct_b", AccountStatus::Disabled)); + + pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now)); + let summary = pool.capacity_summary(now); + + assert_eq!(summary.total_slots, 2); + assert_eq!(summary.used_slots, 1); + assert_eq!(summary.available_slots, 1); +} + +#[test] +fn least_used_should_compare_request_count_when_only_one_account_has_window_reset() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::LeastUsed, + ..AccountPoolOptions::default() + }); + let mut reset_known = Account::test("reset_known", AccountStatus::Active); + reset_known.window_reset_at = Some(now + Duration::seconds(30)); + reset_known.request_count = 10; + let mut reset_unknown = Account::test("reset_unknown", AccountStatus::Active); + reset_unknown.request_count = 2; + pool.insert(reset_known); + pool.insert(reset_unknown); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "reset_unknown"); +} + +#[test] +fn acquire_should_mark_runtime_usage_window() { + let now = fixed_time(); + let mut pool = AccountPool::default(); + let mut account = Account::test("acct_a", AccountStatus::Active); + account.limit_window_seconds = Some(60); + pool.insert(account); + + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.request_count, 1); + assert_eq!(acquired.account.window_request_count, 1); + assert_eq!(acquired.account.window_started_at, Some(now)); + assert_eq!( + acquired.account.window_reset_at, + Some(now + Duration::seconds(60)) + ); + assert_eq!( + acquired.account.last_used_at.as_deref(), + Some(now.to_rfc3339().as_str()) + ); +} + +#[test] +fn record_window_token_usage_should_accumulate_runtime_window_tokens() { + let now = fixed_time(); + let mut pool = AccountPool::default(); + pool.insert(Account::test("acct_a", AccountStatus::Active)); + + pool.record_window_token_usage( + "acct_a", + AccountWindowUsageDelta { + input_tokens: 7, + output_tokens: 4, + cached_tokens: 2, + ..AccountWindowUsageDelta::default() + }, + ); + let acquired = pool + .acquire_with(AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.window_input_tokens, 7); + assert_eq!(acquired.account.window_output_tokens, 4); + assert_eq!(acquired.account.window_cached_tokens, 2); +} diff --git a/crates/core/tests/protocol.rs b/crates/core/tests/protocol.rs index cdf16d088..b69949496 100644 --- a/crates/core/tests/protocol.rs +++ b/crates/core/tests/protocol.rs @@ -45,1723 +45,14 @@ use codex_proxy_core::serving::responses::{ }; use serde_json::json; -#[test] -fn codex_responses_request_should_enable_http_sse_defaults() { - let request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); - - assert!(request.stream); -} - -#[test] -fn codex_responses_transport_should_prefer_websocket_without_history() { - let request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); - - assert_eq!( - transport_for_request(&request), - CodexTransport::WebSocketPreferred - ); - assert!(http_sse_fallback_allowed(&request)); -} - -#[test] -fn codex_responses_transport_should_require_websocket_for_previous_response_id() { - let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); - request.previous_response_id = Some("resp_previous".to_string()); - - assert_eq!( - transport_for_request(&request), - CodexTransport::WebSocketRequired - ); - assert!(!http_sse_fallback_allowed(&request)); -} - -#[test] -fn codex_responses_transport_should_allow_forced_http_sse() { - let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); - request.previous_response_id = Some("resp_previous".to_string()); - request.use_websocket = true; - request.force_http_sse = true; - - assert_eq!(transport_for_request(&request), CodexTransport::HttpSse); - assert!(http_sse_fallback_allowed(&request)); -} - -#[test] -fn codex_responses_transport_flags_should_not_serialize_to_upstream_json() { - let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); - request.use_websocket = true; - request.force_http_sse = true; - - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("use_websocket").is_none()); - assert!(body.get("useWebSocket").is_none()); - assert!(body.get("force_http_sse").is_none()); - assert!(body.get("forceHttpSse").is_none()); -} - -#[test] -fn codex_websocket_payload_audit_snapshot_should_redact_user_content() { - let mut request = CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "private instructions", - vec![json!({ - "role": "user", - "content": "private prompt", - })], - ); - request.previous_response_id = Some("resp_secret".to_string()); - request.reasoning = Some(json!({"effort": "medium"})); - request.tools = Some(vec![json!({ - "type": "function", - "name": "private_tool", - "description": "private schema", - })]); - request.text = Some(json!({"format": {"type": "text"}})); - request.service_tier = Some("flex".to_string()); - request.prompt_cache_key = Some("cache-secret".to_string()); - request.generate = Some(false); - request.include = Some(vec!["reasoning.encrypted_content".to_string()]); - request.client_metadata = Some(json!({ - "thread_id": "thread-secret", - "safe": "value", - })); - - let snapshot = websocket_payload_audit_snapshot(&request); - - assert_eq!( - snapshot.top_level_keys, - vec![ - "type", - "model", - "instructions", - "previous_response_id", - "input", - "tools", - "tool_choice", - "parallel_tool_calls", - "reasoning", - "store", - "stream", - "include", - "service_tier", - "prompt_cache_key", - "text", - "generate", - "client_metadata", - ] - ); - assert_eq!(snapshot.body["type"], "response.create"); - assert_eq!(snapshot.body["model"], "gpt-5.5"); - assert_eq!(snapshot.body["stream"], true); - assert_eq!(snapshot.body["instructions"], ""); - assert_eq!(snapshot.body["input"], ""); - assert_eq!(snapshot.body["previous_response_id"], ""); - assert_eq!(snapshot.body["prompt_cache_key"], ""); - assert_eq!(snapshot.body["client_metadata"], ""); - assert_eq!(snapshot.body["tools"], ""); -} - -#[test] -fn codex_websocket_response_create_payload_text_should_preserve_old_field_order() { - let mut request = CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "private capture instructions", - vec![json!({ - "role": "user", - "content": "private capture prompt", - })], - ); - request.prompt_cache_key = Some("session-1".to_string()); - request.generate = Some(false); - request.client_metadata = Some(json!({ - "thread_id": "capture-thread-secret", - "safe": "capture", - })); - - let payload = - codex_proxy_core::protocol::codex::websocket::websocket_response_create_payload_text( - &request, - ) - .expect("payload should serialize"); - - assert_substrings_appear_in_order( - &payload, - &[ - "\"type\":\"response.create\"", - "\"model\":\"gpt-5.5\"", - "\"instructions\":\"private capture instructions\"", - "\"input\":", - "\"tools\":[]", - "\"tool_choice\":\"auto\"", - "\"parallel_tool_calls\":true", - "\"reasoning\":null", - "\"store\":false", - "\"stream\":true", - "\"include\":[]", - "\"prompt_cache_key\":\"session-1\"", - "\"generate\":false", - "\"client_metadata\":", - ], - ); -} - -#[test] -fn codex_websocket_audit_artifact_should_record_transport_opening_and_payload() { - let mut request = CodexResponsesRequest::new_http_sse( - "gpt-5.5", - "private instructions", - vec![json!({ - "role": "user", - "content": "private prompt", - })], - ); - request.previous_response_id = Some("resp_secret".to_string()); - let opening = OpeningAuditSnapshot { - request_line: "GET /backend-api/codex/responses HTTP/1.1".to_string(), - header_order: vec!["Host".to_string(), "authorization".to_string()], - headers: vec![ - OpeningAuditHeader { - name: "Host".to_string(), - value: "chatgpt.com".to_string(), - }, - OpeningAuditHeader { - name: "authorization".to_string(), - value: "".to_string(), - }, - ], - }; - let payload = websocket_payload_audit_snapshot(&request); - - let artifact = websocket_audit_artifact_from_attempt(&request, opening.clone(), payload); - - assert_eq!(artifact.transport_mode, "websocket_required"); - assert!(!artifact.fallback_allowed); - assert_eq!(artifact.opening, Some(opening)); - assert_eq!( - artifact.payload.expect("payload").body["input"], - "" - ); - assert_eq!(artifact.error, None); -} - -#[test] -fn codex_websocket_parity_diff_should_report_header_payload_and_error_differences() { - let current = codex_proxy_core::protocol::codex::websocket::WebSocketAuditArtifact { - transport_mode: "websocket_preferred".to_string(), - fallback_allowed: true, - opening: Some(OpeningAuditSnapshot { - request_line: "GET /codex/responses HTTP/1.1".to_string(), - header_order: vec!["Host".to_string(), "Connection".to_string()], - headers: vec![OpeningAuditHeader { - name: "Sec-WebSocket-Extensions".to_string(), - value: "permessage-deflate; client_max_window_bits".to_string(), - }], - }), - payload: Some(PayloadAuditSnapshot { - top_level_keys: vec!["type".to_string(), "model".to_string()], - body: json!({"type": "response.create"}), - }), - error: Some(WebSocketAuditErrorSnapshot { - classification: "opening_failed".to_string(), - message: "connection refused".to_string(), - }), - }; - let reference = codex_proxy_core::protocol::codex::websocket::WebSocketAuditArtifact { - transport_mode: "websocket_required".to_string(), - fallback_allowed: false, - opening: Some(OpeningAuditSnapshot { - request_line: "GET /codex/responses?source=audit HTTP/1.1".to_string(), - header_order: vec!["Host".to_string(), "Authorization".to_string()], - headers: vec![OpeningAuditHeader { - name: "Sec-WebSocket-Extensions".to_string(), - value: "identity".to_string(), - }], - }), - payload: Some(PayloadAuditSnapshot { - top_level_keys: vec!["type".to_string(), "previous_response_id".to_string()], - body: json!({"type": "response.create"}), - }), - error: Some(WebSocketAuditErrorSnapshot { - classification: "timeout".to_string(), - message: "timed out".to_string(), - }), - }; - - let diff = websocket_parity_diff(¤t, &reference); - - assert!(diff.differences.iter().any(|difference| { - difference.path == "opening.header_order" - && difference.current == json!(["Host", "Connection"]) - && difference.reference == json!(["Host", "Authorization"]) - })); - assert!(diff - .differences - .iter() - .any(|difference| difference.path == "payload.top_level_keys")); - assert!(diff - .differences - .iter() - .any(|difference| difference.path == "error.classification")); -} - -#[test] -fn codex_websocket_event_to_sse_frame_should_encode_public_events_only() { - let event = json!({ - "type": "response.completed", - "response": { - "id": "resp_ws", - "object": "response", - "usage": { - "input_tokens": 4, - "output_tokens": 2, - "total_tokens": 6 - } - } - }) - .to_string(); - - let frame = websocket_event_to_sse_frame(&event).expect("public event should encode"); - - assert_eq!( - frame, - format!("event: response.completed\ndata: {event}\n\n") - ); - assert!(websocket_event_to_sse_frame(r#"{"type":"codex.rate_limits"}"#).is_none()); - assert!(websocket_event_to_sse_frame(r#"{"response":{}}"#).is_none()); - assert!(websocket_event_to_sse_frame("not-json").is_none()); -} - -#[test] -fn codex_websocket_metadata_turn_state_should_extract_case_insensitive_header() { - let event = json!({ - "type": "response.metadata", - "headers": { - "X-Codex-Turn-State": ["turn-from-metadata"] - } - }) - .to_string(); - - assert_eq!( - websocket_metadata_turn_state(&event).as_deref(), - Some("turn-from-metadata") - ); -} - -#[test] -fn codex_websocket_terminal_event_should_match_completed_failed_and_error() { - assert!(is_terminal_websocket_event("response.completed")); - assert!(is_terminal_websocket_event("response.failed")); - assert!(is_terminal_websocket_event("error")); - assert!(!is_terminal_websocket_event("response.output_text.delta")); -} - -#[test] -fn codex_websocket_error_frame_should_classify_rotatable_codes() { - let frame = json!({ - "type": "response.failed", - "response": { - "error": { - "code": "usage_limit_reached", - "message": "Rate limit reached. Please try again in 11.054s." - } - } - }) - .to_string(); - - let error = - classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot) - .expect("rate limit frame should classify"); - - assert_eq!(error.status_code, 429); -} - -#[test] -fn codex_websocket_error_frame_should_classify_old_gateway_special_codes() { - let cases = [ - ("quota_exhausted", 402), - ("token_expired", 401), - ("account_banned", 403), - ("previous_response_not_found", 400), - ("server_is_overloaded", 503), - ("usage_not_included", 429), - ]; - - for (code, expected_status) in cases { - let frame = json!({ - "type": "response.failed", - "response": { - "error": { - "code": code, - "message": "classified by old gateway parity" - } - } - }) - .to_string(); - - let error = - classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot) - .expect("special gateway code should classify"); - - assert_eq!(error.status_code, expected_status, "code: {code}"); - } -} - -#[test] -fn codex_websocket_error_frame_should_honor_explicit_error_status() { - let frame = json!({ - "type": "error", - "status_code": 403, - "error": { - "code": "forbidden" - } - }) - .to_string(); - - let error = - classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot) - .expect("explicit status should classify"); - - assert_eq!(error.status_code, 403); -} - -#[test] -fn codex_websocket_error_frame_should_ignore_success_status_and_unmapped_error() { - let success_status = json!({ - "type": "error", - "status": 200, - "error": { - "code": "informational" - } - }) - .to_string(); - let unmapped_error = json!({ - "type": "error", - "error": { - "code": "not_a_rotatable_error" - } - }) - .to_string(); - - assert!(classify_websocket_error_frame( - &success_status, - WebSocketErrorClassificationProfile::OneShot - ) - .is_none()); - assert!(classify_websocket_error_frame( - &unmapped_error, - WebSocketErrorClassificationProfile::OneShot - ) - .is_none()); -} - -#[test] -fn codex_websocket_wrapped_error_headers_should_extract_retry_after() { - let frame = json!({ - "type": "error", - "headers": { - "retry-after": ["37"] - }, - "error": { - "code": "rate_limit_exceeded" - } - }) - .to_string(); - - assert_eq!( - retry_after_seconds_from_wrapped_error_headers(&frame), - Some(37) - ); -} - -#[test] -fn codex_websocket_response_completed_parse_error_should_validate_shape() { - let frame = json!({ - "type": "response.completed", - "response": { - "id": "resp_bad", - "usage": { - "input_tokens": "bad", - "output_tokens": 1, - "total_tokens": 1 - } - } - }) - .to_string(); - - let error = websocket_response_completed_parse_error(&frame) - .expect("invalid response.completed should report a parse error"); - - assert!(error.contains("failed to parse ResponseCompleted")); -} - -#[test] -fn codex_websocket_response_completed_parse_error_should_reject_missing_id_and_incomplete_usage() { - let missing_id = json!({ - "type": "response.completed", - "response": { - "object": "response", - "usage": { - "input_tokens": 1, - "output_tokens": 1, - "total_tokens": 2 - } - } - }) - .to_string(); - let incomplete_usage = json!({ - "type": "response.completed", - "response": { - "id": "resp_incomplete_usage", - "object": "response", - "usage": { - "input_tokens": 1, - "output_tokens": 1 - } - } - }) - .to_string(); - - assert!(websocket_response_completed_parse_error(&missing_id) - .expect("missing id should be rejected") - .contains("missing field")); - assert!(websocket_response_completed_parse_error(&incomplete_usage) - .expect("incomplete usage should be rejected") - .contains("missing field")); -} - -#[test] -fn codex_websocket_event_shape_parse_error_should_detect_field_type_mismatches() { - let frame = json!({ - "type": "response.output_text.delta", - "delta": 42 - }) - .to_string(); - - assert!(websocket_event_shape_parse_error(&frame)); -} - -#[test] -fn codex_websocket_response_events_should_detect_missing_required_fields() { - assert!(websocket_response_created_missing_response( - r#"{"type":"response.created"}"# - )); - assert!(websocket_response_output_text_delta_missing_delta( - r#"{"type":"response.output_text.delta"}"# - )); -} - -#[test] -fn codex_websocket_output_item_events_should_detect_invalid_item_shape() { - assert!(websocket_output_item_event_missing_item( - r#"{"type":"response.output_item.added"}"# - )); - assert!(websocket_output_item_event_non_object_item( - r#"{"type":"response.output_item.done","item":"bad"}"# - )); - assert!(websocket_output_item_event_invalid_item_type_tag( - r#"{"type":"response.output_item.done","item":{}}"# - )); -} - -#[test] -fn codex_websocket_message_output_item_should_validate_required_fields() { - let frame = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "content": [{ - "type": "output_text" - }] - } - }) - .to_string(); - - assert!(websocket_message_output_item_event_invalid_required_fields( - &frame - )); -} - -#[test] -fn codex_websocket_optional_null_fields_should_match_missing_field_behavior() { - let message = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "id": null, - "role": "assistant", - "phase": null, - "metadata": null, - "content": [{ - "type": "output_text", - "text": "hello" - }] - } - }) - .to_string(); - let custom_tool = json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "id": null, - "status": null, - "call_id": "call_1", - "name": "render", - "input": "{}" - } - }) - .to_string(); - let context_compaction = json!({ - "type": "response.output_item.done", - "item": { - "type": "context_compaction", - "encrypted_content": null - } - }) - .to_string(); - - assert!(websocket_event_to_sse_frame(&message).is_some()); - assert!(websocket_event_to_sse_frame(&custom_tool).is_some()); - assert!(websocket_event_to_sse_frame(&context_compaction).is_some()); -} - -#[test] -fn codex_websocket_response_control_events_should_detect_incomplete_and_missing_completed_response() -{ - let incomplete = json!({ - "type": "response.incomplete", - "response": { - "incomplete_details": { - "reason": "max_output_tokens" - } - } - }) - .to_string(); - - assert_eq!( - websocket_incomplete_response_reason(&incomplete).as_deref(), - Some("max_output_tokens") - ); - assert!(websocket_response_completed_missing_response( - r#"{"type":"response.completed"}"# - )); -} - -#[test] -fn codex_websocket_delta_events_should_validate_official_required_fields() { - assert!(websocket_delta_event_missing_official_required_fields( - r#"{"type":"response.custom_tool_call_input.delta","delta":"x"}"# - )); - assert!(websocket_delta_event_missing_official_required_fields( - r#"{"type":"response.reasoning_summary_text.delta","delta":"x"}"# - )); - assert!(websocket_delta_event_missing_official_required_fields( - r#"{"type":"response.reasoning_text.delta","delta":"x"}"# - )); -} - -#[test] -fn codex_websocket_output_item_metadata_and_agent_should_validate_required_fields() { - let invalid_metadata = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "metadata": { - "turn_id": 7 - } - } - }) - .to_string(); - let invalid_agent = json!({ - "type": "response.output_item.done", - "item": { - "type": "agent_message", - "author": "assistant", - "content": [{ - "type": "input_text" - }] - } - }) - .to_string(); - - assert!(websocket_output_item_event_invalid_metadata( - &invalid_metadata - )); - assert!(websocket_agent_message_output_item_event_invalid_required_fields(&invalid_agent)); -} - -#[test] -fn codex_websocket_output_item_optional_fields_should_reject_old_gateway_edge_cases() { - let invalid_tool_search_call = json!({ - "type": "response.output_item.done", - "item": { - "type": "tool_search_call", - "call_id": {}, - "status": [], - "execution": "client", - "arguments": {} - } - }) - .to_string(); - let invalid_custom_tool_call = json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "id": {}, - "status": [], - "call_id": "call_1", - "name": "render", - "input": "{}" - } - }) - .to_string(); - let invalid_custom_tool_output = json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call_output", - "call_id": "call_1", - "name": {}, - "output": "ok" - } - }) - .to_string(); - let invalid_tool_search_output = json!({ - "type": "response.output_item.done", - "item": { - "type": "tool_search_output", - "call_id": {}, - "status": "completed", - "execution": "server", - "tools": [] - } - }) - .to_string(); - let invalid_image = json!({ - "type": "response.output_item.done", - "item": { - "type": "image_generation_call", - "id": "img_1", - "status": "completed", - "revised_prompt": {}, - "result": "base64" - } - }) - .to_string(); - let invalid_context_compaction = json!({ - "type": "response.output_item.done", - "item": { - "type": "context_compaction", - "encrypted_content": {} - } - }) - .to_string(); - - assert!( - websocket_tool_search_call_output_item_event_invalid_required_fields( - &invalid_tool_search_call - ) - ); - assert!( - websocket_custom_tool_call_output_item_event_invalid_required_fields( - &invalid_custom_tool_call - ) - ); - assert!( - websocket_custom_tool_call_output_payload_item_event_invalid_required_fields( - &invalid_custom_tool_output - ) - ); - assert!( - websocket_tool_search_output_item_event_invalid_required_fields( - &invalid_tool_search_output - ) - ); - assert!( - websocket_image_generation_call_output_item_event_invalid_required_fields(&invalid_image) - ); - assert!( - websocket_compaction_output_item_event_invalid_required_fields(&invalid_context_compaction) - ); -} - -#[test] -fn codex_websocket_reasoning_and_function_items_should_validate_required_fields() { - let invalid_reasoning = json!({ - "type": "response.output_item.done", - "item": { - "type": "reasoning", - "summary": [{ - "type": "summary_text" - }] - } - }) - .to_string(); - let invalid_function_call = json!({ - "type": "response.output_item.done", - "item": { - "type": "function_call", - "name": "tool", - "call_id": "call_1" - } - }) - .to_string(); - let invalid_function_output = json!({ - "type": "response.output_item.done", - "item": { - "type": "function_call_output", - "call_id": "call_1", - "output": [{ - "type": "input_text" - }] - } - }) - .to_string(); - - assert!(websocket_reasoning_output_item_event_invalid_required_fields(&invalid_reasoning)); - assert!( - websocket_function_call_output_item_event_invalid_required_fields(&invalid_function_call) - ); - assert!( - websocket_function_call_output_payload_item_event_invalid_required_fields( - &invalid_function_output - ) - ); -} - -#[test] -fn codex_websocket_custom_and_tool_search_items_should_validate_required_fields() { - let invalid_custom_call = json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "call_id": "call_1", - "name": "custom" - } - }) - .to_string(); - let invalid_custom_output = json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call_output", - "output": "ok" - } - }) - .to_string(); - let invalid_tool_search_call = json!({ - "type": "response.output_item.done", - "item": { - "type": "tool_search_call", - "arguments": {} - } - }) - .to_string(); - let invalid_tool_search_output = json!({ - "type": "response.output_item.done", - "item": { - "type": "tool_search_output", - "status": "completed", - "execution": "remote" - } - }) - .to_string(); - - assert!( - websocket_custom_tool_call_output_item_event_invalid_required_fields(&invalid_custom_call) - ); - assert!( - websocket_custom_tool_call_output_payload_item_event_invalid_required_fields( - &invalid_custom_output - ) - ); - assert!( - websocket_tool_search_call_output_item_event_invalid_required_fields( - &invalid_tool_search_call - ) - ); - assert!( - websocket_tool_search_output_item_event_invalid_required_fields( - &invalid_tool_search_output - ) - ); -} - -#[test] -fn codex_websocket_local_web_image_and_compaction_items_should_validate_required_fields() { - let invalid_local_shell = json!({ - "type": "response.output_item.done", - "item": { - "type": "local_shell_call", - "call_id": "call_1", - "status": "failed", - "action": { - "type": "exec", - "command": "ls" - } - } - }) - .to_string(); - let invalid_web_search = json!({ - "type": "response.output_item.done", - "item": { - "type": "web_search_call", - "status": "completed", - "action": {} - } - }) - .to_string(); - let invalid_image = json!({ - "type": "response.output_item.done", - "item": { - "type": "image_generation_call", - "id": "img_1", - "status": "completed" - } - }) - .to_string(); - let invalid_compaction = json!({ - "type": "response.output_item.done", - "item": { - "type": "compaction" - } - }) - .to_string(); - - assert!( - websocket_local_shell_call_output_item_event_invalid_required_fields(&invalid_local_shell) - ); - assert!( - websocket_web_search_call_output_item_event_invalid_required_fields(&invalid_web_search) - ); - assert!( - websocket_image_generation_call_output_item_event_invalid_required_fields(&invalid_image) - ); - assert!(websocket_compaction_output_item_event_invalid_required_fields(&invalid_compaction)); -} - -#[test] -fn codex_websocket_local_shell_and_web_search_should_reject_nested_shape_mismatches() { - let invalid_local_shell = json!({ - "type": "response.output_item.done", - "item": { - "type": "local_shell_call", - "call_id": "call_1", - "status": "completed", - "action": { - "type": "exec", - "command": "pwd", - "timeout_ms": "1000", - "env": { - "PATH": {} - } - } - } - }) - .to_string(); - let invalid_web_search = json!({ - "type": "response.output_item.done", - "item": { - "type": "web_search_call", - "status": "completed", - "action": { - "type": "search", - "query": {}, - "queries": ["valid", {}] - } - } - }) - .to_string(); - - assert!( - websocket_local_shell_call_output_item_event_invalid_required_fields(&invalid_local_shell) - ); - assert!( - websocket_web_search_call_output_item_event_invalid_required_fields(&invalid_web_search) - ); -} - -#[test] -fn codex_websocket_reasoning_summary_part_should_require_summary_index() { - assert!( - websocket_reasoning_summary_part_added_missing_summary_index( - r#"{"type":"response.reasoning_summary_part.added"}"# - ) - ); -} - -#[test] -fn codex_websocket_event_to_sse_frame_should_skip_invalid_json_and_shape_mismatches() { - let invalid_delta = json!({ - "type": "response.output_text.delta", - "delta": 42 - }) - .to_string(); - let non_object_item = json!({ - "type": "response.output_item.done", - "item": 123 - }) - .to_string(); - - assert!(websocket_event_to_sse_frame("not-json-from-upstream").is_none()); - assert!(websocket_event_to_sse_frame(&invalid_delta).is_none()); - assert!(websocket_event_to_sse_frame(&non_object_item).is_none()); -} - -#[test] -fn chat_completion_request_should_translate_to_codex_request() { - let request = codex_proxy_core::protocol::openai::chat::ChatCompletionRequest { - model: "gpt-5.5".to_string(), - stream: true, - messages: vec![ - codex_proxy_core::protocol::openai::chat::ChatMessage { - role: "system".to_string(), - content: Some(json!("be brief")), - name: None, - tool_calls: None, - tool_call_id: None, - function_call: None, - }, - codex_proxy_core::protocol::openai::chat::ChatMessage { - role: "user".to_string(), - content: Some(json!("hello")), - name: None, - tool_calls: None, - tool_call_id: None, - function_call: None, - }, - ], - reasoning_effort: Some("medium".to_string()), - service_tier: Some("auto".to_string()), - tools: None, - tool_choice: None, - parallel_tool_calls: None, - functions: None, - response_format: None, - user: Some(" client-123 ".to_string()), - }; - - let codex = codex_proxy_core::protocol::openai::chat::translate_chat_to_codex(request) - .expect("chat request should translate"); - - assert_eq!(codex.model, "gpt-5.5"); - assert!(codex.force_http_sse); - assert_eq!(codex.prompt_cache_key.as_deref(), Some("client-123")); - assert_eq!(codex.client_conversation_id.as_deref(), Some("client-123")); -} - -#[test] -fn sse_parser_should_combine_multiline_data_and_metadata() { - let events = codex_proxy_core::protocol::codex::sse::parse_sse_events( - "event: message\nid: evt_1\ndata: hello\ndata: world\nretry: 10\n\n", - ) - .expect("sse should parse"); - - assert_eq!(events[0].data, "hello\nworld"); -} - -#[test] -fn chat_completion_from_codex_sse_should_convert_completed_response() { - let body = concat!( - "event: response.output_text.delta\n", - "data: {\"delta\":\"hello\"}\n\n", - "event: response.completed\n", - "data: {\"response\":{\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}}\n\n", - ); - - let response = codex_proxy_core::protocol::openai::chat::chat_completion_from_codex_sse( - body, "gpt-5.5", false, None, - ) - .expect("conversion should succeed") - .expect("completed response"); - - assert_eq!(response["choices"][0]["message"]["content"], "hello"); -} - -#[test] -fn response_from_codex_sse_should_collect_completed_response_with_deltas() { - let body = concat!( - "event: response.output_text.delta\n", - "data: {\"delta\":\"hello\"}\n\n", - "event: response.completed\n", - "data: {\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}}\n\n", - ); - - let response = response_from_codex_sse(body, None).expect("conversion should succeed"); - - assert_eq!( - response, - CollectedResponse::Completed(json!({ - "id": "resp_1", - "object": "response", - "status": "completed", - "usage": { - "input_tokens": 2, - "output_tokens": 3 - }, - "output": [{ - "type": "message", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "hello", - "annotations": [] - }] - }], - "output_text": "hello" - })) - ); -} - -#[test] -fn openai_response_request_should_translate_to_codex_request() { - let request = serde_json::from_value::(json!({ - "model": "gpt-5.5", - "input": "hello", - "previous_response_id": "resp_previous", - "turnState": "turn_previous", - "stream": false - })) - .expect("responses request should deserialize"); - - let codex = translate_response_to_codex(request); - - assert_eq!(codex.model, "gpt-5.5"); - assert_eq!(codex.input, vec![json!("hello")]); - assert_eq!(codex.previous_response_id.as_deref(), Some("resp_previous")); - assert_eq!(codex.turn_state.as_deref(), Some("turn_previous")); - assert!(!codex.use_websocket); - assert!(!codex.force_http_sse); -} - -#[test] -fn openai_response_request_should_default_missing_stream_to_true() { - let request = serde_json::from_value::(json!({ - "model": "gpt-5.5", - "input": "hello" - })) - .expect("responses request should deserialize"); - - let codex = translate_response_to_codex(request); - - assert!(codex.stream); -} - -#[test] -fn openai_response_request_should_prepare_tuple_schema_before_upstream() { - let request = serde_json::from_value::(json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "text": { - "format": { - "type": "json_schema", - "name": "TupleAnswer", - "schema": { - "type": "object", - "properties": { - "point": { - "type": "array", - "prefixItems": [ - {"type": "number"}, - {"type": "number"} - ], - "items": false - } - }, - "required": ["point"] - }, - "strict": true - } - } - })) - .expect("responses request should deserialize"); - - let codex = translate_response_to_codex(request); - let schema = &codex.text.as_ref().unwrap()["format"]["schema"]; - - assert_eq!( - schema["properties"]["point"], - json!({ - "type": "object", - "properties": { - "0": {"type": "number"}, - "1": {"type": "number"} - }, - "required": ["0", "1"], - "additionalProperties": false - }) - ); - assert_eq!( - codex.tuple_schema.unwrap()["properties"]["point"]["type"], - "array" - ); -} - -#[test] -fn openai_response_request_should_forward_parity_fields_to_codex() { - let request = serde_json::from_value::(json!({ - "model": "gpt-5.5-fast", - "stream": false, - "input": [], - "instructions": "be terse", - "reasoning": {"effort": "high"}, - "service_tier": "priority", - "tool_choice": {"type": "function", "function": {"name": "lookup"}}, - "parallel_tool_calls": true, - "tools": [{"type": "function", "name": "lookup"}], - "prompt_cache_key": "pcache", - "include": ["reasoning.encrypted_content"], - "client_metadata": {"safe": "yes", "drop": 42}, - "turnState": "turn-body", - "turnMetadata": "meta-body", - "betaFeatures": "beta-body", - "includeTimingMetrics": "true", - "version": "2026-06-12", - "codexWindowId": "window-body", - "parentThreadId": "parent-body", - "use_websocket": false - })) - .expect("responses request should deserialize"); - - let codex = translate_response_to_codex(request); - - assert_eq!(codex.instructions, "be terse"); - assert_eq!( - codex.reasoning, - Some(json!({"effort": "high", "summary": "auto"})) - ); - assert_eq!(codex.service_tier.as_deref(), Some("priority")); - assert_eq!( - codex.tool_choice, - Some(json!({"type": "function", "function": {"name": "lookup"}})) - ); - assert_eq!(codex.parallel_tool_calls, Some(true)); - assert_eq!( - codex.tools, - Some(vec![json!({"type": "function", "name": "lookup"})]) - ); - assert_eq!(codex.prompt_cache_key.as_deref(), Some("pcache")); - assert!(codex.explicit_prompt_cache_key); - assert_eq!( - codex.include, - Some(vec!["reasoning.encrypted_content".to_string()]) - ); - assert_eq!(codex.client_metadata, Some(json!({"safe": "yes"}))); - assert_eq!(codex.turn_state.as_deref(), Some("turn-body")); - assert_eq!(codex.turn_metadata.as_deref(), Some("meta-body")); - assert_eq!(codex.beta_features.as_deref(), Some("beta-body")); - assert_eq!(codex.include_timing_metrics.as_deref(), Some("true")); - assert_eq!(codex.version.as_deref(), Some("2026-06-12")); - assert_eq!(codex.codex_window_id.as_deref(), Some("window-body")); - assert_eq!(codex.parent_thread_id.as_deref(), Some("parent-body")); - assert!(codex.force_http_sse); -} - -#[test] -fn codex_responses_model_options_should_apply_suffix_defaults_and_include_reasoning() { - let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5-high-fast", "", Vec::new()); - let parsed = ParsedModelName { - model_id: "gpt-5.5".to_string(), - reasoning_effort: Some("high".to_string()), - service_tier: Some("fast".to_string()), - }; - let config = ModelConfig { - default_model: "gpt-5.5".to_string(), - default_reasoning_effort: Some("medium".to_string()), - service_tier: Some("flex".to_string()), - aliases: Default::default(), - }; - - apply_response_model_options(&mut request, &parsed, &config); - - assert_eq!(request.model, "gpt-5.5"); - assert_eq!( - request.reasoning, - Some(json!({"summary": "auto", "effort": "high"})) - ); - assert_eq!(request.service_tier.as_deref(), Some("priority")); - assert_eq!( - request.include, - Some(vec!["reasoning.encrypted_content".to_string()]) - ); -} - -#[test] -fn codex_responses_model_options_should_preserve_client_include_and_normalize_body_tier() { - let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "", Vec::new()); - request.reasoning = Some(json!({"summary": "detailed"})); - request.service_tier = Some("fast".to_string()); - request.include = Some(vec!["file_search_call.results".to_string()]); - let parsed = ParsedModelName { - model_id: "gpt-5.5".to_string(), - reasoning_effort: Some("low".to_string()), - service_tier: Some("flex".to_string()), - }; - let config = ModelConfig { - default_model: "gpt-5.5".to_string(), - default_reasoning_effort: Some("medium".to_string()), - service_tier: Some("flex".to_string()), - aliases: Default::default(), - }; - - apply_response_model_options(&mut request, &parsed, &config); - - assert_eq!( - request.reasoning, - Some(json!({"summary": "detailed", "effort": "low"})) - ); - assert_eq!(request.service_tier.as_deref(), Some("priority")); - assert_eq!( - request.include, - Some(vec!["file_search_call.results".to_string()]) - ); -} - -#[test] -fn openai_compact_request_should_drop_responses_only_fields_and_sanitize_input() { - let request = serde_json::from_value::(json!({ - "model": "gpt-5.5-fast", - "instructions": "compress the session", - "input": [ - {"role": "user", "content": "hello"}, - { - "type": "reasoning", - "id": "rs_1", - "status": "completed", - "summary": [{"type": "summary_text", "text": "kept"}], - "ignored": "drop" - }, - {"type": "compaction", "encrypted_content": "enc_compact"}, - {"type": "compaction", "id": "drop_missing_encrypted"} - ], - "tools": [{"type": "function", "name": "lookup"}], - "parallel_tool_calls": false, - "reasoning": {"effort": "high", "summary": "auto", "extra": "drop"}, - "text": { - "format": { - "type": "json_schema", - "name": "Compact", - "schema": {"type": "object"}, - "strict": true - } - }, - "stream": true, - "store": true, - "prompt_cache_key": "must_not_forward" - })) - .expect("responses request should deserialize"); - - let compact = translate_response_to_compact(request); - let body = serde_json::to_value(&compact).expect("compact request should serialize"); - - assert_eq!(body["model"], "gpt-5.5-fast"); - assert_eq!(body["instructions"], "compress the session"); - assert_eq!(body["parallel_tool_calls"], false); - assert_eq!( - body["reasoning"], - json!({"effort": "high", "summary": "auto"}) - ); - assert_eq!( - body["tools"], - json!([{"type": "function", "name": "lookup"}]) - ); - assert_eq!(body["text"]["format"]["type"], "json_schema"); - assert!(body.get("stream").is_none()); - assert!(body.get("store").is_none()); - assert!(body.get("prompt_cache_key").is_none()); - assert_eq!(body["input"].as_array().unwrap().len(), 3); - assert!(body["input"][1].get("ignored").is_none()); - assert_eq!(body["input"][2]["encrypted_content"], "enc_compact"); -} - -#[test] -fn openai_streaming_response_with_previous_response_should_require_websocket() { - let request = serde_json::from_value::(json!({ - "model": "gpt-5.5", - "input": "hello", - "previous_response_id": "resp_previous", - "stream": true - })) - .expect("responses request should deserialize"); - - let codex = translate_response_to_codex(request); - - assert_eq!(codex.previous_response_id.as_deref(), Some("resp_previous")); - assert!(codex.stream); - assert!(!codex.force_http_sse); -} - -#[test] -fn extract_usage_should_read_codex_usage_shape() { - let body = json!({ - "usage": { - "input_tokens": 12, - "output_tokens": 5, - "input_tokens_details": { - "cached_tokens": 3 - } - } - }); - - let usage = extract_usage(&body).expect("usage should exist"); - - assert_eq!( - usage, - TokenUsage { - input_tokens: 12, - output_tokens: 5, - cached_tokens: 3, - image_input_tokens: 0, - image_output_tokens: 0, - total_tokens: 17, - } - ); -} - -#[test] -fn extract_usage_should_read_image_generation_tokens_separately() { - let body = json!({ - "usage": { - "input_tokens": 12, - "output_tokens": 5, - "input_tokens_details": { - "cached_tokens": 3 - } - }, - "tool_usage": { - "image_gen": { - "input_tokens": 31, - "output_tokens": 9 - } - } - }); - - let usage = extract_usage(&body).expect("usage should exist"); - - assert_eq!( - usage, - TokenUsage { - input_tokens: 12, - output_tokens: 5, - cached_tokens: 3, - image_input_tokens: 31, - image_output_tokens: 9, - total_tokens: 17, - } - ); -} - -#[test] -fn extract_usage_should_merge_openai_usage_shape() { - let first = extract_usage(&json!({ - "usage": { - "prompt_tokens": 8, - "completion_tokens": 4, - "prompt_tokens_details": { - "cached_tokens": 2 - } - } - })) - .expect("usage should exist"); - let second = TokenUsage { - input_tokens: 1, - output_tokens: 2, - cached_tokens: 0, - image_input_tokens: 0, - image_output_tokens: 0, - total_tokens: 3, - }; - - assert_eq!( - first.merged(second), - TokenUsage { - input_tokens: 9, - output_tokens: 6, - cached_tokens: 2, - image_input_tokens: 0, - image_output_tokens: 0, - total_tokens: 15, - } - ); -} - -#[test] -fn extract_sse_usage_should_prefer_completed_response_usage() { - let body = concat!( - "event: response.created\n", - "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n", - "event: response.completed\n", - "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n", - ); - - let usage = extract_sse_usage(body) - .expect("usage extraction should succeed") - .expect("usage should exist"); - - assert_eq!( - usage, - TokenUsage { - input_tokens: 3, - output_tokens: 5, - cached_tokens: 1, - image_input_tokens: 0, - image_output_tokens: 0, - total_tokens: 8, - } - ); -} - -#[test] -fn extract_sse_usage_should_read_completed_image_generation_tokens() { - let body = concat!( - "event: response.completed\n", - "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":3}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n", - ); - - let usage = extract_sse_usage(body) - .expect("usage extraction should succeed") - .expect("usage should exist"); - - assert_eq!( - usage, - TokenUsage { - input_tokens: 12, - output_tokens: 5, - cached_tokens: 3, - image_input_tokens: 31, - image_output_tokens: 9, - total_tokens: 17, - } - ); -} - -#[test] -fn retry_after_seconds_from_body_should_read_structured_retry_delay() { - let body = json!({ - "response": { - "error": { - "resets_in_seconds": 45 - } - } - }) - .to_string(); - - assert_eq!(retry_after_seconds_from_body(&body), Some(45)); -} - -#[test] -fn retry_after_seconds_from_body_should_parse_rate_limit_message_seconds() { - let body = json!({ - "response": { - "error": { - "code": "rate_limit_exceeded", - "message": "Rate limit reached. Please try again in 11.054s." - } - } - }) - .to_string(); - - assert_eq!(retry_after_seconds_from_body(&body), Some(12)); -} - -#[test] -fn retry_after_seconds_from_body_should_parse_rate_limit_message_milliseconds() { - let body = json!({ - "error": { - "code": "rate_limit_exceeded", - "message": "Rate limit reached. Please try again in 28ms." - } - }) - .to_string(); - - assert_eq!(retry_after_seconds_from_body(&body), Some(1)); -} - -#[test] -fn retry_after_seconds_from_body_should_ignore_retry_message_for_other_codes() { - let body = json!({ - "response": { - "error": { - "code": "upstream_transient_error", - "message": "Try again in 35 seconds." - } - } - }) - .to_string(); - - assert_eq!(retry_after_seconds_from_body(&body), None); -} - -#[test] -fn parse_rate_limit_headers_should_extract_primary_secondary_and_review_windows() { - let headers = vec![ - ( - "x-codex-primary-used-percent".to_string(), - "100".to_string(), - ), - ( - "x-codex-primary-window-minutes".to_string(), - "5".to_string(), - ), - ( - "x-codex-primary-reset-at".to_string(), - "1893456300".to_string(), - ), - ( - "x-codex-secondary-used-percent".to_string(), - "42.5".to_string(), - ), - ( - "x-codex-secondary-window-minutes".to_string(), - "10080".to_string(), - ), - ( - "x-codex-code-review-primary-used-percent".to_string(), - "80".to_string(), - ), - ( - "x-codex-code-review-primary-reset-at".to_string(), - "1893456600".to_string(), - ), - ]; - - let parsed = parse_rate_limit_headers(&headers).expect("rate limits should parse"); - - assert_eq!( - parsed.primary, - Some(RateLimitWindow { - used_percent: 100.0, - window_minutes: Some(5), - reset_at: Some(1_893_456_300), - }) - ); - assert_eq!( - parsed.secondary.expect("secondary window").window_minutes, - Some(10080) - ); - assert_eq!( - parsed - .code_review - .expect("review window") - .primary - .expect("review primary") - .reset_at, - Some(1_893_456_600) - ); -} - -#[test] -fn parse_rate_limits_event_should_extract_internal_websocket_rate_limits() { - let event = json!({ - "type": "codex.rate_limits", - "rate_limits": { - "primary": { - "used_percent": 99.5, - "window_minutes": 300, - "reset_at": 1893456300 - }, - "secondary": { - "used_percent": 10, - "window_minutes": 10080, - "reset_at": 1894056000 - } - } - }); - - let parsed = parse_rate_limits_event(&event).expect("event should parse"); - - assert_eq!(parsed.primary.expect("primary window").used_percent, 99.5); - assert_eq!( - parsed.secondary.expect("secondary window").reset_at, - Some(1_894_056_000) - ); -} - -#[test] -fn rate_limit_quota_should_preserve_existing_credits_when_passive_data_lacks_credits() { - let headers = vec![ - ("x-codex-primary-used-percent".to_string(), "25".to_string()), - ( - "x-codex-primary-window-minutes".to_string(), - "5".to_string(), - ), - ( - "x-codex-primary-reset-at".to_string(), - "1893456300".to_string(), - ), - ]; - let existing = json!({ - "credits": { - "has_credits": true, - "unlimited": false, - "balance": 12 - } - }); - let parsed = parse_rate_limit_headers(&headers).expect("rate limits should parse"); - - let quota = rate_limit_quota(&parsed, Some("plus"), Some(&existing)); - - assert_eq!(quota["plan_type"], "plus"); - assert_eq!(quota["rate_limit"]["remaining_percent"], 75); - assert_eq!(quota["credits"]["balance"], 12); -} - -#[test] -fn cooldown_with_jitter_should_return_positive_duration_within_expected_range() { - let duration = cooldown_with_jitter(60, 2_000); - - assert!((48..=72).contains(&duration.num_seconds())); -} - -#[tokio::test] -async fn refresh_scheduler_should_refresh_before_expiry_and_preserve_refresh_token() { - use async_trait::async_trait; - use chrono::{Duration as ChronoDuration, Utc}; - use codex_proxy_core::accounts::model::{Account, AccountStatus}; - use codex_proxy_core::auth::oauth::{ - RefreshFailure, RefreshPolicy, RefreshScheduler, RefreshTrigger, TokenPair, - }; - use codex_proxy_core::auth::ports::TokenRefresher; - - #[derive(Clone)] - struct StaticRefreshClient { - result: Result, - } - - #[async_trait] - impl TokenRefresher for StaticRefreshClient { - async fn refresh(&self, _refresh_token: &str) -> Result { - self.result.clone() - } - } - - let now = Utc::now(); - let mut account = Account::test("acct_1", AccountStatus::Active); - account.access_token_expires_at = Some(now + ChronoDuration::seconds(60)); - account.refresh_token = Some("rt_keep".to_string()); - let scheduler = RefreshScheduler::new( - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 2, - }, - StaticRefreshClient { - result: Ok(TokenPair { - access_token: "new-access".to_string(), - refresh_token: None, - }), - }, - ); - - let refreshed = scheduler - .refresh_account_at(&account, RefreshTrigger::BeforeExpiry, now) - .await - .expect("refresh should succeed"); - - assert_eq!(refreshed.access_token, "new-access"); - assert_eq!(refreshed.refresh_token.as_deref(), Some("rt_keep")); - assert_eq!(refreshed.status, AccountStatus::Active); -} +#[path = "protocol/protocol_codex_websocket.rs"] +mod protocol_codex_websocket; +#[path = "protocol/protocol_openai_chat.rs"] +mod protocol_openai_chat; +#[path = "protocol/protocol_openai_responses.rs"] +mod protocol_openai_responses; +#[path = "protocol/protocol_usage_rate_limits.rs"] +mod protocol_usage_rate_limits; fn assert_substrings_appear_in_order(haystack: &str, needles: &[&str]) { let mut cursor = 0; diff --git a/crates/core/tests/protocol/protocol_codex_websocket.rs b/crates/core/tests/protocol/protocol_codex_websocket.rs new file mode 100644 index 000000000..e56d6ff2f --- /dev/null +++ b/crates/core/tests/protocol/protocol_codex_websocket.rs @@ -0,0 +1,908 @@ +use super::*; + +#[test] +fn codex_websocket_payload_audit_snapshot_should_redact_user_content() { + let mut request = CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "private instructions", + vec![json!({ + "role": "user", + "content": "private prompt", + })], + ); + request.previous_response_id = Some("resp_secret".to_string()); + request.reasoning = Some(json!({"effort": "medium"})); + request.tools = Some(vec![json!({ + "type": "function", + "name": "private_tool", + "description": "private schema", + })]); + request.text = Some(json!({"format": {"type": "text"}})); + request.service_tier = Some("flex".to_string()); + request.prompt_cache_key = Some("cache-secret".to_string()); + request.generate = Some(false); + request.include = Some(vec!["reasoning.encrypted_content".to_string()]); + request.client_metadata = Some(json!({ + "thread_id": "thread-secret", + "safe": "value", + })); + + let snapshot = websocket_payload_audit_snapshot(&request); + + assert_eq!( + snapshot.top_level_keys, + vec![ + "type", + "model", + "instructions", + "previous_response_id", + "input", + "tools", + "tool_choice", + "parallel_tool_calls", + "reasoning", + "store", + "stream", + "include", + "service_tier", + "prompt_cache_key", + "text", + "generate", + "client_metadata", + ] + ); + assert_eq!(snapshot.body["type"], "response.create"); + assert_eq!(snapshot.body["model"], "gpt-5.5"); + assert_eq!(snapshot.body["stream"], true); + assert_eq!(snapshot.body["instructions"], ""); + assert_eq!(snapshot.body["input"], ""); + assert_eq!(snapshot.body["previous_response_id"], ""); + assert_eq!(snapshot.body["prompt_cache_key"], ""); + assert_eq!(snapshot.body["client_metadata"], ""); + assert_eq!(snapshot.body["tools"], ""); +} + +#[test] +fn codex_websocket_response_create_payload_text_should_preserve_canonical_field_order() { + let mut request = CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "private capture instructions", + vec![json!({ + "role": "user", + "content": "private capture prompt", + })], + ); + request.prompt_cache_key = Some("session-1".to_string()); + request.generate = Some(false); + request.client_metadata = Some(json!({ + "thread_id": "capture-thread-secret", + "safe": "capture", + })); + + let payload = + codex_proxy_core::protocol::codex::websocket::websocket_response_create_payload_text( + &request, + ) + .expect("payload should serialize"); + + assert_substrings_appear_in_order( + &payload, + &[ + "\"type\":\"response.create\"", + "\"model\":\"gpt-5.5\"", + "\"instructions\":\"private capture instructions\"", + "\"input\":", + "\"tools\":[]", + "\"tool_choice\":\"auto\"", + "\"parallel_tool_calls\":true", + "\"reasoning\":null", + "\"store\":false", + "\"stream\":true", + "\"include\":[]", + "\"prompt_cache_key\":\"session-1\"", + "\"generate\":false", + "\"client_metadata\":", + ], + ); +} + +#[test] +fn codex_websocket_audit_artifact_should_record_transport_opening_and_payload() { + let mut request = CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "private instructions", + vec![json!({ + "role": "user", + "content": "private prompt", + })], + ); + request.previous_response_id = Some("resp_secret".to_string()); + let opening = OpeningAuditSnapshot { + request_line: "GET /backend-api/codex/responses HTTP/1.1".to_string(), + header_order: vec!["Host".to_string(), "authorization".to_string()], + headers: vec![ + OpeningAuditHeader { + name: "Host".to_string(), + value: "chatgpt.com".to_string(), + }, + OpeningAuditHeader { + name: "authorization".to_string(), + value: "".to_string(), + }, + ], + }; + let payload = websocket_payload_audit_snapshot(&request); + + let artifact = websocket_audit_artifact_from_attempt(&request, opening.clone(), payload); + + assert_eq!(artifact.transport_mode, "websocket_required"); + assert!(!artifact.fallback_allowed); + assert_eq!(artifact.opening, Some(opening)); + assert_eq!( + artifact.payload.expect("payload").body["input"], + "" + ); + assert_eq!(artifact.error, None); +} + +#[test] +fn codex_websocket_parity_diff_should_report_header_payload_and_error_differences() { + let current = codex_proxy_core::protocol::codex::websocket::WebSocketAuditArtifact { + transport_mode: "websocket_preferred".to_string(), + fallback_allowed: true, + opening: Some(OpeningAuditSnapshot { + request_line: "GET /codex/responses HTTP/1.1".to_string(), + header_order: vec!["Host".to_string(), "Connection".to_string()], + headers: vec![OpeningAuditHeader { + name: "Sec-WebSocket-Extensions".to_string(), + value: "permessage-deflate; client_max_window_bits".to_string(), + }], + }), + payload: Some(PayloadAuditSnapshot { + top_level_keys: vec!["type".to_string(), "model".to_string()], + body: json!({"type": "response.create"}), + }), + error: Some(WebSocketAuditErrorSnapshot { + classification: "opening_failed".to_string(), + message: "connection refused".to_string(), + }), + }; + let reference = codex_proxy_core::protocol::codex::websocket::WebSocketAuditArtifact { + transport_mode: "websocket_required".to_string(), + fallback_allowed: false, + opening: Some(OpeningAuditSnapshot { + request_line: "GET /codex/responses?source=audit HTTP/1.1".to_string(), + header_order: vec!["Host".to_string(), "Authorization".to_string()], + headers: vec![OpeningAuditHeader { + name: "Sec-WebSocket-Extensions".to_string(), + value: "identity".to_string(), + }], + }), + payload: Some(PayloadAuditSnapshot { + top_level_keys: vec!["type".to_string(), "previous_response_id".to_string()], + body: json!({"type": "response.create"}), + }), + error: Some(WebSocketAuditErrorSnapshot { + classification: "timeout".to_string(), + message: "timed out".to_string(), + }), + }; + + let diff = websocket_parity_diff(¤t, &reference); + + assert!(diff.differences.iter().any(|difference| { + difference.path == "opening.header_order" + && difference.current == json!(["Host", "Connection"]) + && difference.reference == json!(["Host", "Authorization"]) + })); + assert!(diff + .differences + .iter() + .any(|difference| difference.path == "payload.top_level_keys")); + assert!(diff + .differences + .iter() + .any(|difference| difference.path == "error.classification")); +} + +#[test] +fn codex_websocket_event_to_sse_frame_should_encode_public_events_only() { + let event = json!({ + "type": "response.completed", + "response": { + "id": "resp_ws", + "object": "response", + "usage": { + "input_tokens": 4, + "output_tokens": 2, + "total_tokens": 6 + } + } + }) + .to_string(); + + let frame = websocket_event_to_sse_frame(&event).expect("public event should encode"); + + assert_eq!( + frame, + format!("event: response.completed\ndata: {event}\n\n") + ); + assert!(websocket_event_to_sse_frame(r#"{"type":"codex.rate_limits"}"#).is_none()); + assert!(websocket_event_to_sse_frame(r#"{"response":{}}"#).is_none()); + assert!(websocket_event_to_sse_frame("not-json").is_none()); +} + +#[test] +fn codex_websocket_metadata_turn_state_should_extract_case_insensitive_header() { + let event = json!({ + "type": "response.metadata", + "headers": { + "X-Codex-Turn-State": ["turn-from-metadata"] + } + }) + .to_string(); + + assert_eq!( + websocket_metadata_turn_state(&event).as_deref(), + Some("turn-from-metadata") + ); +} + +#[test] +fn codex_websocket_terminal_event_should_match_completed_failed_and_error() { + assert!(is_terminal_websocket_event("response.completed")); + assert!(is_terminal_websocket_event("response.failed")); + assert!(is_terminal_websocket_event("error")); + assert!(!is_terminal_websocket_event("response.output_text.delta")); +} + +#[test] +fn codex_websocket_error_frame_should_classify_rotatable_codes() { + let frame = json!({ + "type": "response.failed", + "response": { + "error": { + "code": "usage_limit_reached", + "message": "Rate limit reached. Please try again in 11.054s." + } + } + }) + .to_string(); + + let error = + classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot) + .expect("rate limit frame should classify"); + + assert_eq!(error.status_code, 429); +} + +#[test] +fn codex_websocket_error_frame_should_classify_upstream_gateway_special_codes() { + let cases = [ + ("quota_exhausted", 402), + ("token_expired", 401), + ("account_banned", 403), + ("previous_response_not_found", 400), + ("server_is_overloaded", 503), + ("usage_not_included", 429), + ]; + + for (code, expected_status) in cases { + let frame = json!({ + "type": "response.failed", + "response": { + "error": { + "code": code, + "message": "classified by upstream gateway compatibility" + } + } + }) + .to_string(); + + let error = + classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot) + .expect("special gateway code should classify"); + + assert_eq!(error.status_code, expected_status, "code: {code}"); + } +} + +#[test] +fn codex_websocket_error_frame_should_honor_explicit_error_status() { + let frame = json!({ + "type": "error", + "status_code": 403, + "error": { + "code": "forbidden" + } + }) + .to_string(); + + let error = + classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot) + .expect("explicit status should classify"); + + assert_eq!(error.status_code, 403); +} + +#[test] +fn codex_websocket_error_frame_should_ignore_success_status_and_unmapped_error() { + let success_status = json!({ + "type": "error", + "status": 200, + "error": { + "code": "informational" + } + }) + .to_string(); + let unmapped_error = json!({ + "type": "error", + "error": { + "code": "not_a_rotatable_error" + } + }) + .to_string(); + + assert!(classify_websocket_error_frame( + &success_status, + WebSocketErrorClassificationProfile::OneShot + ) + .is_none()); + assert!(classify_websocket_error_frame( + &unmapped_error, + WebSocketErrorClassificationProfile::OneShot + ) + .is_none()); +} + +#[test] +fn codex_websocket_wrapped_error_headers_should_extract_retry_after() { + let frame = json!({ + "type": "error", + "headers": { + "retry-after": ["37"] + }, + "error": { + "code": "rate_limit_exceeded" + } + }) + .to_string(); + + assert_eq!( + retry_after_seconds_from_wrapped_error_headers(&frame), + Some(37) + ); +} + +#[test] +fn codex_websocket_response_completed_parse_error_should_validate_shape() { + let frame = json!({ + "type": "response.completed", + "response": { + "id": "resp_bad", + "usage": { + "input_tokens": "bad", + "output_tokens": 1, + "total_tokens": 1 + } + } + }) + .to_string(); + + let error = websocket_response_completed_parse_error(&frame) + .expect("invalid response.completed should report a parse error"); + + assert!(error.contains("failed to parse ResponseCompleted")); +} + +#[test] +fn codex_websocket_response_completed_parse_error_should_reject_missing_id_and_incomplete_usage() { + let missing_id = json!({ + "type": "response.completed", + "response": { + "object": "response", + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + } + }) + .to_string(); + let incomplete_usage = json!({ + "type": "response.completed", + "response": { + "id": "resp_incomplete_usage", + "object": "response", + "usage": { + "input_tokens": 1, + "output_tokens": 1 + } + } + }) + .to_string(); + + assert!(websocket_response_completed_parse_error(&missing_id) + .expect("missing id should be rejected") + .contains("missing field")); + assert!(websocket_response_completed_parse_error(&incomplete_usage) + .expect("incomplete usage should be rejected") + .contains("missing field")); +} + +#[test] +fn codex_websocket_event_shape_parse_error_should_detect_field_type_mismatches() { + let frame = json!({ + "type": "response.output_text.delta", + "delta": 42 + }) + .to_string(); + + assert!(websocket_event_shape_parse_error(&frame)); +} + +#[test] +fn codex_websocket_response_events_should_detect_missing_required_fields() { + assert!(websocket_response_created_missing_response( + r#"{"type":"response.created"}"# + )); + assert!(websocket_response_output_text_delta_missing_delta( + r#"{"type":"response.output_text.delta"}"# + )); +} + +#[test] +fn codex_websocket_output_item_events_should_detect_invalid_item_shape() { + assert!(websocket_output_item_event_missing_item( + r#"{"type":"response.output_item.added"}"# + )); + assert!(websocket_output_item_event_non_object_item( + r#"{"type":"response.output_item.done","item":"bad"}"# + )); + assert!(websocket_output_item_event_invalid_item_type_tag( + r#"{"type":"response.output_item.done","item":{}}"# + )); +} + +#[test] +fn codex_websocket_message_output_item_should_validate_required_fields() { + let frame = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text" + }] + } + }) + .to_string(); + + assert!(websocket_message_output_item_event_invalid_required_fields( + &frame + )); +} + +#[test] +fn codex_websocket_optional_null_fields_should_match_missing_field_behavior() { + let message = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "id": null, + "role": "assistant", + "phase": null, + "metadata": null, + "content": [{ + "type": "output_text", + "text": "hello" + }] + } + }) + .to_string(); + let custom_tool = json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "id": null, + "status": null, + "call_id": "call_1", + "name": "render", + "input": "{}" + } + }) + .to_string(); + let context_compaction = json!({ + "type": "response.output_item.done", + "item": { + "type": "context_compaction", + "encrypted_content": null + } + }) + .to_string(); + + assert!(websocket_event_to_sse_frame(&message).is_some()); + assert!(websocket_event_to_sse_frame(&custom_tool).is_some()); + assert!(websocket_event_to_sse_frame(&context_compaction).is_some()); +} + +#[test] +fn codex_websocket_response_control_events_should_detect_incomplete_and_missing_completed_response() +{ + let incomplete = json!({ + "type": "response.incomplete", + "response": { + "incomplete_details": { + "reason": "max_output_tokens" + } + } + }) + .to_string(); + + assert_eq!( + websocket_incomplete_response_reason(&incomplete).as_deref(), + Some("max_output_tokens") + ); + assert!(websocket_response_completed_missing_response( + r#"{"type":"response.completed"}"# + )); +} + +#[test] +fn codex_websocket_delta_events_should_validate_official_required_fields() { + assert!(websocket_delta_event_missing_official_required_fields( + r#"{"type":"response.custom_tool_call_input.delta","delta":"x"}"# + )); + assert!(websocket_delta_event_missing_official_required_fields( + r#"{"type":"response.reasoning_summary_text.delta","delta":"x"}"# + )); + assert!(websocket_delta_event_missing_official_required_fields( + r#"{"type":"response.reasoning_text.delta","delta":"x"}"# + )); +} + +#[test] +fn codex_websocket_output_item_metadata_and_agent_should_validate_required_fields() { + let invalid_metadata = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "metadata": { + "turn_id": 7 + } + } + }) + .to_string(); + let invalid_agent = json!({ + "type": "response.output_item.done", + "item": { + "type": "agent_message", + "author": "assistant", + "content": [{ + "type": "input_text" + }] + } + }) + .to_string(); + + assert!(websocket_output_item_event_invalid_metadata( + &invalid_metadata + )); + assert!(websocket_agent_message_output_item_event_invalid_required_fields(&invalid_agent)); +} + +#[test] +fn codex_websocket_output_item_optional_fields_should_reject_upstream_gateway_edge_cases() { + let invalid_tool_search_call = json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_call", + "call_id": {}, + "status": [], + "execution": "client", + "arguments": {} + } + }) + .to_string(); + let invalid_custom_tool_call = json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "id": {}, + "status": [], + "call_id": "call_1", + "name": "render", + "input": "{}" + } + }) + .to_string(); + let invalid_custom_tool_output = json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call_output", + "call_id": "call_1", + "name": {}, + "output": "ok" + } + }) + .to_string(); + let invalid_tool_search_output = json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_output", + "call_id": {}, + "status": "completed", + "execution": "server", + "tools": [] + } + }) + .to_string(); + let invalid_image = json!({ + "type": "response.output_item.done", + "item": { + "type": "image_generation_call", + "id": "img_1", + "status": "completed", + "revised_prompt": {}, + "result": "base64" + } + }) + .to_string(); + let invalid_context_compaction = json!({ + "type": "response.output_item.done", + "item": { + "type": "context_compaction", + "encrypted_content": {} + } + }) + .to_string(); + + assert!( + websocket_tool_search_call_output_item_event_invalid_required_fields( + &invalid_tool_search_call + ) + ); + assert!( + websocket_custom_tool_call_output_item_event_invalid_required_fields( + &invalid_custom_tool_call + ) + ); + assert!( + websocket_custom_tool_call_output_payload_item_event_invalid_required_fields( + &invalid_custom_tool_output + ) + ); + assert!( + websocket_tool_search_output_item_event_invalid_required_fields( + &invalid_tool_search_output + ) + ); + assert!( + websocket_image_generation_call_output_item_event_invalid_required_fields(&invalid_image) + ); + assert!( + websocket_compaction_output_item_event_invalid_required_fields(&invalid_context_compaction) + ); +} + +#[test] +fn codex_websocket_reasoning_and_function_items_should_validate_required_fields() { + let invalid_reasoning = json!({ + "type": "response.output_item.done", + "item": { + "type": "reasoning", + "summary": [{ + "type": "summary_text" + }] + } + }) + .to_string(); + let invalid_function_call = json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": "tool", + "call_id": "call_1" + } + }) + .to_string(); + let invalid_function_output = json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call_output", + "call_id": "call_1", + "output": [{ + "type": "input_text" + }] + } + }) + .to_string(); + + assert!(websocket_reasoning_output_item_event_invalid_required_fields(&invalid_reasoning)); + assert!( + websocket_function_call_output_item_event_invalid_required_fields(&invalid_function_call) + ); + assert!( + websocket_function_call_output_payload_item_event_invalid_required_fields( + &invalid_function_output + ) + ); +} + +#[test] +fn codex_websocket_custom_and_tool_search_items_should_validate_required_fields() { + let invalid_custom_call = json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "call_id": "call_1", + "name": "custom" + } + }) + .to_string(); + let invalid_custom_output = json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call_output", + "output": "ok" + } + }) + .to_string(); + let invalid_tool_search_call = json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_call", + "arguments": {} + } + }) + .to_string(); + let invalid_tool_search_output = json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_output", + "status": "completed", + "execution": "remote" + } + }) + .to_string(); + + assert!( + websocket_custom_tool_call_output_item_event_invalid_required_fields(&invalid_custom_call) + ); + assert!( + websocket_custom_tool_call_output_payload_item_event_invalid_required_fields( + &invalid_custom_output + ) + ); + assert!( + websocket_tool_search_call_output_item_event_invalid_required_fields( + &invalid_tool_search_call + ) + ); + assert!( + websocket_tool_search_output_item_event_invalid_required_fields( + &invalid_tool_search_output + ) + ); +} + +#[test] +fn codex_websocket_local_web_image_and_compaction_items_should_validate_required_fields() { + let invalid_local_shell = json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "call_id": "call_1", + "status": "failed", + "action": { + "type": "exec", + "command": "ls" + } + } + }) + .to_string(); + let invalid_web_search = json!({ + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "status": "completed", + "action": {} + } + }) + .to_string(); + let invalid_image = json!({ + "type": "response.output_item.done", + "item": { + "type": "image_generation_call", + "id": "img_1", + "status": "completed" + } + }) + .to_string(); + let invalid_compaction = json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction" + } + }) + .to_string(); + + assert!( + websocket_local_shell_call_output_item_event_invalid_required_fields(&invalid_local_shell) + ); + assert!( + websocket_web_search_call_output_item_event_invalid_required_fields(&invalid_web_search) + ); + assert!( + websocket_image_generation_call_output_item_event_invalid_required_fields(&invalid_image) + ); + assert!(websocket_compaction_output_item_event_invalid_required_fields(&invalid_compaction)); +} + +#[test] +fn codex_websocket_local_shell_and_web_search_should_reject_nested_shape_mismatches() { + let invalid_local_shell = json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "call_id": "call_1", + "status": "completed", + "action": { + "type": "exec", + "command": "pwd", + "timeout_ms": "1000", + "env": { + "PATH": {} + } + } + } + }) + .to_string(); + let invalid_web_search = json!({ + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": {}, + "queries": ["valid", {}] + } + } + }) + .to_string(); + + assert!( + websocket_local_shell_call_output_item_event_invalid_required_fields(&invalid_local_shell) + ); + assert!( + websocket_web_search_call_output_item_event_invalid_required_fields(&invalid_web_search) + ); +} + +#[test] +fn codex_websocket_reasoning_summary_part_should_require_summary_index() { + assert!( + websocket_reasoning_summary_part_added_missing_summary_index( + r#"{"type":"response.reasoning_summary_part.added"}"# + ) + ); +} + +#[test] +fn codex_websocket_event_to_sse_frame_should_skip_invalid_json_and_shape_mismatches() { + let invalid_delta = json!({ + "type": "response.output_text.delta", + "delta": 42 + }) + .to_string(); + let non_object_item = json!({ + "type": "response.output_item.done", + "item": 123 + }) + .to_string(); + + assert!(websocket_event_to_sse_frame("not-json-from-upstream").is_none()); + assert!(websocket_event_to_sse_frame(&invalid_delta).is_none()); + assert!(websocket_event_to_sse_frame(&non_object_item).is_none()); +} diff --git a/crates/core/tests/protocol/protocol_openai_chat.rs b/crates/core/tests/protocol/protocol_openai_chat.rs new file mode 100644 index 000000000..be1ddb23c --- /dev/null +++ b/crates/core/tests/protocol/protocol_openai_chat.rs @@ -0,0 +1,71 @@ +use super::*; + +#[test] +fn chat_completion_request_should_translate_to_codex_request() { + let request = codex_proxy_core::protocol::openai::chat::ChatCompletionRequest { + model: "gpt-5.5".to_string(), + stream: true, + messages: vec![ + codex_proxy_core::protocol::openai::chat::ChatMessage { + role: "system".to_string(), + content: Some(json!("be brief")), + name: None, + tool_calls: None, + tool_call_id: None, + function_call: None, + }, + codex_proxy_core::protocol::openai::chat::ChatMessage { + role: "user".to_string(), + content: Some(json!("hello")), + name: None, + tool_calls: None, + tool_call_id: None, + function_call: None, + }, + ], + reasoning_effort: Some("medium".to_string()), + service_tier: Some("auto".to_string()), + tools: None, + tool_choice: None, + parallel_tool_calls: None, + functions: None, + response_format: None, + user: Some(" client-123 ".to_string()), + }; + + let codex = codex_proxy_core::protocol::openai::chat::translate_chat_to_codex(request) + .expect("chat request should translate"); + + assert_eq!(codex.model, "gpt-5.5"); + assert!(codex.force_http_sse); + assert_eq!(codex.prompt_cache_key.as_deref(), Some("client-123")); + assert_eq!(codex.client_conversation_id.as_deref(), Some("client-123")); +} + +#[test] +fn sse_parser_should_combine_multiline_data_and_metadata() { + let events = codex_proxy_core::protocol::codex::sse::parse_sse_events( + "event: message\nid: evt_1\ndata: hello\ndata: world\nretry: 10\n\n", + ) + .expect("sse should parse"); + + assert_eq!(events[0].data, "hello\nworld"); +} + +#[test] +fn chat_completion_from_codex_sse_should_convert_completed_response() { + let body = concat!( + "event: response.output_text.delta\n", + "data: {\"delta\":\"hello\"}\n\n", + "event: response.completed\n", + "data: {\"response\":{\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}}\n\n", + ); + + let response = codex_proxy_core::protocol::openai::chat::chat_completion_from_codex_sse( + body, "gpt-5.5", false, None, + ) + .expect("conversion should succeed") + .expect("completed response"); + + assert_eq!(response["choices"][0]["message"]["content"], "hello"); +} diff --git a/crates/core/tests/protocol/protocol_openai_responses.rs b/crates/core/tests/protocol/protocol_openai_responses.rs new file mode 100644 index 000000000..590a1c63a --- /dev/null +++ b/crates/core/tests/protocol/protocol_openai_responses.rs @@ -0,0 +1,371 @@ +use super::*; + +#[test] +fn codex_responses_request_should_enable_http_sse_defaults() { + let request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); + + assert!(request.stream); +} + +#[test] +fn codex_responses_transport_should_prefer_websocket_without_history() { + let request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); + + assert_eq!( + transport_for_request(&request), + CodexTransport::WebSocketPreferred + ); + assert!(http_sse_fallback_allowed(&request)); +} + +#[test] +fn codex_responses_transport_should_require_websocket_for_previous_response_id() { + let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); + request.previous_response_id = Some("resp_previous".to_string()); + + assert_eq!( + transport_for_request(&request), + CodexTransport::WebSocketRequired + ); + assert!(!http_sse_fallback_allowed(&request)); +} + +#[test] +fn codex_responses_transport_should_allow_forced_http_sse() { + let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); + request.previous_response_id = Some("resp_previous".to_string()); + request.use_websocket = true; + request.force_http_sse = true; + + assert_eq!(transport_for_request(&request), CodexTransport::HttpSse); + assert!(http_sse_fallback_allowed(&request)); +} + +#[test] +fn codex_responses_transport_flags_should_not_serialize_to_upstream_json() { + let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]); + request.use_websocket = true; + request.force_http_sse = true; + + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("use_websocket").is_none()); + assert!(body.get("useWebSocket").is_none()); + assert!(body.get("force_http_sse").is_none()); + assert!(body.get("forceHttpSse").is_none()); +} + +#[test] +fn response_from_codex_sse_should_collect_completed_response_with_deltas() { + let body = concat!( + "event: response.output_text.delta\n", + "data: {\"delta\":\"hello\"}\n\n", + "event: response.completed\n", + "data: {\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}}\n\n", + ); + + let response = response_from_codex_sse(body, None).expect("conversion should succeed"); + + assert_eq!( + response, + CollectedResponse::Completed(json!({ + "id": "resp_1", + "object": "response", + "status": "completed", + "usage": { + "input_tokens": 2, + "output_tokens": 3 + }, + "output": [{ + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "hello", + "annotations": [] + }] + }], + "output_text": "hello" + })) + ); +} + +#[test] +fn openai_response_request_should_translate_to_codex_request() { + let request = serde_json::from_value::(json!({ + "model": "gpt-5.5", + "input": "hello", + "previous_response_id": "resp_previous", + "turnState": "turn_previous", + "stream": false + })) + .expect("responses request should deserialize"); + + let codex = translate_response_to_codex(request); + + assert_eq!(codex.model, "gpt-5.5"); + assert_eq!(codex.input, vec![json!("hello")]); + assert_eq!(codex.previous_response_id.as_deref(), Some("resp_previous")); + assert_eq!(codex.turn_state.as_deref(), Some("turn_previous")); + assert!(!codex.use_websocket); + assert!(!codex.force_http_sse); +} + +#[test] +fn openai_response_request_should_default_missing_stream_to_true() { + let request = serde_json::from_value::(json!({ + "model": "gpt-5.5", + "input": "hello" + })) + .expect("responses request should deserialize"); + + let codex = translate_response_to_codex(request); + + assert!(codex.stream); +} + +#[test] +fn openai_response_request_should_prepare_tuple_schema_before_upstream() { + let request = serde_json::from_value::(json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "text": { + "format": { + "type": "json_schema", + "name": "TupleAnswer", + "schema": { + "type": "object", + "properties": { + "point": { + "type": "array", + "prefixItems": [ + {"type": "number"}, + {"type": "number"} + ], + "items": false + } + }, + "required": ["point"] + }, + "strict": true + } + } + })) + .expect("responses request should deserialize"); + + let codex = translate_response_to_codex(request); + let schema = &codex.text.as_ref().unwrap()["format"]["schema"]; + + assert_eq!( + schema["properties"]["point"], + json!({ + "type": "object", + "properties": { + "0": {"type": "number"}, + "1": {"type": "number"} + }, + "required": ["0", "1"], + "additionalProperties": false + }) + ); + assert_eq!( + codex.tuple_schema.unwrap()["properties"]["point"]["type"], + "array" + ); +} + +#[test] +fn openai_response_request_should_forward_parity_fields_to_codex() { + let request = serde_json::from_value::(json!({ + "model": "gpt-5.5-fast", + "stream": false, + "input": [], + "instructions": "be terse", + "reasoning": {"effort": "high"}, + "service_tier": "priority", + "tool_choice": {"type": "function", "function": {"name": "lookup"}}, + "parallel_tool_calls": true, + "tools": [{"type": "function", "name": "lookup"}], + "prompt_cache_key": "pcache", + "include": ["reasoning.encrypted_content"], + "client_metadata": {"safe": "yes", "drop": 42}, + "turnState": "turn-body", + "turnMetadata": "meta-body", + "betaFeatures": "beta-body", + "includeTimingMetrics": "true", + "version": "2026-06-12", + "codexWindowId": "window-body", + "parentThreadId": "parent-body", + "use_websocket": false + })) + .expect("responses request should deserialize"); + + let codex = translate_response_to_codex(request); + + assert_eq!(codex.instructions, "be terse"); + assert_eq!( + codex.reasoning, + Some(json!({"effort": "high", "summary": "auto"})) + ); + assert_eq!(codex.service_tier.as_deref(), Some("priority")); + assert_eq!( + codex.tool_choice, + Some(json!({"type": "function", "function": {"name": "lookup"}})) + ); + assert_eq!(codex.parallel_tool_calls, Some(true)); + assert_eq!( + codex.tools, + Some(vec![json!({"type": "function", "name": "lookup"})]) + ); + assert_eq!(codex.prompt_cache_key.as_deref(), Some("pcache")); + assert!(codex.explicit_prompt_cache_key); + assert_eq!( + codex.include, + Some(vec!["reasoning.encrypted_content".to_string()]) + ); + assert_eq!(codex.client_metadata, Some(json!({"safe": "yes"}))); + assert_eq!(codex.turn_state.as_deref(), Some("turn-body")); + assert_eq!(codex.turn_metadata.as_deref(), Some("meta-body")); + assert_eq!(codex.beta_features.as_deref(), Some("beta-body")); + assert_eq!(codex.include_timing_metrics.as_deref(), Some("true")); + assert_eq!(codex.version.as_deref(), Some("2026-06-12")); + assert_eq!(codex.codex_window_id.as_deref(), Some("window-body")); + assert_eq!(codex.parent_thread_id.as_deref(), Some("parent-body")); + assert!(codex.force_http_sse); +} + +#[test] +fn codex_responses_model_options_should_apply_suffix_defaults_and_include_reasoning() { + let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5-high-fast", "", Vec::new()); + let parsed = ParsedModelName { + model_id: "gpt-5.5".to_string(), + reasoning_effort: Some("high".to_string()), + service_tier: Some("fast".to_string()), + }; + let config = ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: Some("medium".to_string()), + service_tier: Some("flex".to_string()), + aliases: Default::default(), + }; + + apply_response_model_options(&mut request, &parsed, &config); + + assert_eq!(request.model, "gpt-5.5"); + assert_eq!( + request.reasoning, + Some(json!({"summary": "auto", "effort": "high"})) + ); + assert_eq!(request.service_tier.as_deref(), Some("priority")); + assert_eq!( + request.include, + Some(vec!["reasoning.encrypted_content".to_string()]) + ); +} + +#[test] +fn codex_responses_model_options_should_preserve_client_include_and_normalize_body_tier() { + let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "", Vec::new()); + request.reasoning = Some(json!({"summary": "detailed"})); + request.service_tier = Some("fast".to_string()); + request.include = Some(vec!["file_search_call.results".to_string()]); + let parsed = ParsedModelName { + model_id: "gpt-5.5".to_string(), + reasoning_effort: Some("low".to_string()), + service_tier: Some("flex".to_string()), + }; + let config = ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: Some("medium".to_string()), + service_tier: Some("flex".to_string()), + aliases: Default::default(), + }; + + apply_response_model_options(&mut request, &parsed, &config); + + assert_eq!( + request.reasoning, + Some(json!({"summary": "detailed", "effort": "low"})) + ); + assert_eq!(request.service_tier.as_deref(), Some("priority")); + assert_eq!( + request.include, + Some(vec!["file_search_call.results".to_string()]) + ); +} + +#[test] +fn openai_compact_request_should_drop_responses_only_fields_and_sanitize_input() { + let request = serde_json::from_value::(json!({ + "model": "gpt-5.5-fast", + "instructions": "compress the session", + "input": [ + {"role": "user", "content": "hello"}, + { + "type": "reasoning", + "id": "rs_1", + "status": "completed", + "summary": [{"type": "summary_text", "text": "kept"}], + "ignored": "drop" + }, + {"type": "compaction", "encrypted_content": "enc_compact"}, + {"type": "compaction", "id": "drop_missing_encrypted"} + ], + "tools": [{"type": "function", "name": "lookup"}], + "parallel_tool_calls": false, + "reasoning": {"effort": "high", "summary": "auto", "extra": "drop"}, + "text": { + "format": { + "type": "json_schema", + "name": "Compact", + "schema": {"type": "object"}, + "strict": true + } + }, + "stream": true, + "store": true, + "prompt_cache_key": "must_not_forward" + })) + .expect("responses request should deserialize"); + + let compact = translate_response_to_compact(request); + let body = serde_json::to_value(&compact).expect("compact request should serialize"); + + assert_eq!(body["model"], "gpt-5.5-fast"); + assert_eq!(body["instructions"], "compress the session"); + assert_eq!(body["parallel_tool_calls"], false); + assert_eq!( + body["reasoning"], + json!({"effort": "high", "summary": "auto"}) + ); + assert_eq!( + body["tools"], + json!([{"type": "function", "name": "lookup"}]) + ); + assert_eq!(body["text"]["format"]["type"], "json_schema"); + assert!(body.get("stream").is_none()); + assert!(body.get("store").is_none()); + assert!(body.get("prompt_cache_key").is_none()); + assert_eq!(body["input"].as_array().unwrap().len(), 3); + assert!(body["input"][1].get("ignored").is_none()); + assert_eq!(body["input"][2]["encrypted_content"], "enc_compact"); +} + +#[test] +fn openai_streaming_response_with_previous_response_should_require_websocket() { + let request = serde_json::from_value::(json!({ + "model": "gpt-5.5", + "input": "hello", + "previous_response_id": "resp_previous", + "stream": true + })) + .expect("responses request should deserialize"); + + let codex = translate_response_to_codex(request); + + assert_eq!(codex.previous_response_id.as_deref(), Some("resp_previous")); + assert!(codex.stream); + assert!(!codex.force_http_sse); +} diff --git a/crates/core/tests/protocol/protocol_usage_rate_limits.rs b/crates/core/tests/protocol/protocol_usage_rate_limits.rs new file mode 100644 index 000000000..9e7eb9814 --- /dev/null +++ b/crates/core/tests/protocol/protocol_usage_rate_limits.rs @@ -0,0 +1,372 @@ +use super::*; + +#[test] +fn extract_usage_should_read_codex_usage_shape() { + let body = json!({ + "usage": { + "input_tokens": 12, + "output_tokens": 5, + "input_tokens_details": { + "cached_tokens": 3 + } + } + }); + + let usage = extract_usage(&body).expect("usage should exist"); + + assert_eq!( + usage, + TokenUsage { + input_tokens: 12, + output_tokens: 5, + cached_tokens: 3, + image_input_tokens: 0, + image_output_tokens: 0, + total_tokens: 17, + } + ); +} + +#[test] +fn extract_usage_should_read_image_generation_tokens_separately() { + let body = json!({ + "usage": { + "input_tokens": 12, + "output_tokens": 5, + "input_tokens_details": { + "cached_tokens": 3 + } + }, + "tool_usage": { + "image_gen": { + "input_tokens": 31, + "output_tokens": 9 + } + } + }); + + let usage = extract_usage(&body).expect("usage should exist"); + + assert_eq!( + usage, + TokenUsage { + input_tokens: 12, + output_tokens: 5, + cached_tokens: 3, + image_input_tokens: 31, + image_output_tokens: 9, + total_tokens: 17, + } + ); +} + +#[test] +fn extract_usage_should_merge_openai_usage_shape() { + let first = extract_usage(&json!({ + "usage": { + "prompt_tokens": 8, + "completion_tokens": 4, + "prompt_tokens_details": { + "cached_tokens": 2 + } + } + })) + .expect("usage should exist"); + let second = TokenUsage { + input_tokens: 1, + output_tokens: 2, + cached_tokens: 0, + image_input_tokens: 0, + image_output_tokens: 0, + total_tokens: 3, + }; + + assert_eq!( + first.merged(second), + TokenUsage { + input_tokens: 9, + output_tokens: 6, + cached_tokens: 2, + image_input_tokens: 0, + image_output_tokens: 0, + total_tokens: 15, + } + ); +} + +#[test] +fn extract_sse_usage_should_prefer_completed_response_usage() { + let body = concat!( + "event: response.created\n", + "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n", + "event: response.completed\n", + "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n", + ); + + let usage = extract_sse_usage(body) + .expect("usage extraction should succeed") + .expect("usage should exist"); + + assert_eq!( + usage, + TokenUsage { + input_tokens: 3, + output_tokens: 5, + cached_tokens: 1, + image_input_tokens: 0, + image_output_tokens: 0, + total_tokens: 8, + } + ); +} + +#[test] +fn extract_sse_usage_should_read_completed_image_generation_tokens() { + let body = concat!( + "event: response.completed\n", + "data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":3}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n", + ); + + let usage = extract_sse_usage(body) + .expect("usage extraction should succeed") + .expect("usage should exist"); + + assert_eq!( + usage, + TokenUsage { + input_tokens: 12, + output_tokens: 5, + cached_tokens: 3, + image_input_tokens: 31, + image_output_tokens: 9, + total_tokens: 17, + } + ); +} + +#[test] +fn retry_after_seconds_from_body_should_read_structured_retry_delay() { + let body = json!({ + "response": { + "error": { + "resets_in_seconds": 45 + } + } + }) + .to_string(); + + assert_eq!(retry_after_seconds_from_body(&body), Some(45)); +} + +#[test] +fn retry_after_seconds_from_body_should_parse_rate_limit_message_seconds() { + let body = json!({ + "response": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit reached. Please try again in 11.054s." + } + } + }) + .to_string(); + + assert_eq!(retry_after_seconds_from_body(&body), Some(12)); +} + +#[test] +fn retry_after_seconds_from_body_should_parse_rate_limit_message_milliseconds() { + let body = json!({ + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit reached. Please try again in 28ms." + } + }) + .to_string(); + + assert_eq!(retry_after_seconds_from_body(&body), Some(1)); +} + +#[test] +fn retry_after_seconds_from_body_should_ignore_retry_message_for_other_codes() { + let body = json!({ + "response": { + "error": { + "code": "upstream_transient_error", + "message": "Try again in 35 seconds." + } + } + }) + .to_string(); + + assert_eq!(retry_after_seconds_from_body(&body), None); +} + +#[test] +fn parse_rate_limit_headers_should_extract_primary_secondary_and_review_windows() { + let headers = vec![ + ( + "x-codex-primary-used-percent".to_string(), + "100".to_string(), + ), + ( + "x-codex-primary-window-minutes".to_string(), + "5".to_string(), + ), + ( + "x-codex-primary-reset-at".to_string(), + "1893456300".to_string(), + ), + ( + "x-codex-secondary-used-percent".to_string(), + "42.5".to_string(), + ), + ( + "x-codex-secondary-window-minutes".to_string(), + "10080".to_string(), + ), + ( + "x-codex-code-review-primary-used-percent".to_string(), + "80".to_string(), + ), + ( + "x-codex-code-review-primary-reset-at".to_string(), + "1893456600".to_string(), + ), + ]; + + let parsed = parse_rate_limit_headers(&headers).expect("rate limits should parse"); + + assert_eq!( + parsed.primary, + Some(RateLimitWindow { + used_percent: 100.0, + window_minutes: Some(5), + reset_at: Some(1_893_456_300), + }) + ); + assert_eq!( + parsed.secondary.expect("secondary window").window_minutes, + Some(10080) + ); + assert_eq!( + parsed + .code_review + .expect("review window") + .primary + .expect("review primary") + .reset_at, + Some(1_893_456_600) + ); +} + +#[test] +fn parse_rate_limits_event_should_extract_internal_websocket_rate_limits() { + let event = json!({ + "type": "codex.rate_limits", + "rate_limits": { + "primary": { + "used_percent": 99.5, + "window_minutes": 300, + "reset_at": 1893456300 + }, + "secondary": { + "used_percent": 10, + "window_minutes": 10080, + "reset_at": 1894056000 + } + } + }); + + let parsed = parse_rate_limits_event(&event).expect("event should parse"); + + assert_eq!(parsed.primary.expect("primary window").used_percent, 99.5); + assert_eq!( + parsed.secondary.expect("secondary window").reset_at, + Some(1_894_056_000) + ); +} + +#[test] +fn rate_limit_quota_should_preserve_existing_credits_when_passive_data_lacks_credits() { + let headers = vec![ + ("x-codex-primary-used-percent".to_string(), "25".to_string()), + ( + "x-codex-primary-window-minutes".to_string(), + "5".to_string(), + ), + ( + "x-codex-primary-reset-at".to_string(), + "1893456300".to_string(), + ), + ]; + let existing = json!({ + "credits": { + "has_credits": true, + "unlimited": false, + "balance": 12 + } + }); + let parsed = parse_rate_limit_headers(&headers).expect("rate limits should parse"); + + let quota = rate_limit_quota(&parsed, Some("plus"), Some(&existing)); + + assert_eq!(quota["plan_type"], "plus"); + assert_eq!(quota["rate_limit"]["remaining_percent"], 75); + assert_eq!(quota["credits"]["balance"], 12); +} + +#[test] +fn cooldown_with_jitter_should_return_positive_duration_within_expected_range() { + let duration = cooldown_with_jitter(60, 2_000); + + assert!((48..=72).contains(&duration.num_seconds())); +} + +#[tokio::test] +async fn refresh_scheduler_should_refresh_before_expiry_and_preserve_refresh_token() { + use async_trait::async_trait; + use chrono::{Duration as ChronoDuration, Utc}; + use codex_proxy_core::accounts::model::{Account, AccountStatus}; + use codex_proxy_core::auth::oauth::{ + RefreshFailure, RefreshPolicy, RefreshScheduler, RefreshTrigger, TokenPair, + }; + use codex_proxy_core::auth::ports::TokenRefresher; + + #[derive(Clone)] + struct StaticRefreshClient { + result: Result, + } + + #[async_trait] + impl TokenRefresher for StaticRefreshClient { + async fn refresh(&self, _refresh_token: &str) -> Result { + self.result.clone() + } + } + + let now = Utc::now(); + let mut account = Account::test("acct_1", AccountStatus::Active); + account.access_token_expires_at = Some(now + ChronoDuration::seconds(60)); + account.refresh_token = Some("rt_keep".to_string()); + let scheduler = RefreshScheduler::new( + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 2, + }, + StaticRefreshClient { + result: Ok(TokenPair { + access_token: "new-access".to_string(), + refresh_token: None, + }), + }, + ); + + let refreshed = scheduler + .refresh_account_at(&account, RefreshTrigger::BeforeExpiry, now) + .await + .expect("refresh should succeed"); + + assert_eq!(refreshed.access_token, "new-access"); + assert_eq!(refreshed.refresh_token.as_deref(), Some("rt_keep")); + assert_eq!(refreshed.status, AccountStatus::Active); +} diff --git a/crates/platform/src/storage/paths.rs b/crates/platform/src/storage/paths.rs index fc84d99d0..cda2da6f6 100644 --- a/crates/platform/src/storage/paths.rs +++ b/crates/platform/src/storage/paths.rs @@ -92,67 +92,3 @@ fn parse_installation_id(raw: &str) -> Option { fn generate_installation_id() -> String { Uuid::new_v4().to_string() } - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - use uuid::Uuid; - - use super::*; - - #[test] - fn resolve_installation_id_should_prefer_codex_desktop_file() { - let temp = TempDir::new().expect("tempdir should be created"); - let codex_path = temp.path().join(".codex").join("installation_id"); - let data_path = temp.path().join("data").join("installation_id"); - fs::create_dir_all(codex_path.parent().expect("codex path should have parent")) - .expect("codex parent should be created"); - fs::create_dir_all(data_path.parent().expect("data path should have parent")) - .expect("data parent should be created"); - let codex_id = Uuid::new_v4().to_string(); - let data_id = Uuid::new_v4().to_string(); - fs::write(&codex_path, &codex_id).expect("codex id should be written"); - fs::write(&data_path, data_id).expect("data id should be written"); - - let resolved = - load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path)) - .expect("installation id should resolve"); - - assert_eq!(resolved, codex_id); - } - - #[test] - fn resolve_installation_id_should_read_data_file_when_codex_file_is_absent() { - let temp = TempDir::new().expect("tempdir should be created"); - let codex_path = temp.path().join(".codex").join("installation_id"); - let data_path = temp.path().join("data").join("installation_id"); - fs::create_dir_all(data_path.parent().expect("data path should have parent")) - .expect("data parent should be created"); - let data_id = Uuid::new_v4().to_string(); - fs::write(&data_path, &data_id).expect("data id should be written"); - - let resolved = - load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path)) - .expect("installation id should resolve"); - - assert_eq!(resolved, data_id); - } - - #[test] - fn resolve_installation_id_should_generate_and_persist_when_files_are_absent() { - let temp = TempDir::new().expect("tempdir should be created"); - let codex_path = temp.path().join(".codex").join("installation_id"); - let data_path = temp.path().join("data").join("installation_id"); - - let resolved = - load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path)) - .expect("installation id should resolve"); - - assert_eq!( - fs::read_to_string(&data_path).expect("generated id should be persisted"), - resolved - ); - } -} diff --git a/crates/platform/tests/config.rs b/crates/platform/tests/config.rs index 32c49eb9c..e6ced9a94 100644 --- a/crates/platform/tests/config.rs +++ b/crates/platform/tests/config.rs @@ -1,9 +1,66 @@ use codex_proxy_platform::config::{AppConfig, LoggingConfig}; use std::fs; +const DEFAULT_CONFIG_YAML: &str = r#" +server: + host: 0.0.0.0 + port: 8080 +api: + base_url: https://chatgpt.com/backend-api +model: + default: gpt-5.5 + default_reasoning_effort: null + default_service_tier: null + aliases: {} +auth: + refresh_margin_seconds: 300 + refresh_enabled: true + refresh_concurrency: 2 + max_concurrent_per_account: 3 + request_interval_ms: 50 + rotation_strategy: least_used + tier_priority: [] + oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann + oauth_auth_endpoint: https://auth.openai.com/oauth/authorize + oauth_token_endpoint: https://auth.openai.com/oauth/token +quota: + refresh_interval_minutes: 5 + warning_thresholds: + primary: + - 80 + - 90 + secondary: + - 80 + - 90 + skip_exhausted: true +usage_stats: + history_retention_days: null +database: + url: sqlite://.runtime/data/codex-proxy-rs.sqlite +security: + master_key_file: .runtime/data/master.key + api_key_pepper_file: .runtime/data/api-key-pepper.key +tls: + force_http11: false +ws_pool: + enabled: true + max_age_ms: 3300000 + max_per_account: 8 +admin: + session_ttl_minutes: 1440 + default_username: admin + default_password: admin +logging: + directory: .runtime/logs + retention_days: 14 + enabled: true + capacity: 2000 + capture_body: false +"#; + #[test] fn default_config_keeps_only_codex_backend() { - let cfg: AppConfig = serde_yml::from_str(include_str!("../../../config.yaml")).unwrap(); + let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap(); assert_eq!(cfg.server.host, "0.0.0.0"); assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api"); assert_eq!(cfg.model.default_model, "gpt-5.5"); @@ -25,7 +82,7 @@ fn default_config_keeps_only_codex_backend() { #[test] fn default_config_keeps_runtime_artifacts_under_runtime_directory() { - let cfg: AppConfig = serde_yml::from_str(include_str!("../../../config.yaml")).unwrap(); + let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap(); assert_eq!( cfg.database.url, diff --git a/crates/runtime/tests/tasks.rs b/crates/runtime/tests/tasks.rs index c09f7e797..bbf85f5f1 100644 --- a/crates/runtime/tests/tasks.rs +++ b/crates/runtime/tests/tasks.rs @@ -29,320 +29,14 @@ use wiremock::{ Mock, MockServer, ResponseTemplate, }; -#[tokio::test] -async fn fingerprint_update_task_should_start_background_checker() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("fingerprints.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let repo = FingerprintRepository::new(pool); - - let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( - Some(repo), - "https://example.invalid/appcast.xml".to_string(), - dir.path().join("extracted-fingerprint.json"), - "26.800.1".to_string(), - "6001".to_string(), - ); - - handle.shutdown().await; -} - -#[tokio::test] -async fn fingerprint_update_task_should_apply_initial_appcast_update_to_repository() { - let server = MockServer::start().await; - mount_appcast( - &server, - r#" - - - - - - - - "#, - ) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("fingerprints-initial-update.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let repo = FingerprintRepository::new(pool); - let extracted_path = dir.path().join("extracted-fingerprint.json"); - std::fs::write( - &extracted_path, - r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#, - ) - .expect("extracted fingerprint should be written"); - - let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( - Some(repo.clone()), - format!("{}/appcast.xml", server.uri()), - extracted_path, - "26.800.1".to_string(), - "6001".to_string(), - ); - - let stored = wait_for_auto_updated_fingerprint(&repo).await; - handle.shutdown().await; - - assert_eq!( - ( - stored.app_version.as_str(), - stored.build_number.as_str(), - stored.chromium_version.as_str() - ), - ("26.900.1", "7001", "147") - ); -} - -#[tokio::test] -async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() { - let server = MockServer::start().await; - mount_appcast( - &server, - r#" - - - - - - - - "#, - ) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("fingerprints-no-update.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let repo = FingerprintRepository::new(pool); - let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( - Some(repo.clone()), - format!("{}/appcast.xml", server.uri()), - dir.path().join("missing-extracted-fingerprint.json"), - "26.900.1".to_string(), - "7001".to_string(), - ); - - wait_for_appcast_requests(&server, 1).await; - handle.shutdown().await; - let stored = repo - .load_latest_auto_updated() - .await - .expect("latest auto update should load"); - - assert!(stored.is_none()); -} - -#[tokio::test] -async fn fingerprint_update_task_should_check_appcast_without_repository() { - let server = MockServer::start().await; - mount_appcast( - &server, - r#" - - - - - - - - "#, - ) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( - None, - format!("{}/appcast.xml", server.uri()), - dir.path().join("missing-extracted-fingerprint.json"), - "26.800.1".to_string(), - "6001".to_string(), - ); - - wait_for_appcast_requests(&server, 1).await; - handle.shutdown().await; -} - -#[tokio::test] -async fn model_refresh_task_should_start_and_shutdown() { - let model_service = Arc::new(ModelService::new( - ModelConfig { - default_model: "gpt-5.5".to_string(), - default_reasoning_effort: None, - service_tier: None, - aliases: Default::default(), - }, - None, - None, - None, - )); - let account_store = Arc::new(FakeAccountStore); - - let handle = codex_proxy_runtime::tasks::model_refresh::ModelRefreshTask::new( - model_service, - account_store, - ) - .start(); - - handle.shutdown().await; -} - -#[tokio::test] -async fn cookie_cleanup_task_should_delete_only_expired_cookie_rows() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("cookies.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let secret_box = SecretBox::new([7u8; 32]); - let store = SqliteCookieStore::new(pool.clone(), secret_box); - insert_account(&pool, "acct-cookie").await; - let now = Utc::now(); - insert_cookie( - &pool, - "expired-cookie", - "acct-cookie", - "old", - now - Duration::minutes(1), - ) - .await; - insert_cookie( - &pool, - "active-cookie", - "acct-cookie", - "fresh", - now + Duration::minutes(10), - ) - .await; - - let deleted = codex_proxy_runtime::tasks::cookie_cleanup::CookieCleanupTask::new(store) - .cleanup_once_at(now) - .await - .expect("cookie cleanup should succeed"); - - let remaining = cookie_ids(&pool).await; - - assert_eq!((deleted, remaining), (1, vec!["active-cookie".to_string()])); -} - -#[tokio::test] -async fn session_cleanup_task_should_delete_only_expired_sessions() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("sessions.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAdminSessionStore::new(pool.clone()); - let now = Utc::now(); - insert_admin_user(&pool, "admin").await; - insert_admin_session( - &pool, - "expired-session", - "admin", - now - Duration::minutes(1), - ) - .await; - insert_admin_session( - &pool, - "active-session", - "admin", - now + Duration::minutes(10), - ) - .await; - - let deleted = codex_proxy_runtime::tasks::session_cleanup::SessionCleanupTask::new(store, 3600) - .cleanup_once_at(now) - .await - .expect("session cleanup should succeed"); - - let remaining = admin_session_ids(&pool).await; - - assert_eq!( - (deleted, remaining), - (1, vec!["active-session".to_string()]) - ); -} - -#[tokio::test] -async fn session_affinity_cleanup_task_should_delete_only_expired_affinities() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("session-affinity-cleanup.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteSessionAffinityStore::new(pool.clone()); - let now = Utc::now(); - store - .upsert( - "expired-response", - &session_affinity_entry("expired-account", now - Duration::hours(2)), - Duration::hours(1), - ) - .await - .expect("expired affinity should be inserted"); - store - .upsert( - "active-response", - &session_affinity_entry("active-account", now), - Duration::hours(1), - ) - .await - .expect("active affinity should be inserted"); - - let deleted = - codex_proxy_runtime::tasks::session_affinity_cleanup::SessionAffinityCleanupTask::new( - store, 3600, - ) - .cleanup_once_at(now) - .await - .expect("session affinity cleanup should succeed"); - - let remaining = session_affinity_response_ids(&pool).await; - - assert_eq!( - (deleted, remaining), - (1, vec!["active-response".to_string()]) - ); -} - -#[tokio::test] -async fn start_background_tasks_should_register_migrated_runtime_tasks() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("background-tasks.sqlite"); - let database_url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&database_url).await.expect("sqlite pool"); - let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box( - test_config(database_url), - pool, - SecretBox::new([9u8; 32]), - ); - - let coordinator = codex_proxy_runtime::tasks::coordinator::start_background_tasks(&state).await; - let task_names = coordinator.task_names(); - - assert_eq!( - task_names, - [ - "cookie_cleanup", - "session_cleanup", - "session_affinity_cleanup", - "model_refresh", - "token_refresh", - "quota_refresh", - "fingerprint_update" - ] - ); - - coordinator.shutdown().await; -} +#[path = "tasks/cleanup.rs"] +mod cleanup; +#[path = "tasks/coordinator.rs"] +mod coordinator; +#[path = "tasks/fingerprint.rs"] +mod fingerprint; +#[path = "tasks/model_refresh.rs"] +mod model_refresh; struct FakeAccountStore; diff --git a/crates/runtime/tests/tasks/cleanup.rs b/crates/runtime/tests/tasks/cleanup.rs new file mode 100644 index 000000000..b1af959c1 --- /dev/null +++ b/crates/runtime/tests/tasks/cleanup.rs @@ -0,0 +1,119 @@ +use super::*; + +#[tokio::test] +async fn cookie_cleanup_task_should_delete_only_expired_cookie_rows() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("cookies.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let secret_box = SecretBox::new([7u8; 32]); + let store = SqliteCookieStore::new(pool.clone(), secret_box); + insert_account(&pool, "acct-cookie").await; + let now = Utc::now(); + insert_cookie( + &pool, + "expired-cookie", + "acct-cookie", + "old", + now - Duration::minutes(1), + ) + .await; + insert_cookie( + &pool, + "active-cookie", + "acct-cookie", + "fresh", + now + Duration::minutes(10), + ) + .await; + + let deleted = codex_proxy_runtime::tasks::cookie_cleanup::CookieCleanupTask::new(store) + .cleanup_once_at(now) + .await + .expect("cookie cleanup should succeed"); + + let remaining = cookie_ids(&pool).await; + + assert_eq!((deleted, remaining), (1, vec!["active-cookie".to_string()])); +} + +#[tokio::test] +async fn session_cleanup_task_should_delete_only_expired_sessions() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("sessions.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAdminSessionStore::new(pool.clone()); + let now = Utc::now(); + insert_admin_user(&pool, "admin").await; + insert_admin_session( + &pool, + "expired-session", + "admin", + now - Duration::minutes(1), + ) + .await; + insert_admin_session( + &pool, + "active-session", + "admin", + now + Duration::minutes(10), + ) + .await; + + let deleted = codex_proxy_runtime::tasks::session_cleanup::SessionCleanupTask::new(store, 3600) + .cleanup_once_at(now) + .await + .expect("session cleanup should succeed"); + + let remaining = admin_session_ids(&pool).await; + + assert_eq!( + (deleted, remaining), + (1, vec!["active-session".to_string()]) + ); +} + +#[tokio::test] +async fn session_affinity_cleanup_task_should_delete_only_expired_affinities() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("session-affinity-cleanup.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteSessionAffinityStore::new(pool.clone()); + let now = Utc::now(); + store + .upsert( + "expired-response", + &session_affinity_entry("expired-account", now - Duration::hours(2)), + Duration::hours(1), + ) + .await + .expect("expired affinity should be inserted"); + store + .upsert( + "active-response", + &session_affinity_entry("active-account", now), + Duration::hours(1), + ) + .await + .expect("active affinity should be inserted"); + + let deleted = + codex_proxy_runtime::tasks::session_affinity_cleanup::SessionAffinityCleanupTask::new( + store, 3600, + ) + .cleanup_once_at(now) + .await + .expect("session affinity cleanup should succeed"); + + let remaining = session_affinity_response_ids(&pool).await; + + assert_eq!( + (deleted, remaining), + (1, vec!["active-response".to_string()]) + ); +} diff --git a/crates/runtime/tests/tasks/coordinator.rs b/crates/runtime/tests/tasks/coordinator.rs new file mode 100644 index 000000000..00425d3e3 --- /dev/null +++ b/crates/runtime/tests/tasks/coordinator.rs @@ -0,0 +1,32 @@ +use super::*; + +#[tokio::test] +async fn start_background_tasks_should_register_migrated_runtime_tasks() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("background-tasks.sqlite"); + let database_url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&database_url).await.expect("sqlite pool"); + let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box( + test_config(database_url), + pool, + SecretBox::new([9u8; 32]), + ); + + let coordinator = codex_proxy_runtime::tasks::coordinator::start_background_tasks(&state).await; + let task_names = coordinator.task_names(); + + assert_eq!( + task_names, + [ + "cookie_cleanup", + "session_cleanup", + "session_affinity_cleanup", + "model_refresh", + "token_refresh", + "quota_refresh", + "fingerprint_update" + ] + ); + + coordinator.shutdown().await; +} diff --git a/crates/runtime/tests/tasks/fingerprint.rs b/crates/runtime/tests/tasks/fingerprint.rs new file mode 100644 index 000000000..d5d71f05f --- /dev/null +++ b/crates/runtime/tests/tasks/fingerprint.rs @@ -0,0 +1,143 @@ +use super::*; + +#[tokio::test] +async fn fingerprint_update_task_should_start_background_checker() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = FingerprintRepository::new(pool); + + let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( + Some(repo), + "https://example.invalid/appcast.xml".to_string(), + dir.path().join("extracted-fingerprint.json"), + "26.800.1".to_string(), + "6001".to_string(), + ); + + handle.shutdown().await; +} + +#[tokio::test] +async fn fingerprint_update_task_should_apply_initial_appcast_update_to_repository() { + let server = MockServer::start().await; + mount_appcast( + &server, + r#" + + + + + + + + "#, + ) + .await; + + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints-initial-update.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = FingerprintRepository::new(pool); + let extracted_path = dir.path().join("extracted-fingerprint.json"); + std::fs::write( + &extracted_path, + r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#, + ) + .expect("extracted fingerprint should be written"); + + let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( + Some(repo.clone()), + format!("{}/appcast.xml", server.uri()), + extracted_path, + "26.800.1".to_string(), + "6001".to_string(), + ); + + let stored = wait_for_auto_updated_fingerprint(&repo).await; + handle.shutdown().await; + + assert_eq!( + ( + stored.app_version.as_str(), + stored.build_number.as_str(), + stored.chromium_version.as_str() + ), + ("26.900.1", "7001", "147") + ); +} + +#[tokio::test] +async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() { + let server = MockServer::start().await; + mount_appcast( + &server, + r#" + + + + + + + + "#, + ) + .await; + + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints-no-update.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = FingerprintRepository::new(pool); + let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( + Some(repo.clone()), + format!("{}/appcast.xml", server.uri()), + dir.path().join("missing-extracted-fingerprint.json"), + "26.900.1".to_string(), + "7001".to_string(), + ); + + wait_for_appcast_requests(&server, 1).await; + handle.shutdown().await; + let stored = repo + .load_latest_auto_updated() + .await + .expect("latest auto update should load"); + + assert!(stored.is_none()); +} + +#[tokio::test] +async fn fingerprint_update_task_should_check_appcast_without_repository() { + let server = MockServer::start().await; + mount_appcast( + &server, + r#" + + + + + + + + "#, + ) + .await; + + let dir = tempfile::tempdir().expect("temp dir"); + let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( + None, + format!("{}/appcast.xml", server.uri()), + dir.path().join("missing-extracted-fingerprint.json"), + "26.800.1".to_string(), + "6001".to_string(), + ); + + wait_for_appcast_requests(&server, 1).await; + handle.shutdown().await; +} diff --git a/crates/runtime/tests/tasks/model_refresh.rs b/crates/runtime/tests/tasks/model_refresh.rs new file mode 100644 index 000000000..41e21f1e0 --- /dev/null +++ b/crates/runtime/tests/tasks/model_refresh.rs @@ -0,0 +1,25 @@ +use super::*; + +#[tokio::test] +async fn model_refresh_task_should_start_and_shutdown() { + let model_service = Arc::new(ModelService::new( + ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases: Default::default(), + }, + None, + None, + None, + )); + let account_store = Arc::new(FakeAccountStore); + + let handle = codex_proxy_runtime::tasks::model_refresh::ModelRefreshTask::new( + model_service, + account_store, + ) + .start(); + + handle.shutdown().await; +} diff --git a/crates/runtime/tests/token_refresh.rs b/crates/runtime/tests/token_refresh.rs index 1e3e4260a..5b30fa023 100644 --- a/crates/runtime/tests/token_refresh.rs +++ b/crates/runtime/tests/token_refresh.rs @@ -29,836 +29,12 @@ use tokio::{ time::{sleep, timeout}, }; -#[tokio::test] -async fn token_refresh_task_should_persist_refreshed_access_token_and_keep_refresh_token() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([11u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 18, 8, 0, 0).unwrap(); - let new_expires_at = now + Duration::hours(1); - let new_access_token = test_jwt(new_expires_at.timestamp()); - store - .insert(NewAccount { - id: "acct-refresh".to_string(), - email: Some("user@example.com".to_string()), - account_id: Some("chatgpt-account".to_string()), - user_id: Some("user-id".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new( - test_jwt((now + Duration::seconds(30)).timestamp()).into(), - ), - refresh_token: Some(SecretString::new("refresh-old".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let refresher = StaticTokenRefresher { - response: Arc::new(Mutex::new(Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }))), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("refresh should succeed"); - let stored = store - .get("acct-refresh") - .await - .expect("account should load") - .expect("account should exist"); - - assert_eq!( - ( - summary.refreshed, - stored.access_token.expose_secret(), - stored - .refresh_token - .as_ref() - .map(ExposeSecret::expose_secret), - stored.access_token_expires_at, - stored.status, - ), - ( - 1, - new_access_token.as_str(), - Some("refresh-old"), - Some(new_expires_at), - AccountStatus::Active, - ) - ); -} - -#[tokio::test] -async fn token_refresh_task_should_skip_account_when_refresh_lease_is_held() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-lease.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool.clone(), SecretBox::new([12u8; 32])); - let leases = SqliteRefreshLeaseStore::new(pool.clone()); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 8, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); - store - .insert(NewAccount { - id: "acct-lease-held".to_string(), - email: Some("lease@example.com".to_string()), - account_id: Some("chatgpt-lease".to_string()), - user_id: Some("user-lease".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.clone().into()), - refresh_token: Some(SecretString::new("refresh-held".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - assert!(leases - .try_acquire( - "acct-lease-held", - "external-owner", - now + Duration::minutes(5), - now, - ) - .await - .expect("external owner should acquire lease")); - let refresher = CountingTokenRefresher::default(); - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher.clone(), - ) - .with_refresh_lease_store(leases); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("refresh scan should skip lease-held account"); - let stored = store - .get("acct-lease-held") - .await - .expect("account should load") - .expect("account should exist"); - - assert_eq!(summary.skipped, 1); - assert_eq!(summary.refreshed, 0); - assert_eq!(refresher.calls.load(Ordering::SeqCst), 0); - assert_eq!( - stored.access_token.expose_secret(), - old_access_token.as_str() - ); - assert_eq!(stored.status, AccountStatus::Active); -} - -#[tokio::test] -async fn token_refresh_task_should_mark_due_account_refreshing_before_refresher_call() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-status.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([13u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 9, 0, 0).unwrap(); - let new_expires_at = now + Duration::hours(1); - let new_access_token = test_jwt(new_expires_at.timestamp()); - store - .insert(NewAccount { - id: "acct-refreshing-marker".to_string(), - email: Some("marker@example.com".to_string()), - account_id: Some("chatgpt-marker".to_string()), - user_id: Some("user-marker".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new( - test_jwt((now + Duration::seconds(30)).timestamp()).into(), - ), - refresh_token: Some(SecretString::new("refresh-marker".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let observed_statuses = Arc::new(Mutex::new(Vec::new())); - let refresher = StoreObservingTokenRefresher { - store: store.clone(), - account_id: "acct-refreshing-marker".to_string(), - observed_statuses: observed_statuses.clone(), - response: Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("refresh should succeed"); - let stored = store - .get("acct-refreshing-marker") - .await - .expect("account should load") - .expect("account should exist"); - let observed_statuses = observed_statuses.lock().await.clone(); - - assert_eq!(summary.refreshed, 1); - assert_eq!(observed_statuses, [AccountStatus::Refreshing]); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_recover_refreshing_account_after_restart() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-recovery.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([14u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 10, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::hours(1)).timestamp()); - let new_access_token = test_jwt((now + Duration::hours(2)).timestamp()); - store - .insert(NewAccount { - id: "acct-refreshing-recovery".to_string(), - email: Some("recovery@example.com".to_string()), - account_id: Some("chatgpt-recovery".to_string()), - user_id: Some("user-recovery".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.into()), - refresh_token: Some(SecretString::new("refresh-recovery".to_string().into())), - access_token_expires_at: Some(now + Duration::hours(1)), - status: AccountStatus::Refreshing, - }) - .await - .expect("account should be inserted"); - let refresher = StaticTokenRefresher { - response: Arc::new(Mutex::new(Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }))), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("refreshing account should recover"); - let stored = store - .get("acct-refreshing-recovery") - .await - .expect("account should load") - .expect("account should exist"); - - assert_eq!(summary.refreshed, 1); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_restore_active_status_after_transport_failure() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-transport-failure.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([15u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 11, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-transport".to_string(), - email: Some("transport@example.com".to_string()), - account_id: Some("chatgpt-transport".to_string()), - user_id: Some("user-transport".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.clone().into()), - refresh_token: Some(SecretString::new("refresh-transport".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let observed_statuses = Arc::new(Mutex::new(Vec::new())); - let refresher = StoreObservingTokenRefresher { - store: store.clone(), - account_id: "acct-refresh-transport".to_string(), - observed_statuses: observed_statuses.clone(), - response: Err(RefreshFailure::Transport), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ) - .with_retry_delays(vec![StdDuration::ZERO; 4]); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("transport failure should be summarized"); - let stored = store - .get("acct-refresh-transport") - .await - .expect("account should load") - .expect("account should exist"); - let observed_statuses = observed_statuses.lock().await.clone(); - - assert_eq!(summary.failed, 1); - assert_eq!(observed_statuses, [AccountStatus::Refreshing; 5]); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - old_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_skip_duplicate_in_flight_refresh() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-in-flight.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([18u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 14, 0, 0).unwrap(); - let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-in-flight".to_string(), - email: Some("in-flight@example.com".to_string()), - account_id: Some("chatgpt-in-flight".to_string()), - user_id: Some("user-in-flight".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new( - test_jwt((now + Duration::seconds(30)).timestamp()).into(), - ), - refresh_token: Some(SecretString::new("refresh-in-flight".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let refresher = BlockingTokenRefresher { - calls: Arc::new(AtomicUsize::new(0)), - started: Arc::new(Notify::new()), - release: Arc::new(Notify::new()), - response: Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }), - }; - let task = Arc::new( - codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher.clone(), - ), - ); - - let first = { - let task = task.clone(); - tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await }) - }; - refresher.started.notified().await; - - let second = { - let task = task.clone(); - tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await }) - }; - let second = match timeout(StdDuration::from_millis(100), second).await { - Ok(result) => result.expect("second scan should join"), - Err(_) => { - refresher.release.notify_waiters(); - let _ = first.await; - panic!("second scan should skip instead of waiting on duplicate refresh"); - } - } - .expect("second scan should succeed"); - - refresher.release.notify_waiters(); - let first = first - .await - .expect("first scan should join") - .expect("first scan should succeed"); - let stored = store - .get("acct-refresh-in-flight") - .await - .expect("account should load") - .expect("account should exist"); - - assert_eq!(first.refreshed, 1); - assert_eq!(second.skipped, 1); - assert_eq!(refresher.calls.load(Ordering::SeqCst), 1); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_persist_success_after_transient_transport_retry() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-transient-success.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([16u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 12, 0, 0).unwrap(); - let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-transient-success".to_string(), - email: Some("transient@example.com".to_string()), - account_id: Some("chatgpt-transient".to_string()), - user_id: Some("user-transient".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new( - test_jwt((now + Duration::seconds(30)).timestamp()).into(), - ), - refresh_token: Some(SecretString::new("refresh-transient".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let observed_statuses = Arc::new(Mutex::new(Vec::new())); - let refresher = SequenceTokenRefresher { - store: store.clone(), - account_id: "acct-refresh-transient-success".to_string(), - observed_statuses: observed_statuses.clone(), - responses: Arc::new(Mutex::new(VecDeque::from(vec![ - Err(RefreshFailure::Transport), - Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }), - ]))), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ) - .with_retry_delays(vec![StdDuration::ZERO; 4]); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("retry should eventually refresh"); - let stored = store - .get("acct-refresh-transient-success") - .await - .expect("account should load") - .expect("account should exist"); - let observed_statuses = observed_statuses.lock().await.clone(); - - assert_eq!(summary.refreshed, 1); - assert_eq!(summary.failed, 0); - assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_delay_recovery_after_retry_exhaustion() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-delayed-recovery.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([19u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 15, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); - let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-delayed-recovery".to_string(), - email: Some("delayed-recovery@example.com".to_string()), - account_id: Some("chatgpt-delayed-recovery".to_string()), - user_id: Some("user-delayed-recovery".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.clone().into()), - refresh_token: Some(SecretString::new( - "refresh-delayed-recovery".to_string().into(), - )), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let mut responses = Vec::new(); - responses.extend((0..5).map(|_| Err(RefreshFailure::Transport))); - responses.push(Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - })); - let observed_statuses = Arc::new(Mutex::new(Vec::new())); - let refresher = SequenceTokenRefresher { - store: store.clone(), - account_id: "acct-refresh-delayed-recovery".to_string(), - observed_statuses: observed_statuses.clone(), - responses: Arc::new(Mutex::new(VecDeque::from(responses))), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ) - .with_retry_delays(vec![StdDuration::ZERO; 4]); - - let failed = task - .refresh_due_accounts_once_at(now) - .await - .expect("first scan should summarize retry exhaustion"); - let delayed = task - .refresh_due_accounts_once_at(now + Duration::minutes(5)) - .await - .expect("recovery window should skip refresh"); - let refreshed = task - .refresh_due_accounts_once_at(now + Duration::minutes(10) + Duration::seconds(1)) - .await - .expect("recovery window should allow refresh"); - let stored = store - .get("acct-refresh-delayed-recovery") - .await - .expect("account should load") - .expect("account should exist"); - let observed_statuses = observed_statuses.lock().await.clone(); - - assert_eq!(failed.failed, 1); - assert_eq!(delayed.skipped, 1); - assert_eq!(refreshed.refreshed, 1); - assert_eq!(observed_statuses, [AccountStatus::Refreshing; 6]); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_schedule_future_per_account_timer_without_refreshing_early() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-future-timer.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([20u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 16, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-future-timer".to_string(), - email: Some("future-timer@example.com".to_string()), - account_id: Some("chatgpt-future-timer".to_string()), - user_id: Some("user-future-timer".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.clone().into()), - refresh_token: Some(SecretString::new("refresh-future-timer".to_string().into())), - access_token_expires_at: Some(now + Duration::minutes(10)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let refresher = CountingTokenRefresher::default(); - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store, - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher.clone(), - ); - - let summary = task - .schedule_account_timers_once_at(now) - .await - .expect("timer scheduling should succeed"); - tokio::task::yield_now().await; - - assert_eq!(summary.scheduled, 1); - assert_eq!(task.scheduled_timer_count().await, 1); - assert_eq!(refresher.calls.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn token_refresh_task_should_fire_per_account_timer_at_refresh_time() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-per-account-timer.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([21u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 17, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::seconds(1)).timestamp()); - let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-timer".to_string(), - email: Some("timer@example.com".to_string()), - account_id: Some("chatgpt-timer".to_string()), - user_id: Some("user-timer".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.clone().into()), - refresh_token: Some(SecretString::new("refresh-timer".to_string().into())), - access_token_expires_at: Some(now + Duration::seconds(1)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let refresher = NotifyingTokenRefresher { - calls: Arc::new(AtomicUsize::new(0)), - started: Arc::new(Notify::new()), - response: Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 1, - refresh_concurrency: 1, - }, - refresher.clone(), - ) - .with_retry_delays(vec![StdDuration::ZERO; 4]); - - let started = refresher.started.notified(); - tokio::pin!(started); - let summary = task - .schedule_account_timers_once_at(now) - .await - .expect("timer scheduling should succeed"); - timeout(StdDuration::from_secs(2), &mut started) - .await - .expect("scheduled timer should trigger refresh"); - tokio::task::yield_now().await; - let stored = store - .get("acct-refresh-timer") - .await - .expect("account should load") - .expect("account should exist"); - - assert_eq!(summary.immediate, 1); - assert_eq!(refresher.calls.load(Ordering::SeqCst), 1); - assert_eq!(task.scheduled_timer_count().await, 1); - assert_eq!(stored.status, AccountStatus::Active); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); -} - -#[tokio::test] -async fn token_refresh_task_should_reschedule_next_timer_after_scheduled_refresh() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-next-timer.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([22u8; 32])); - let now = Utc::now(); - let old_expires_at = now + Duration::milliseconds(50); - let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-next-timer".to_string(), - email: Some("next-timer@example.com".to_string()), - account_id: Some("chatgpt-next-timer".to_string()), - user_id: Some("user-next-timer".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(test_jwt(old_expires_at.timestamp()).into()), - refresh_token: Some(SecretString::new("refresh-next-timer".to_string().into())), - access_token_expires_at: Some(old_expires_at), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let refresher = NotifyingTokenRefresher { - calls: Arc::new(AtomicUsize::new(0)), - started: Arc::new(Notify::new()), - response: Ok(TokenPair { - access_token: new_access_token.clone(), - refresh_token: None, - }), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 0, - refresh_concurrency: 1, - }, - refresher.clone(), - ) - .with_retry_delays(vec![StdDuration::ZERO; 4]); - - let started = refresher.started.notified(); - tokio::pin!(started); - let summary = task - .schedule_account_timers_once_at(now) - .await - .expect("timer scheduling should succeed"); - timeout(StdDuration::from_secs(2), &mut started) - .await - .expect("scheduled timer should trigger refresh"); - - let mut stored = store - .get("acct-refresh-next-timer") - .await - .expect("account should load") - .expect("account should exist"); - for _ in 0..50 { - if stored.access_token.expose_secret() == new_access_token.as_str() { - break; - } - sleep(StdDuration::from_millis(20)).await; - stored = store - .get("acct-refresh-next-timer") - .await - .expect("account should load") - .expect("account should exist"); - } - - assert_eq!(summary.scheduled, 1); - assert_eq!(refresher.calls.load(Ordering::SeqCst), 1); - assert_eq!( - stored.access_token.expose_secret(), - new_access_token.as_str() - ); - assert_eq!(task.scheduled_timer_count().await, 1); -} - -#[tokio::test] -async fn token_refresh_task_should_confirm_invalid_grant_before_expiring_account() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("token-refresh-permanent-failure.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let store = SqliteAccountStore::new(pool, SecretBox::new([17u8; 32])); - let now = Utc.with_ymd_and_hms(2026, 6, 19, 13, 0, 0).unwrap(); - let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); - store - .insert(NewAccount { - id: "acct-refresh-invalid-grant".to_string(), - email: Some("invalid-grant@example.com".to_string()), - account_id: Some("chatgpt-invalid-grant".to_string()), - user_id: Some("user-invalid-grant".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new(old_access_token.clone().into()), - refresh_token: Some(SecretString::new( - "refresh-invalid-grant".to_string().into(), - )), - access_token_expires_at: Some(now + Duration::seconds(30)), - status: AccountStatus::Active, - }) - .await - .expect("account should be inserted"); - let observed_statuses = Arc::new(Mutex::new(Vec::new())); - let refresher = SequenceTokenRefresher { - store: store.clone(), - account_id: "acct-refresh-invalid-grant".to_string(), - observed_statuses: observed_statuses.clone(), - responses: Arc::new(Mutex::new(VecDeque::from(vec![ - Err(RefreshFailure::InvalidGrant), - Err(RefreshFailure::InvalidGrant), - ]))), - }; - let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( - store.clone(), - RefreshPolicy { - refresh_margin_seconds: 300, - refresh_concurrency: 1, - }, - refresher, - ) - .with_retry_delays(vec![StdDuration::ZERO; 4]); - - let summary = task - .refresh_due_accounts_once_at(now) - .await - .expect("confirmed permanent failure should update status"); - let stored = store - .get("acct-refresh-invalid-grant") - .await - .expect("account should load") - .expect("account should exist"); - let observed_statuses = observed_statuses.lock().await.clone(); - - assert_eq!(summary.status_updated, 1); - assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]); - assert_eq!(stored.status, AccountStatus::Expired); - assert_eq!( - stored.access_token.expose_secret(), - old_access_token.as_str() - ); -} +#[path = "token_refresh/token_refresh_failures.rs"] +mod token_refresh_failures; +#[path = "token_refresh/token_refresh_scheduling.rs"] +mod token_refresh_scheduling; +#[path = "token_refresh/token_refresh_success.rs"] +mod token_refresh_success; #[derive(Clone)] struct StaticTokenRefresher { diff --git a/crates/runtime/tests/token_refresh/token_refresh_failures.rs b/crates/runtime/tests/token_refresh/token_refresh_failures.rs new file mode 100644 index 000000000..8b6315286 --- /dev/null +++ b/crates/runtime/tests/token_refresh/token_refresh_failures.rs @@ -0,0 +1,337 @@ +use super::*; + +#[tokio::test] +async fn token_refresh_task_should_mark_due_account_refreshing_before_refresher_call() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-status.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([13u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 9, 0, 0).unwrap(); + let new_expires_at = now + Duration::hours(1); + let new_access_token = test_jwt(new_expires_at.timestamp()); + store + .insert(NewAccount { + id: "acct-refreshing-marker".to_string(), + email: Some("marker@example.com".to_string()), + account_id: Some("chatgpt-marker".to_string()), + user_id: Some("user-marker".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new( + test_jwt((now + Duration::seconds(30)).timestamp()).into(), + ), + refresh_token: Some(SecretString::new("refresh-marker".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let observed_statuses = Arc::new(Mutex::new(Vec::new())); + let refresher = StoreObservingTokenRefresher { + store: store.clone(), + account_id: "acct-refreshing-marker".to_string(), + observed_statuses: observed_statuses.clone(), + response: Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("refresh should succeed"); + let stored = store + .get("acct-refreshing-marker") + .await + .expect("account should load") + .expect("account should exist"); + let observed_statuses = observed_statuses.lock().await.clone(); + + assert_eq!(summary.refreshed, 1); + assert_eq!(observed_statuses, [AccountStatus::Refreshing]); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); +} + +#[tokio::test] +async fn token_refresh_task_should_recover_refreshing_account_after_restart() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-recovery.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([14u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 10, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::hours(1)).timestamp()); + let new_access_token = test_jwt((now + Duration::hours(2)).timestamp()); + store + .insert(NewAccount { + id: "acct-refreshing-recovery".to_string(), + email: Some("recovery@example.com".to_string()), + account_id: Some("chatgpt-recovery".to_string()), + user_id: Some("user-recovery".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.into()), + refresh_token: Some(SecretString::new("refresh-recovery".to_string().into())), + access_token_expires_at: Some(now + Duration::hours(1)), + status: AccountStatus::Refreshing, + }) + .await + .expect("account should be inserted"); + let refresher = StaticTokenRefresher { + response: Arc::new(Mutex::new(Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }))), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("refreshing account should recover"); + let stored = store + .get("acct-refreshing-recovery") + .await + .expect("account should load") + .expect("account should exist"); + + assert_eq!(summary.refreshed, 1); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); +} + +#[tokio::test] +async fn token_refresh_task_should_restore_active_status_after_transport_failure() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-transport-failure.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([15u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 11, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-transport".to_string(), + email: Some("transport@example.com".to_string()), + account_id: Some("chatgpt-transport".to_string()), + user_id: Some("user-transport".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.clone().into()), + refresh_token: Some(SecretString::new("refresh-transport".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let observed_statuses = Arc::new(Mutex::new(Vec::new())); + let refresher = StoreObservingTokenRefresher { + store: store.clone(), + account_id: "acct-refresh-transport".to_string(), + observed_statuses: observed_statuses.clone(), + response: Err(RefreshFailure::Transport), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ) + .with_retry_delays(vec![StdDuration::ZERO; 4]); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("transport failure should be summarized"); + let stored = store + .get("acct-refresh-transport") + .await + .expect("account should load") + .expect("account should exist"); + let observed_statuses = observed_statuses.lock().await.clone(); + + assert_eq!(summary.failed, 1); + assert_eq!(observed_statuses, [AccountStatus::Refreshing; 5]); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + old_access_token.as_str() + ); +} + +#[tokio::test] +async fn token_refresh_task_should_delay_recovery_after_retry_exhaustion() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-delayed-recovery.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([19u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 15, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); + let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-delayed-recovery".to_string(), + email: Some("delayed-recovery@example.com".to_string()), + account_id: Some("chatgpt-delayed-recovery".to_string()), + user_id: Some("user-delayed-recovery".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.clone().into()), + refresh_token: Some(SecretString::new( + "refresh-delayed-recovery".to_string().into(), + )), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let mut responses = Vec::new(); + responses.extend((0..5).map(|_| Err(RefreshFailure::Transport))); + responses.push(Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + })); + let observed_statuses = Arc::new(Mutex::new(Vec::new())); + let refresher = SequenceTokenRefresher { + store: store.clone(), + account_id: "acct-refresh-delayed-recovery".to_string(), + observed_statuses: observed_statuses.clone(), + responses: Arc::new(Mutex::new(VecDeque::from(responses))), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ) + .with_retry_delays(vec![StdDuration::ZERO; 4]); + + let failed = task + .refresh_due_accounts_once_at(now) + .await + .expect("first scan should summarize retry exhaustion"); + let delayed = task + .refresh_due_accounts_once_at(now + Duration::minutes(5)) + .await + .expect("recovery window should skip refresh"); + let refreshed = task + .refresh_due_accounts_once_at(now + Duration::minutes(10) + Duration::seconds(1)) + .await + .expect("recovery window should allow refresh"); + let stored = store + .get("acct-refresh-delayed-recovery") + .await + .expect("account should load") + .expect("account should exist"); + let observed_statuses = observed_statuses.lock().await.clone(); + + assert_eq!(failed.failed, 1); + assert_eq!(delayed.skipped, 1); + assert_eq!(refreshed.refreshed, 1); + assert_eq!(observed_statuses, [AccountStatus::Refreshing; 6]); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); +} + +#[tokio::test] +async fn token_refresh_task_should_confirm_invalid_grant_before_expiring_account() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-permanent-failure.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([17u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 13, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-invalid-grant".to_string(), + email: Some("invalid-grant@example.com".to_string()), + account_id: Some("chatgpt-invalid-grant".to_string()), + user_id: Some("user-invalid-grant".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.clone().into()), + refresh_token: Some(SecretString::new( + "refresh-invalid-grant".to_string().into(), + )), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let observed_statuses = Arc::new(Mutex::new(Vec::new())); + let refresher = SequenceTokenRefresher { + store: store.clone(), + account_id: "acct-refresh-invalid-grant".to_string(), + observed_statuses: observed_statuses.clone(), + responses: Arc::new(Mutex::new(VecDeque::from(vec![ + Err(RefreshFailure::InvalidGrant), + Err(RefreshFailure::InvalidGrant), + ]))), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ) + .with_retry_delays(vec![StdDuration::ZERO; 4]); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("confirmed permanent failure should update status"); + let stored = store + .get("acct-refresh-invalid-grant") + .await + .expect("account should load") + .expect("account should exist"); + let observed_statuses = observed_statuses.lock().await.clone(); + + assert_eq!(summary.status_updated, 1); + assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]); + assert_eq!(stored.status, AccountStatus::Expired); + assert_eq!( + stored.access_token.expose_secret(), + old_access_token.as_str() + ); +} diff --git a/crates/runtime/tests/token_refresh/token_refresh_scheduling.rs b/crates/runtime/tests/token_refresh/token_refresh_scheduling.rs new file mode 100644 index 000000000..245d7f63f --- /dev/null +++ b/crates/runtime/tests/token_refresh/token_refresh_scheduling.rs @@ -0,0 +1,351 @@ +use super::*; + +#[tokio::test] +async fn token_refresh_task_should_skip_account_when_refresh_lease_is_held() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-lease.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool.clone(), SecretBox::new([12u8; 32])); + let leases = SqliteRefreshLeaseStore::new(pool.clone()); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 8, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp()); + store + .insert(NewAccount { + id: "acct-lease-held".to_string(), + email: Some("lease@example.com".to_string()), + account_id: Some("chatgpt-lease".to_string()), + user_id: Some("user-lease".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.clone().into()), + refresh_token: Some(SecretString::new("refresh-held".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + assert!(leases + .try_acquire( + "acct-lease-held", + "external-owner", + now + Duration::minutes(5), + now, + ) + .await + .expect("external owner should acquire lease")); + let refresher = CountingTokenRefresher::default(); + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher.clone(), + ) + .with_refresh_lease_store(leases); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("refresh scan should skip lease-held account"); + let stored = store + .get("acct-lease-held") + .await + .expect("account should load") + .expect("account should exist"); + + assert_eq!(summary.skipped, 1); + assert_eq!(summary.refreshed, 0); + assert_eq!(refresher.calls.load(Ordering::SeqCst), 0); + assert_eq!( + stored.access_token.expose_secret(), + old_access_token.as_str() + ); + assert_eq!(stored.status, AccountStatus::Active); +} + +#[tokio::test] +async fn token_refresh_task_should_skip_duplicate_in_flight_refresh() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-in-flight.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([18u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 14, 0, 0).unwrap(); + let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-in-flight".to_string(), + email: Some("in-flight@example.com".to_string()), + account_id: Some("chatgpt-in-flight".to_string()), + user_id: Some("user-in-flight".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new( + test_jwt((now + Duration::seconds(30)).timestamp()).into(), + ), + refresh_token: Some(SecretString::new("refresh-in-flight".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let refresher = BlockingTokenRefresher { + calls: Arc::new(AtomicUsize::new(0)), + started: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + response: Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }), + }; + let task = Arc::new( + codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher.clone(), + ), + ); + + let first = { + let task = task.clone(); + tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await }) + }; + refresher.started.notified().await; + + let second = { + let task = task.clone(); + tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await }) + }; + let second = match timeout(StdDuration::from_millis(100), second).await { + Ok(result) => result.expect("second scan should join"), + Err(_) => { + refresher.release.notify_waiters(); + let _ = first.await; + panic!("second scan should skip instead of waiting on duplicate refresh"); + } + } + .expect("second scan should succeed"); + + refresher.release.notify_waiters(); + let first = first + .await + .expect("first scan should join") + .expect("first scan should succeed"); + let stored = store + .get("acct-refresh-in-flight") + .await + .expect("account should load") + .expect("account should exist"); + + assert_eq!(first.refreshed, 1); + assert_eq!(second.skipped, 1); + assert_eq!(refresher.calls.load(Ordering::SeqCst), 1); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); +} + +#[tokio::test] +async fn token_refresh_task_should_schedule_future_per_account_timer_without_refreshing_early() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-future-timer.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([20u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 16, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-future-timer".to_string(), + email: Some("future-timer@example.com".to_string()), + account_id: Some("chatgpt-future-timer".to_string()), + user_id: Some("user-future-timer".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.clone().into()), + refresh_token: Some(SecretString::new("refresh-future-timer".to_string().into())), + access_token_expires_at: Some(now + Duration::minutes(10)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let refresher = CountingTokenRefresher::default(); + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store, + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher.clone(), + ); + + let summary = task + .schedule_account_timers_once_at(now) + .await + .expect("timer scheduling should succeed"); + tokio::task::yield_now().await; + + assert_eq!(summary.scheduled, 1); + assert_eq!(task.scheduled_timer_count().await, 1); + assert_eq!(refresher.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn token_refresh_task_should_fire_per_account_timer_at_refresh_time() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-per-account-timer.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([21u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 17, 0, 0).unwrap(); + let old_access_token = test_jwt((now + Duration::seconds(1)).timestamp()); + let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-timer".to_string(), + email: Some("timer@example.com".to_string()), + account_id: Some("chatgpt-timer".to_string()), + user_id: Some("user-timer".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(old_access_token.clone().into()), + refresh_token: Some(SecretString::new("refresh-timer".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(1)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let refresher = NotifyingTokenRefresher { + calls: Arc::new(AtomicUsize::new(0)), + started: Arc::new(Notify::new()), + response: Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 1, + refresh_concurrency: 1, + }, + refresher.clone(), + ) + .with_retry_delays(vec![StdDuration::ZERO; 4]); + + let started = refresher.started.notified(); + tokio::pin!(started); + let summary = task + .schedule_account_timers_once_at(now) + .await + .expect("timer scheduling should succeed"); + timeout(StdDuration::from_secs(2), &mut started) + .await + .expect("scheduled timer should trigger refresh"); + tokio::task::yield_now().await; + let stored = store + .get("acct-refresh-timer") + .await + .expect("account should load") + .expect("account should exist"); + + assert_eq!(summary.immediate, 1); + assert_eq!(refresher.calls.load(Ordering::SeqCst), 1); + assert_eq!(task.scheduled_timer_count().await, 1); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); +} + +#[tokio::test] +async fn token_refresh_task_should_reschedule_next_timer_after_scheduled_refresh() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-next-timer.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([22u8; 32])); + let now = Utc::now(); + let old_expires_at = now + Duration::milliseconds(50); + let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-next-timer".to_string(), + email: Some("next-timer@example.com".to_string()), + account_id: Some("chatgpt-next-timer".to_string()), + user_id: Some("user-next-timer".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new(test_jwt(old_expires_at.timestamp()).into()), + refresh_token: Some(SecretString::new("refresh-next-timer".to_string().into())), + access_token_expires_at: Some(old_expires_at), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let refresher = NotifyingTokenRefresher { + calls: Arc::new(AtomicUsize::new(0)), + started: Arc::new(Notify::new()), + response: Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 0, + refresh_concurrency: 1, + }, + refresher.clone(), + ) + .with_retry_delays(vec![StdDuration::ZERO; 4]); + + let started = refresher.started.notified(); + tokio::pin!(started); + let summary = task + .schedule_account_timers_once_at(now) + .await + .expect("timer scheduling should succeed"); + timeout(StdDuration::from_secs(2), &mut started) + .await + .expect("scheduled timer should trigger refresh"); + + let mut stored = store + .get("acct-refresh-next-timer") + .await + .expect("account should load") + .expect("account should exist"); + for _ in 0..50 { + if stored.access_token.expose_secret() == new_access_token.as_str() { + break; + } + sleep(StdDuration::from_millis(20)).await; + stored = store + .get("acct-refresh-next-timer") + .await + .expect("account should load") + .expect("account should exist"); + } + + assert_eq!(summary.scheduled, 1); + assert_eq!(refresher.calls.load(Ordering::SeqCst), 1); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); + assert_eq!(task.scheduled_timer_count().await, 1); +} diff --git a/crates/runtime/tests/token_refresh/token_refresh_success.rs b/crates/runtime/tests/token_refresh/token_refresh_success.rs new file mode 100644 index 000000000..07db54e86 --- /dev/null +++ b/crates/runtime/tests/token_refresh/token_refresh_success.rs @@ -0,0 +1,146 @@ +use super::*; + +#[tokio::test] +async fn token_refresh_task_should_persist_refreshed_access_token_and_keep_refresh_token() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([11u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 18, 8, 0, 0).unwrap(); + let new_expires_at = now + Duration::hours(1); + let new_access_token = test_jwt(new_expires_at.timestamp()); + store + .insert(NewAccount { + id: "acct-refresh".to_string(), + email: Some("user@example.com".to_string()), + account_id: Some("chatgpt-account".to_string()), + user_id: Some("user-id".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new( + test_jwt((now + Duration::seconds(30)).timestamp()).into(), + ), + refresh_token: Some(SecretString::new("refresh-old".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let refresher = StaticTokenRefresher { + response: Arc::new(Mutex::new(Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }))), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("refresh should succeed"); + let stored = store + .get("acct-refresh") + .await + .expect("account should load") + .expect("account should exist"); + + assert_eq!( + ( + summary.refreshed, + stored.access_token.expose_secret(), + stored + .refresh_token + .as_ref() + .map(ExposeSecret::expose_secret), + stored.access_token_expires_at, + stored.status, + ), + ( + 1, + new_access_token.as_str(), + Some("refresh-old"), + Some(new_expires_at), + AccountStatus::Active, + ) + ); +} + +#[tokio::test] +async fn token_refresh_task_should_persist_success_after_transient_transport_retry() { + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("token-refresh-transient-success.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let store = SqliteAccountStore::new(pool, SecretBox::new([16u8; 32])); + let now = Utc.with_ymd_and_hms(2026, 6, 19, 12, 0, 0).unwrap(); + let new_access_token = test_jwt((now + Duration::hours(1)).timestamp()); + store + .insert(NewAccount { + id: "acct-refresh-transient-success".to_string(), + email: Some("transient@example.com".to_string()), + account_id: Some("chatgpt-transient".to_string()), + user_id: Some("user-transient".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new( + test_jwt((now + Duration::seconds(30)).timestamp()).into(), + ), + refresh_token: Some(SecretString::new("refresh-transient".to_string().into())), + access_token_expires_at: Some(now + Duration::seconds(30)), + status: AccountStatus::Active, + }) + .await + .expect("account should be inserted"); + let observed_statuses = Arc::new(Mutex::new(Vec::new())); + let refresher = SequenceTokenRefresher { + store: store.clone(), + account_id: "acct-refresh-transient-success".to_string(), + observed_statuses: observed_statuses.clone(), + responses: Arc::new(Mutex::new(VecDeque::from(vec![ + Err(RefreshFailure::Transport), + Ok(TokenPair { + access_token: new_access_token.clone(), + refresh_token: None, + }), + ]))), + }; + let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new( + store.clone(), + RefreshPolicy { + refresh_margin_seconds: 300, + refresh_concurrency: 1, + }, + refresher, + ) + .with_retry_delays(vec![StdDuration::ZERO; 4]); + + let summary = task + .refresh_due_accounts_once_at(now) + .await + .expect("retry should eventually refresh"); + let stored = store + .get("acct-refresh-transient-success") + .await + .expect("account should load") + .expect("account should exist"); + let observed_statuses = observed_statuses.lock().await.clone(); + + assert_eq!(summary.refreshed, 1); + assert_eq!(summary.failed, 0); + assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]); + assert_eq!(stored.status, AccountStatus::Active); + assert_eq!( + stored.access_token.expose_secret(), + new_access_token.as_str() + ); +} diff --git a/crates/server/src/openai_api/sse.rs b/crates/server/src/openai_api/sse.rs index e5acc157c..8c5b8da69 100644 --- a/crates/server/src/openai_api/sse.rs +++ b/crates/server/src/openai_api/sse.rs @@ -11,17 +11,3 @@ pub fn openai_sse_frame(event: &str, data: &str) -> String { pub fn done_sse_frame() -> &'static str { "data: [DONE]\n\n" } - -#[cfg(test)] -mod tests { - use super::{done_sse_frame, openai_sse_frame}; - - #[test] - fn openai_sse_frame_should_encode_event_data_and_done_marker() { - assert_eq!( - openai_sse_frame("response.output_text.delta", "{\"delta\":\"hi\"}"), - "event: response.output_text.delta\ndata: {\"delta\":\"hi\"}\n\n" - ); - assert_eq!(done_sse_frame(), "data: [DONE]\n\n"); - } -} diff --git a/crates/server/tests/admin_accounts_routes.rs b/crates/server/tests/admin_accounts_routes.rs index d14b15002..def72a855 100644 --- a/crates/server/tests/admin_accounts_routes.rs +++ b/crates/server/tests/admin_accounts_routes.rs @@ -37,254 +37,16 @@ use wiremock::{ Mock, MockServer, ResponseTemplate, }; -#[tokio::test] -async fn admin_accounts_list_should_not_decrypt_account_tokens() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-accounts.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - sqlx::query( - "insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", - ) - .bind("acct_corrupt") - .bind("user@example.com") - .bind("not-a-secret-box-cipher") - .bind("active") - .bind("2026-06-18T00:00:00Z") - .bind("2026-06-18T00:00:00Z") - .execute(&pool) - .await - .unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([63u8; 32]), - ApiKeyHasher::new([64u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts?limit=10") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_accounts_list") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["requestId"], "req_accounts_list"); - assert_eq!(body["data"][0]["id"], "acct_corrupt"); - assert_eq!(body["data"][0]["email"], "user@example.com"); -} - -#[tokio::test] -async fn admin_usage_stats_should_return_page_and_summary() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-usage.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - sqlx::query( - "insert into accounts (id, email, label, plan_type, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind("acct_usage") - .bind("usage@example.com") - .bind("primary") - .bind("plus") - .bind("cipher") - .bind("active") - .bind("2026-06-18T00:00:00Z") - .bind("2026-06-18T00:00:00Z") - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "insert into account_usage (account_id, request_count, empty_response_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, last_used_at) values (?, 3, 1, 21, 8, 5, 7, 2, 1, 0, ?)", - ) - .bind("acct_usage") - .bind("2026-06-18T00:10:00Z") - .execute(&pool) - .await - .unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([73u8; 32]), - ApiKeyHasher::new([74u8; 32]), - ); - let app = router::router().with_state(state); - - let page_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/usage-stats?limit=10") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_usage_page") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let page_status = page_response.status(); - let page_body = response_json(page_response).await; - - assert_eq!(page_status, StatusCode::OK); - assert_eq!(page_body["data"][0]["accountId"], "acct_usage"); - assert_eq!(page_body["data"][0]["email"], "usage@example.com"); - assert_eq!(page_body["data"][0]["requestCount"], 3); - assert_eq!(page_body["data"][0]["inputTokens"], 21); - - let summary_response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/usage-stats/summary") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_usage_summary") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let summary_status = summary_response.status(); - let summary_body = response_json(summary_response).await; - - assert_eq!(summary_status, StatusCode::OK); - assert_eq!(summary_body["data"]["accountCount"], 1); - assert_eq!(summary_body["data"]["requestCount"], 3); - assert_eq!(summary_body["data"]["outputTokens"], 8); -} - -#[tokio::test] -async fn admin_usage_stats_should_require_admin_session_cookie() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-usage-auth.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([125u8; 32]), - ApiKeyHasher::new([126u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/usage-stats") - .header("x-request-id", "req_usage_auth") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], 40101); - assert_eq!(body["requestId"], "req_usage_auth"); -} - -#[tokio::test] -async fn admin_usage_stats_should_cursor_page_account_usage() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-usage-cursor.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - seed_usage_account( - &pool, - "acct_a", - "a@example.com", - "primary", - "plus", - 3, - 0, - 12, - 5, - 1, - "2026-06-11T00:00:00Z", - ) - .await; - seed_usage_account( - &pool, - "acct_b", - "b@example.com", - "backup", - "free", - 2, - 1, - 7, - 3, - 2, - "2026-06-11T00:01:00Z", - ) - .await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([127u8; 32]), - ApiKeyHasher::new([128u8; 32]), - ); - let app = router::router().with_state(state); - - let first_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/usage-stats?limit=1") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_usage_cursor") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let first_status = first_response.status(); - let first_body = response_json(first_response).await; - - assert_eq!(first_status, StatusCode::OK); - assert_eq!(first_body["code"], 200); - assert_eq!(first_body["requestId"], "req_usage_cursor"); - assert_eq!(first_body["data"].as_array().unwrap().len(), 1); - assert_eq!(first_body["data"][0]["accountId"], "acct_b"); - assert_eq!(first_body["data"][0]["requestCount"], 2); - assert_eq!(first_body["data"][0]["emptyResponseCount"], 1); - assert_eq!(first_body["data"][0]["inputTokens"], 7); - assert_eq!(first_body["page"]["limit"], 1); - let cursor = first_body["page"]["nextCursor"].as_str().unwrap(); - - let second_response = app - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/api/admin/usage-stats?limit=1&cursor={cursor}")) - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let second_status = second_response.status(); - let second_body = response_json(second_response).await; - - assert_eq!(second_status, StatusCode::OK); - assert_eq!(second_body["data"][0]["accountId"], "acct_a"); - assert!(second_body["page"]["nextCursor"].is_null()); -} +#[path = "admin_accounts_routes/admin_accounts_import_export.rs"] +mod admin_accounts_import_export; +#[path = "admin_accounts_routes/admin_accounts_lifecycle.rs"] +mod admin_accounts_lifecycle; +#[path = "admin_accounts_routes/admin_accounts_list.rs"] +mod admin_accounts_list; +#[path = "admin_accounts_routes/admin_accounts_oauth.rs"] +mod admin_accounts_oauth; +#[path = "admin_accounts_routes/admin_accounts_quota.rs"] +mod admin_accounts_quota; async fn seed_admin_session(pool: &SqlitePool, session_id: &str) { sqlx::query( @@ -354,3022 +116,6 @@ async fn seed_usage_account( .unwrap(); } -#[tokio::test] -async fn admin_accounts_lifecycle_should_update_and_delete_accounts() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-accounts-lifecycle.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - sqlx::query( - "insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", - ) - .bind("acct_lifecycle") - .bind("life@example.com") - .bind("cipher") - .bind("active") - .bind("2026-06-18T00:00:00Z") - .bind("2026-06-18T00:00:00Z") - .execute(&pool) - .await - .unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([75u8; 32]), - ApiKeyHasher::new([76u8; 32]), - ); - let app = router::router().with_state(state); - - let label = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/api/admin/accounts/acct_lifecycle/label") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(json!({"label": "primary"}).to_string())) - .unwrap(), - ) - .await - .unwrap(); - let label_status = label.status(); - let label_body = response_json(label).await; - - assert_eq!(label_status, StatusCode::OK); - assert_eq!(label_body["data"]["label"], "primary"); - - let status = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/api/admin/accounts/acct_lifecycle/status") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(json!({"status": "disabled"}).to_string())) - .unwrap(), - ) - .await - .unwrap(); - let status_code = status.status(); - let status_body = response_json(status).await; - - assert_eq!(status_code, StatusCode::OK); - assert_eq!(status_body["data"]["status"], "disabled"); - - let deleted = app - .oneshot( - Request::builder() - .method("DELETE") - .uri("/api/admin/accounts/acct_lifecycle") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let delete_status = deleted.status(); - let delete_body = response_json(deleted).await; - - assert_eq!(delete_status, StatusCode::OK); - assert_eq!(delete_body["data"]["deleted"], true); -} - -#[tokio::test] -async fn admin_accounts_export_should_return_native_account_tokens() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-accounts-export.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([77u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_export".to_string(), - email: Some("export@example.com".to_string()), - account_id: Some("chatgpt_export".to_string()), - user_id: Some("user_export".to_string()), - label: Some("primary".to_string()), - plan_type: Some("plus".to_string()), - access_token: SecretString::new("access-export".to_string().into()), - refresh_token: Some(SecretString::new("refresh-export".to_string().into())), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - secret_box, - ApiKeyHasher::new([78u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/export?ids=acct_export") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_accounts_export") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["requestId"], "req_accounts_export"); - assert_eq!(body["data"]["sourceFormat"], "native"); - assert_eq!(body["data"]["accounts"][0]["id"], "acct_export"); - assert_eq!(body["data"]["accounts"][0]["token"], "access-export"); - assert_eq!( - body["data"]["accounts"][0]["refreshToken"], - "refresh-export" - ); -} - -#[tokio::test] -async fn admin_accounts_import_should_store_native_account_tokens() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-accounts-import.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([79u8; 32]); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([80u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_accounts_import") - .body(Body::from( - json!({ - "accounts": [{ - "id": "acct_import", - "email": "import@example.com", - "accountId": "chatgpt_import", - "userId": "user_import", - "label": "secondary", - "planType": "team", - "token": "access-import", - "refreshToken": "refresh-import", - "accessTokenExpiresAt": "2026-06-18T02:00:00Z", - "status": "disabled" - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let stored = SqliteAccountStore::new(pool, secret_box) - .get("acct_import") - .await - .unwrap() - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["requestId"], "req_accounts_import"); - assert_eq!(body["data"]["imported"], 1); - assert_eq!(body["data"]["skipped"], 0); - assert_eq!(body["data"]["sourceFormat"], "native"); - assert_eq!(stored.access_token.expose_secret(), "access-import"); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "refresh-import" - ); - assert_eq!(stored.status, AccountStatus::Disabled); -} - -#[tokio::test] -async fn admin_accounts_import_should_require_admin_session_cookie() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-accounts-import-auth.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([111u8; 32]), - ApiKeyHasher::new([112u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("x-request-id", "req_accounts_import_auth") - .body(Body::from(r#"{"accounts":[]}"#)) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], 40101); - assert_eq!(body["requestId"], "req_accounts_import_auth"); -} - -#[tokio::test] -async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() { - let (app, _state, pool, _dir, _secret_box) = - admin_accounts_test_app("admin-accounts-import-sanitized.sqlite", 113).await; - - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_accounts_import_sanitized") - .body(Body::from( - json!({ - "accounts": [{ - "id": "acct_imported_sanitized", - "email": "user@example.com", - "accountId": "chatgpt-account", - "userId": "chatgpt-user", - "label": "primary", - "planType": "plus", - "token": "access-secret", - "refreshToken": "refresh-secret", - "status": "active" - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["imported"], 1); - assert_eq!(body["data"]["skipped"], 0); - - let stored: (String, String) = sqlx::query_as( - "select access_token_cipher, refresh_token_cipher from accounts where id = ?", - ) - .bind("acct_imported_sanitized") - .fetch_one(&pool) - .await - .unwrap(); - assert!(stored.0.starts_with("v1:")); - assert!(!stored.0.contains("access-secret")); - assert!(stored.1.starts_with("v1:")); - assert!(!stored.1.contains("refresh-secret")); - - let list_response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts?limit=10") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_accounts_import_list") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let list_status = list_response.status(); - let list_body = response_json(list_response).await; - - assert_eq!(list_status, StatusCode::OK); - assert_eq!(list_body["data"][0]["id"], "acct_imported_sanitized"); - assert_eq!(list_body["data"][0]["email"], "user@example.com"); - assert!(list_body["data"][0].get("token").is_none()); - assert!(list_body["data"][0].get("refreshToken").is_none()); - assert_eq!(list_body["page"]["limit"], 10); -} - -#[tokio::test] -async fn admin_accounts_import_should_reject_non_native_export_shape() { - let (app, _state, pool, _dir, _secret_box) = - admin_accounts_test_app("admin-accounts-import-external-shape.sqlite", 114).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_external_import") - .body(Body::from( - json!({ - "type": "external-data", - "version": 1, - "legacy": [], - "accounts": [{ - "platform": "openai", - "type": "oauth", - "credentials": { - "access_token": "Bearer external-access-secret", - "refresh_token": "rt_external", - "email": "team@example.com", - "chatgpt_account_id": "chatgpt-account", - "chatgpt_user_id": "chatgpt-user", - "plan_type": "team" - } - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let stored: (i64,) = sqlx::query_as("select count(*) from accounts") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["message"], "No importable accounts found"); - assert_eq!(stored.0, 0); -} - -#[tokio::test] -async fn admin_accounts_import_should_reject_native_payload_with_unknown_account_fields() { - let (app, _state, _pool, _dir, _secret_box) = - admin_accounts_test_app("admin-accounts-import-unknown-account.sqlite", 115).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_native_unknown_account") - .body(Body::from( - json!({ - "accounts": [{ - "id": "acct_native_unknown", - "token": "native-access-secret", - "refreshToken": "native-refresh-secret", - "email": "native@example.com", - "accountId": "native-account", - "userId": "native-user", - "label": "Native Unknown", - "planType": "plus", - "legacyField": "ignored-secret", - "status": "active" - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["message"], "No importable accounts found"); -} - -#[tokio::test] -async fn admin_accounts_import_should_reject_native_payload_with_unknown_container_fields() { - let (app, _state, _pool, _dir, _secret_box) = - admin_accounts_test_app("admin-accounts-import-unknown-container.sqlite", 116).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_native_unknown_container") - .body(Body::from( - json!({ - "accounts": [{ - "id": "acct_native_extra_container", - "token": "native-access-secret", - "refreshToken": "native-refresh-secret", - "email": "native@example.com", - "accountId": "native-account", - "userId": "native-user", - "label": "Native", - "planType": "plus", - "status": "active" - }], - "legacyContainer": true - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["message"], "No importable accounts found"); -} - -#[tokio::test] -async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_filter_ids() { - let (app, _state, pool, _dir, secret_box) = - admin_accounts_test_app("admin-accounts-export-filter.sqlite", 117).await; - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_export_a".to_string(), - email: Some("export-a@example.com".to_string()), - account_id: Some("chatgpt-export-a".to_string()), - user_id: Some("user-export-a".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new("access-acct_export_a".to_string().into()), - refresh_token: Some(SecretString::new( - "refresh-acct_export_a".to_string().into(), - )), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - seed_encrypted_account( - &pool, - secret_box, - NewAccount { - id: "acct_export_b".to_string(), - email: Some("export-b@example.com".to_string()), - account_id: Some("chatgpt-export-b".to_string()), - user_id: Some("user-export-b".to_string()), - label: None, - plan_type: Some("team".to_string()), - access_token: SecretString::new("access-acct_export_b".to_string().into()), - refresh_token: Some(SecretString::new( - "refresh-acct_export_b".to_string().into(), - )), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/export?ids=acct_export_a") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["sourceFormat"], "native"); - assert_eq!(body["data"]["accounts"].as_array().unwrap().len(), 1); - assert_eq!(body["data"]["accounts"][0]["id"], "acct_export_a"); - assert_eq!(body["data"]["accounts"][0]["token"], "access-acct_export_a"); - assert_eq!( - body["data"]["accounts"][0]["refreshToken"], - "refresh-acct_export_a" - ); - assert!(body["data"]["accounts"][0].get("legacyField").is_none()); -} - -#[tokio::test] -async fn admin_accounts_export_should_reject_unsupported_external_format() { - let (app, _state, _pool, _dir, _secret_box) = - admin_accounts_test_app("admin-accounts-export-external.sqlite", 118).await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/export?format=external") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_accounts_export_should_reject_unsupported_full_format() { - let (app, _state, _pool, _dir, _secret_box) = - admin_accounts_test_app("admin-accounts-export-full.sqlite", 119).await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/export?format=full") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_account_cookies_should_store_encrypted_cookie_header() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-cookies.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([85u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_cookies".to_string(), - email: Some("cookies@example.com".to_string()), - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-cookies".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool.clone(), - secret_box, - ApiKeyHasher::new([86u8; 32]), - ); - let app = router::router().with_state(state); - - let set = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/acct_cookies/cookies") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({"cookies":"cf_clearance=clear-secret; __cf_bm=bm-secret"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let set_status = set.status(); - let set_body = response_json(set).await; - - assert_eq!(set_status, StatusCode::OK); - assert_eq!( - set_body["data"]["cookies"], - "__cf_bm=bm-secret; cf_clearance=clear-secret" - ); - let stored = sqlx::query_as::<_, (String, String)>( - "select name, value_cipher from account_cookies where account_id = ? order by name asc", - ) - .bind("acct_cookies") - .fetch_all(&pool) - .await - .unwrap(); - assert_eq!(stored.len(), 2); - assert!(stored.iter().all(|(_, cipher)| cipher.starts_with("v1:"))); - assert!(stored - .iter() - .all(|(_, cipher)| !cipher.contains("clear-secret") && !cipher.contains("bm-secret"))); - - let get = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/acct_cookies/cookies") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let get_status = get.status(); - let get_body = response_json(get).await; - - assert_eq!(get_status, StatusCode::OK); - assert_eq!( - get_body["data"]["cookies"], - "__cf_bm=bm-secret; cf_clearance=clear-secret" - ); - - let delete = app - .clone() - .oneshot( - Request::builder() - .method("DELETE") - .uri("/api/admin/accounts/acct_cookies/cookies") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let delete_status = delete.status(); - let delete_body = response_json(delete).await; - - assert_eq!(delete_status, StatusCode::OK); - assert_eq!(delete_body["data"]["deleted"], true); -} - -#[tokio::test] -async fn admin_account_cookies_should_reject_missing_account_and_empty_payload() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-cookies-invalid.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([87u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_cookie_invalid".to_string(), - email: Some("cookie-invalid@example.com".to_string()), - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-cookie-invalid".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - secret_box, - ApiKeyHasher::new([88u8; 32]), - ); - let app = router::router().with_state(state); - - let missing = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/missing/cookies") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(missing.status(), StatusCode::NOT_FOUND); - - let empty = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/acct_cookie_invalid/cookies") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(json!({"cookies": ""}).to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(empty.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_auth_login_start_should_return_pkce_auth_url_and_state() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-auth-login-start.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([95u8; 32]), - ApiKeyHasher::new([96u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/login-start") - .header("cookie", "cpr_admin_session=session_1") - .header("host", "127.0.0.1:8080") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - let state = body["data"]["state"].as_str().unwrap(); - let auth_url = body["data"]["authUrl"].as_str().unwrap(); - assert_eq!(state.len(), 32); - assert!(auth_url.starts_with("https://auth.openai.com/oauth/authorize?")); - assert!(auth_url.contains("response_type=code")); - assert!(auth_url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann")); - assert!(auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback")); - assert!(auth_url.contains("scope=openid%20profile%20email%20offline_access")); - assert!(auth_url.contains("code_challenge_method=S256")); - assert!(auth_url.contains("originator=codex_cli_rs")); - assert!(auth_url.contains(&format!("state={state}"))); -} - -#[tokio::test] -async fn admin_auth_status_should_require_admin_session_cookie() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-auth-status-auth.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([105u8; 32]), - ApiKeyHasher::new([106u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/auth/status") - .header("x-request-id", "req_auth_status_no_session") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], 40101); - assert_eq!(body["requestId"], "req_auth_status_no_session"); -} - -#[tokio::test] -async fn admin_auth_status_should_report_user_and_pool_summary_without_secrets() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-auth-status-summary.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([107u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_auth_active".to_string(), - email: Some("active-auth@example.com".to_string()), - account_id: Some("auth-active-account".to_string()), - user_id: Some("auth-active-user".to_string()), - label: None, - plan_type: Some("plus".to_string()), - access_token: SecretString::new("access-auth-active".to_string().into()), - refresh_token: Some(SecretString::new("refresh-auth-active".to_string().into())), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_auth_disabled".to_string(), - email: Some("disabled-auth@example.com".to_string()), - account_id: Some("auth-disabled-account".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-auth-disabled".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Disabled, - }, - ) - .await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - secret_box, - ApiKeyHasher::new([108u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/auth/status") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_auth_status_summary") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["authenticated"], true); - assert_eq!(body["data"]["pool"]["total"], 2); - assert_eq!(body["data"]["pool"]["active"], 1); - assert_eq!(body["data"]["pool"]["disabled"], 1); - assert_eq!(body["data"]["user"]["email"], "active-auth@example.com"); - assert!(body["data"]["user"].get("accessToken").is_none()); - assert!(body["data"]["user"].get("token").is_none()); - assert!(body["data"]["user"].get("refreshToken").is_none()); -} - -#[tokio::test] -async fn admin_auth_logout_should_clear_accounts_usage_cookies_and_runtime_pool() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-auth-logout.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([109u8; 32]); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([110u8; 32]), - ); - let app = router::router().with_state(state.clone()); - let imported = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({ - "accounts": [{ - "id": "acct_auth_logout", - "email": "logout@example.com", - "planType": "plus", - "token": "access-auth-logout", - "refreshToken": "refresh-auth-logout", - "status": "active" - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(imported.status(), StatusCode::OK); - sqlx::query( - "insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens) values (?, 2, 3, 4, 1)", - ) - .bind("acct_auth_logout") - .execute(&pool) - .await - .unwrap(); - let set_cookies = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/acct_auth_logout/cookies") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"cookies":"cf_clearance=secret"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(set_cookies.status(), StatusCode::OK); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/logout") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_auth_logout") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["success"], true); - assert_eq!(body["data"]["deleted"], 1); - let account_count: (i64,) = sqlx::query_as("select count(*) from accounts") - .fetch_one(&pool) - .await - .unwrap(); - let usage_count: (i64,) = sqlx::query_as("select count(*) from account_usage") - .fetch_one(&pool) - .await - .unwrap(); - let cookie_count: (i64,) = sqlx::query_as("select count(*) from account_cookies") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(account_count.0, 0); - assert_eq!(usage_count.0, 0); - assert_eq!(cookie_count.0, 0); - assert!(state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .is_none()); -} - -#[tokio::test] -async fn admin_auth_device_login_should_return_openai_device_code() { - let client = StaticOAuthClient { - device_response: Ok(DeviceCode { - device_code: "device-secret".to_string(), - user_code: "ABCD-EFGH".to_string(), - verification_uri: "https://auth.openai.com/activate".to_string(), - verification_uri_complete: "https://auth.openai.com/activate?user_code=ABCD-EFGH" - .to_string(), - expires_in: 900, - interval: 5, - }), - poll_response: Err(OAuthError::AuthorizationPending), - exchange_response: Err(OAuthError::Rejected("not used".to_string())), - poll_calls: Arc::new(Mutex::new(Vec::new())), - exchange_calls: Arc::new(Mutex::new(Vec::new())), - }; - let (app, _state, _pool, _dir) = - admin_accounts_test_app_with_oauth_client("admin-auth-device-login.sqlite", 111, client) - .await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/device-login") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_device_login") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["userCode"], "ABCD-EFGH"); - assert_eq!( - body["data"]["verificationUri"], - "https://auth.openai.com/activate" - ); - assert_eq!(body["data"]["deviceCode"], "device-secret"); - assert_eq!(body["data"]["expiresIn"], 900); - assert_eq!(body["data"]["interval"], 5); -} - -#[tokio::test] -async fn admin_auth_device_poll_should_return_pending_without_importing_account() { - let poll_calls = Arc::new(Mutex::new(Vec::new())); - let client = StaticOAuthClient { - device_response: Err(OAuthError::Rejected("not used".to_string())), - poll_response: Err(OAuthError::SlowDown), - exchange_response: Err(OAuthError::Rejected("not used".to_string())), - poll_calls: poll_calls.clone(), - exchange_calls: Arc::new(Mutex::new(Vec::new())), - }; - let (app, _state, pool, _dir) = - admin_accounts_test_app_with_oauth_client("admin-auth-device-pending.sqlite", 112, client) - .await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/auth/device-poll/device-secret") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_device_pending") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["pending"], true); - assert_eq!(body["data"]["success"], false); - assert_eq!(body["data"]["code"], "slow_down"); - assert_eq!(*poll_calls.lock().await, vec!["device-secret".to_string()]); - let account_count: (i64,) = sqlx::query_as("select count(*) from accounts") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(account_count.0, 0); -} - -#[tokio::test] -async fn admin_auth_device_poll_should_import_tokens_without_returning_secrets() { - let token = test_jwt( - "device-account", - Some("device-user"), - Some("device@example.com"), - Some("plus"), - ); - let poll_calls = Arc::new(Mutex::new(Vec::new())); - let client = StaticOAuthClient { - device_response: Err(OAuthError::Rejected("not used".to_string())), - poll_response: Ok(TokenPair { - access_token: token.clone(), - refresh_token: Some("device-refresh-secret".to_string()), - }), - exchange_response: Err(OAuthError::Rejected("not used".to_string())), - poll_calls: poll_calls.clone(), - exchange_calls: Arc::new(Mutex::new(Vec::new())), - }; - let (app, state, pool, _dir) = - admin_accounts_test_app_with_oauth_client("admin-auth-device-success.sqlite", 113, client) - .await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/auth/device-poll/device-success") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_device_success") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["success"], true); - assert_eq!(body["data"]["pending"], false); - assert!(body["data"].get("accessToken").is_none()); - assert!(body["data"].get("refreshToken").is_none()); - assert_eq!(*poll_calls.lock().await, vec!["device-success".to_string()]); - let stored = SqliteAccountStore::new(pool.clone(), SecretBox::new([113u8; 32])) - .get( - state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account - .id - .as_str(), - ) - .await - .unwrap() - .unwrap(); - assert_eq!(stored.email.as_deref(), Some("device@example.com")); - assert_eq!(stored.account_id.as_deref(), Some("device-account")); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "device-refresh-secret" - ); -} - -#[tokio::test] -async fn admin_auth_login_start_should_use_configured_oauth_authorize_endpoint() { - let dir = tempfile::tempdir().unwrap(); - let db = dir - .path() - .join("admin-auth-login-start-configured-oauth.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let mut config = test_config(url); - config.auth.oauth_client_id = "app_configured_client".to_string(); - config.auth.oauth_auth_endpoint = "https://auth.example.test/oauth/authorize".to_string(); - config.auth.oauth_token_endpoint = "https://auth.example.test/oauth/token".to_string(); - let state = AppState::with_pool_secret_and_api_key_hasher( - config, - pool, - SecretBox::new([114u8; 32]), - ApiKeyHasher::new([115u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/login-start") - .header("cookie", "cpr_admin_session=session_1") - .header("host", "127.0.0.1:8080") - .header("x-request-id", "req_login_start_configured_oauth") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json(response).await; - let auth_url = body["data"]["authUrl"].as_str().unwrap(); - - assert!(auth_url.starts_with("https://auth.example.test/oauth/authorize?")); - assert!(auth_url.contains("client_id=app_configured_client")); -} - -#[tokio::test] -async fn admin_auth_code_relay_should_exchange_code_and_import_account() { - let token = test_jwt( - "pkce-account", - Some("pkce-user"), - Some("pkce@example.com"), - Some("plus"), - ); - let exchange_calls = Arc::new(Mutex::new(Vec::new())); - let client = StaticOAuthClient { - device_response: Err(OAuthError::Rejected("not used".to_string())), - poll_response: Err(OAuthError::AuthorizationPending), - exchange_response: Ok(TokenPair { - access_token: token, - refresh_token: Some("pkce-refresh-secret".to_string()), - }), - poll_calls: Arc::new(Mutex::new(Vec::new())), - exchange_calls: exchange_calls.clone(), - }; - let (app, state, _pool, _dir) = - admin_accounts_test_app_with_oauth_client("admin-auth-code-relay.sqlite", 116, client) - .await; - let start = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/login-start") - .header("cookie", "cpr_admin_session=session_1") - .header("host", "127.0.0.1:8080") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let start_body = response_json(start).await; - let state_value = start_body["data"]["state"].as_str().unwrap(); - let callback_url = - format!("http://localhost:1455/auth/callback?code=oauth-code&state={state_value}"); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/code-relay") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_code_relay") - .body(Body::from(json!({"callbackUrl": callback_url}).to_string())) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["success"], true); - assert!(body["data"].get("accessToken").is_none()); - assert!(body["data"].get("refreshToken").is_none()); - let calls = exchange_calls.lock().await; - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].code, "oauth-code"); - assert!(!calls[0].code_verifier.is_empty()); - assert_eq!(calls[0].redirect_uri, "http://localhost:1455/auth/callback"); - drop(calls); - let acquired = state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account; - assert_eq!(acquired.account_id.as_deref(), Some("pkce-account")); -} - -#[tokio::test] -async fn admin_auth_code_relay_should_reject_invalid_callback_url() { - let client = StaticOAuthClient { - device_response: Err(OAuthError::Rejected("not used".to_string())), - poll_response: Err(OAuthError::AuthorizationPending), - exchange_response: Err(OAuthError::Rejected("not used".to_string())), - poll_calls: Arc::new(Mutex::new(Vec::new())), - exchange_calls: Arc::new(Mutex::new(Vec::new())), - }; - let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_client( - "admin-auth-code-relay-invalid.sqlite", - 117, - client, - ) - .await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/code-relay") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_code_relay_invalid") - .body(Body::from(json!({"callbackUrl": "not a url"}).to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host() { - let token = test_jwt( - "callback-account", - Some("callback-user"), - Some("callback@example.com"), - Some("plus"), - ); - let exchange_calls = Arc::new(Mutex::new(Vec::new())); - let client = StaticOAuthClient { - device_response: Err(OAuthError::Rejected("not used".to_string())), - poll_response: Err(OAuthError::AuthorizationPending), - exchange_response: Ok(TokenPair { - access_token: token, - refresh_token: Some("callback-refresh-secret".to_string()), - }), - poll_calls: Arc::new(Mutex::new(Vec::new())), - exchange_calls: exchange_calls.clone(), - }; - let (app, _state, pool, _dir) = - admin_accounts_test_app_with_oauth_client("admin-auth-callback.sqlite", 118, client).await; - let start = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/auth/login-start") - .header("cookie", "cpr_admin_session=session_1") - .header("host", "codex.local:1455") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let start_body = response_json(start).await; - let state_value = start_body["data"]["state"].as_str().unwrap(); - let callback_path = format!("/auth/callback?code=callback-code&state={state_value}"); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri(callback_path) - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get("location").unwrap(), - "http://codex.local:1455/" - ); - assert_eq!(exchange_calls.lock().await[0].code, "callback-code"); - let count: (i64,) = sqlx::query_as("select count(*) from accounts where account_id = ?") - .bind("callback-account") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count.0, 1); -} - -#[tokio::test] -async fn admin_account_health_check_should_skip_account_without_refresh_token() { - let upstream = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "rate_limit": { - "primary_window": { - "used_percent": 1 - } - } - }))) - .expect(0) - .mount(&upstream) - .await; - - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-health-no-refresh.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([93u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_health".to_string(), - email: Some("health@example.com".to_string()), - account_id: Some("chatgpt-health".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-health".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let mut config = test_config(url); - config.api.base_url = upstream.uri(); - let state = AppState::with_pool_secret_and_api_key_hasher( - config, - pool, - secret_box, - ApiKeyHasher::new([94u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/health-check") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({ - "ids": ["acct_health"], - "concurrency": 1, - "staggerMs": 500 - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["summary"]["total"], 1); - assert_eq!(body["data"]["summary"]["alive"], 0); - assert_eq!(body["data"]["summary"]["dead"], 0); - assert_eq!(body["data"]["summary"]["skipped"], 1); - assert_eq!(body["data"]["results"][0]["id"], "acct_health"); - assert_eq!(body["data"]["results"][0]["result"], "skipped"); - assert_eq!(body["data"]["results"][0]["status"], "active"); - assert_eq!(body["data"]["results"][0]["error"], "no refresh token"); -} - -#[tokio::test] -async fn admin_accounts_health_check_should_refresh_oauth_without_touching_codex_backend() { - let upstream = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&upstream) - .await; - - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-health-refresh.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([124u8; 32]); - for (id, refresh_token) in [ - ("acct_health_alive", "refresh-acct_health_alive"), - ("acct_health_dead", "refresh-acct_health_dead"), - ] { - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: id.to_string(), - email: Some(format!("{id}@example.com")), - account_id: Some(format!("old-{id}")), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new(format!("old-access-{id}").into()), - refresh_token: Some(SecretString::new(refresh_token.to_string().into())), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - } - let calls = Arc::new(Mutex::new(Vec::new())); - let mut config = test_config(url); - config.api.base_url = upstream.uri(); - let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( - config, - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([124u8; 32]), - HealthCheckTokenRefresher { - calls: calls.clone(), - }, - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/health-check") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_health_refresh") - .body(Body::from( - json!({ - "ids": ["acct_health_alive", "acct_health_dead"], - "concurrency": 2, - "staggerMs": 500 - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["summary"]["total"], 2); - assert_eq!(body["data"]["summary"]["alive"], 1); - assert_eq!(body["data"]["summary"]["dead"], 1); - assert_eq!(body["data"]["summary"]["skipped"], 0); - let mut refresh_calls = calls.lock().await.clone(); - refresh_calls.sort(); - assert_eq!( - refresh_calls, - vec![ - "refresh-acct_health_alive".to_string(), - "refresh-acct_health_dead".to_string(), - ] - ); - let serialized = serde_json::to_string(&body).unwrap(); - assert!(!serialized.contains("new-health-access")); - assert!(!serialized.contains("new-health-refresh")); - let dead_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_health_dead") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(dead_status.0, "expired"); -} - -#[tokio::test] -async fn admin_accounts_health_check_should_reject_unsupported_stagger_ms_field() { - let (app, _state, _pool, _dir, _secret_box) = - admin_accounts_test_app("admin-account-health-unsupported-field.sqlite", 125).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/health-check") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"stagger_ms":1000}"#)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = response_json(response).await; - assert_eq!(body["code"], 40001); -} - -#[tokio::test] -async fn admin_account_quota_should_fetch_usage_store_quota_and_not_return_secrets() { - let upstream = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .and(header("authorization", "Bearer access-quota")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plan_type": "plus", - "rate_limit": { - "allowed": true, - "limit_reached": false, - "primary_window": { - "used_percent": 25, - "reset_at": 1770000400, - "limit_window_seconds": 3600 - } - } - }))) - .expect(1) - .mount(&upstream) - .await; - - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([95u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_quota".to_string(), - email: Some("quota@example.com".to_string()), - account_id: Some("chatgpt-quota".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-quota".to_string().into()), - refresh_token: Some(SecretString::new("refresh-quota".to_string().into())), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let mut config = test_config(url); - config.api.base_url = upstream.uri(); - let state = AppState::with_pool_secret_and_api_key_hasher( - config, - pool.clone(), - secret_box, - ApiKeyHasher::new([96u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/acct_quota/quota") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_quota") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - - assert_eq!(status, StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["quota"]["plan_type"], "plus"); - assert_eq!(body["data"]["quota"]["rate_limit"]["remaining_percent"], 75); - assert_eq!( - body["data"]["raw"]["rate_limit"]["primary_window"]["used_percent"], - 25 - ); - let serialized = serde_json::to_string(&body).unwrap(); - assert!(!serialized.contains("access-quota")); - assert!(!serialized.contains("refresh-quota")); - - let stored: (String,) = sqlx::query_as("select quota_json from accounts where id = ?") - .bind("acct_quota") - .fetch_one(&pool) - .await - .unwrap(); - assert!(stored.0.contains("\"remaining_percent\":75")); - assert!(!stored.0.contains("access-quota")); -} - -#[tokio::test] -async fn admin_account_quota_should_return_bad_gateway_when_usage_fetch_fails() { - let upstream = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .and(header("authorization", "Bearer access-quota-fail")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": { - "message": "quota unavailable" - } - }))) - .expect(1) - .mount(&upstream) - .await; - - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota-fail.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([97u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_quota_fail".to_string(), - email: Some("quota-fail@example.com".to_string()), - account_id: Some("chatgpt-quota-fail".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-quota-fail".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let mut config = test_config(url); - config.api.base_url = upstream.uri(); - let state = AppState::with_pool_secret_and_api_key_hasher( - config, - pool, - secret_box, - ApiKeyHasher::new([98u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/acct_quota_fail/quota") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_quota_fail") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_GATEWAY); - assert_eq!(body["code"], 50201); - assert_eq!(body["message"], "Failed to fetch quota from Codex API"); - assert!(body["data"]["error"] - .as_str() - .is_some_and(|error| error.contains("quota unavailable"))); - assert_eq!(body["requestId"], "req_quota_fail"); -} - -#[tokio::test] -async fn admin_account_quota_should_reject_inactive_account_without_calling_upstream() { - let upstream = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .expect(0) - .mount(&upstream) - .await; - - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota-inactive.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([99u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_quota_inactive".to_string(), - email: Some("quota-inactive@example.com".to_string()), - account_id: Some("chatgpt-quota-inactive".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-quota-inactive".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Disabled, - }, - ) - .await; - let mut config = test_config(url); - config.api.base_url = upstream.uri(); - let state = AppState::with_pool_secret_and_api_key_hasher( - config, - pool.clone(), - secret_box, - ApiKeyHasher::new([100u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/acct_quota_inactive/quota") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_quota_inactive") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let quota_json: (Option,) = - sqlx::query_as("select quota_json from accounts where id = ?") - .bind("acct_quota_inactive") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::CONFLICT); - assert_eq!(body["code"], 40901); - assert_eq!(body["message"], "Account is disabled, cannot query quota"); - assert_eq!(body["requestId"], "req_quota_inactive"); - assert!(quota_json.0.is_none()); -} - -#[tokio::test] -async fn admin_account_quota_should_return_store_error_when_quota_persistence_fails() { - let upstream = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/codex/usage")) - .and(header("authorization", "Bearer access-quota-store-fail")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plan_type": "plus", - "rate_limit": { - "allowed": true, - "limit_reached": false, - "primary_window": { - "used_percent": 40, - "reset_at": 1770000500, - "limit_window_seconds": 3600 - } - } - }))) - .expect(1) - .mount(&upstream) - .await; - - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota-store-fail.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([101u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_quota_store_fail".to_string(), - email: Some("quota-store-fail@example.com".to_string()), - account_id: Some("chatgpt-quota-store-fail".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-quota-store-fail".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - sqlx::query( - "create trigger quota_write_denied before update of quota_json on accounts begin select raise(abort, 'quota write denied'); end", - ) - .execute(&pool) - .await - .unwrap(); - let mut config = test_config(url); - config.api.base_url = upstream.uri(); - let state = AppState::with_pool_secret_and_api_key_hasher( - config, - pool.clone(), - secret_box, - ApiKeyHasher::new([102u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/acct_quota_store_fail/quota") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_quota_store_fail") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let quota_json: (Option,) = - sqlx::query_as("select quota_json from accounts where id = ?") - .bind("acct_quota_store_fail") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(body["code"], 50001); - assert_eq!(body["message"], "Failed to store account quota"); - assert_eq!(body["requestId"], "req_quota_store_fail"); - assert!(quota_json.0.is_none()); -} - -#[tokio::test] -async fn admin_accounts_batch_status_should_update_found_accounts_and_report_invalid_requests() { - let (app, state, pool, _dir, secret_box) = - admin_accounts_test_app("admin-accounts-batch-status-route.sqlite", 120).await; - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_batch_status_a".to_string(), - email: Some("batch-a@example.com".to_string()), - account_id: Some("batch-a".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-batch-a".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - seed_encrypted_account( - &pool, - secret_box, - NewAccount { - id: "acct_batch_status_b".to_string(), - email: Some("batch-b@example.com".to_string()), - account_id: Some("batch-b".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-batch-b".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let restored = state.restore_account_pool_from_repository().await.unwrap(); - assert_eq!(restored, 2); - - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/batch-status") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({ - "ids": ["acct_batch_status_a", "ghost"], - "status": "disabled" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["updated"], 1); - assert_eq!(body["data"]["notFound"], json!(["ghost"])); - let statuses = - sqlx::query_as::<_, (String, String)>("select id, status from accounts order by id asc") - .fetch_all(&pool) - .await - .unwrap(); - assert_eq!( - statuses, - vec![ - ("acct_batch_status_a".to_string(), "disabled".to_string()), - ("acct_batch_status_b".to_string(), "active".to_string()) - ] - ); - let invalid = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/batch-status") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({ - "ids": ["acct_batch_status_a"], - "status": "expired" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); - - let empty = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/batch-status") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"ids":[],"status":"active"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(empty.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_account_refresh_should_update_tokens_and_runtime_pool_without_returning_secrets() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-refresh-route.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([121u8; 32]); - let refreshed_access_token = test_jwt( - "refresh-account", - Some("refresh-user"), - Some("refresh@example.com"), - Some("plus"), - ); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_refresh_route".to_string(), - email: Some("old-refresh@example.com".to_string()), - account_id: Some("old-refresh-account".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("old-access-token".to_string().into()), - refresh_token: Some(SecretString::new("old-refresh-token".to_string().into())), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([122u8; 32]), - StaticTokenRefresher { - result: Ok(TokenPair { - access_token: refreshed_access_token.clone(), - refresh_token: Some("new-admin-refresh-rt".to_string()), - }), - calls: Arc::new(Mutex::new(Vec::new())), - }, - ); - let app = router::router().with_state(state.clone()); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/acct_refresh_route/refresh") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_refresh_account") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["id"], "acct_refresh_route"); - assert_eq!(body["data"]["result"], "alive"); - assert_eq!(body["data"]["previousStatus"], "active"); - let serialized = serde_json::to_string(&body).unwrap(); - assert!(!serialized.contains(&refreshed_access_token)); - assert!(!serialized.contains("new-admin-refresh-rt")); - - let stored = SqliteAccountStore::new(pool.clone(), secret_box) - .get("acct_refresh_route") - .await - .unwrap() - .unwrap(); - assert_eq!(stored.access_token.expose_secret(), refreshed_access_token); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "new-admin-refresh-rt" - ); - let raw: (String, String) = sqlx::query_as( - "select access_token_cipher, refresh_token_cipher from accounts where id = ?", - ) - .bind("acct_refresh_route") - .fetch_one(&pool) - .await - .unwrap(); - assert!(raw.0.starts_with("v1:")); - assert!(!raw.0.contains("old-access-token")); - assert!(!raw.0.contains(&refreshed_access_token)); - assert!(raw.1.starts_with("v1:")); - assert!(!raw.1.contains("new-admin-refresh-rt")); - let acquired = state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account; - assert_eq!(acquired.access_token, refreshed_access_token); -} - -#[tokio::test] -async fn admin_account_refresh_should_mark_invalid_refresh_token_as_expired() { - let dir = tempfile::tempdir().unwrap(); - let db = dir - .path() - .join("admin-account-refresh-invalid-route.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([123u8; 32]); - seed_encrypted_account( - &pool, - secret_box, - NewAccount { - id: "acct_refresh_invalid_route".to_string(), - email: Some("refresh-invalid@example.com".to_string()), - account_id: Some("refresh-invalid-account".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("invalid-old-access".to_string().into()), - refresh_token: Some(SecretString::new( - "invalid-refresh-token".to_string().into(), - )), - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( - test_config(url), - pool.clone(), - SecretBox::new([123u8; 32]), - ApiKeyHasher::new([124u8; 32]), - StaticTokenRefresher { - result: Err(RefreshFailure::InvalidGrant), - calls: Arc::new(Mutex::new(Vec::new())), - }, - ); - let restored = state.restore_account_pool_from_repository().await.unwrap(); - assert_eq!(restored, 1); - let app = router::router().with_state(state.clone()); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/acct_refresh_invalid_route/refresh") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_refresh_invalid") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["result"], "dead"); - assert_eq!(body["data"]["status"], "expired"); - let stored_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_refresh_invalid_route") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(stored_status.0, "expired"); - assert!(state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .is_none()); -} - -#[tokio::test] -async fn admin_account_reset_usage_should_clear_local_counters_and_pool_last_used() { - let (app, state, pool, _dir, secret_box) = - admin_accounts_test_app("admin-account-reset-usage-route.sqlite", 125).await; - let window_reset_at = Utc::now() + Duration::minutes(5); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_reset_usage_route".to_string(), - email: Some("reset-usage@example.com".to_string()), - account_id: Some("reset-usage-account".to_string()), - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-reset-usage".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - sqlx::query( - "insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds, last_used_at) values (?, 7, 11, 13, 17, 5, 19, 23, 29, ?, ?, 300, ?)", - ) - .bind("acct_reset_usage_route") - .bind("2026-06-12T12:30:00Z") - .bind(window_reset_at.to_rfc3339()) - .bind("2026-06-12T12:00:00Z") - .execute(&pool) - .await - .unwrap(); - state.restore_account_pool_from_repository().await.unwrap(); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/acct_reset_usage_route/reset-usage") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_reset_usage") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["id"], "acct_reset_usage_route"); - assert_eq!(body["data"]["reset"], true); - type ResetUsageRow = ( - i64, - i64, - i64, - i64, - i64, - i64, - i64, - i64, - Option, - Option, - ); - let usage: ResetUsageRow = sqlx::query_as( - "select request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_reset_at, last_used_at from account_usage where account_id = ?", - ) - .bind("acct_reset_usage_route") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!( - usage, - ( - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - Some(window_reset_at.to_rfc3339()), - None - ) - ); - let pool_account = SqliteAccountStore::new(pool, secret_box) - .get_pool_account("acct_reset_usage_route") - .await - .unwrap() - .unwrap(); - assert_eq!(pool_account.request_count, 0); - assert_eq!(pool_account.window_request_count, 0); - assert_eq!(pool_account.window_reset_at, Some(window_reset_at)); - assert!(pool_account.last_used_at.is_none()); -} - -#[tokio::test] -async fn admin_account_create_should_derive_claims_and_store_encrypted_tokens() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-create.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([91u8; 32]); - let token = test_jwt( - "jwt-account", - Some("jwt-user"), - Some("jwt@example.com"), - Some("team"), - ); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([92u8; 32]), - ); - let app = router::router().with_state(state.clone()); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({ - "id": "caller-id", - "email": "caller@example.com", - "accountId": "caller-account", - "userId": "caller-user", - "label": "Caller Label", - "planType": "caller-plan", - "token": format!("Bearer {token}"), - "refreshToken": "manual-refresh-secret", - "status": "disabled" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let created_id = body["data"]["id"].as_str().unwrap(); - assert_ne!(created_id, "caller-id"); - assert_eq!(body["data"]["email"], "jwt@example.com"); - assert_eq!(body["data"]["accountId"], "jwt-account"); - assert_eq!(body["data"]["userId"], "jwt-user"); - assert_eq!(body["data"]["planType"], "team"); - assert!(body["data"]["label"].is_null()); - assert_eq!(body["data"]["status"], "active"); - assert!(body["data"].get("token").is_none()); - assert!(body["data"].get("refreshToken").is_none()); - - let stored = SqliteAccountStore::new(pool.clone(), secret_box) - .get(created_id) - .await - .unwrap() - .unwrap(); - assert_eq!(stored.access_token.expose_secret(), &token); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "manual-refresh-secret" - ); - - let raw_cipher: (String,) = - sqlx::query_as("select access_token_cipher from accounts where id = ?") - .bind(created_id) - .fetch_one(&pool) - .await - .unwrap(); - assert!(!raw_cipher.0.contains(&token)); - let raw_refresh_cipher: (String,) = - sqlx::query_as("select refresh_token_cipher from accounts where id = ?") - .bind(created_id) - .fetch_one(&pool) - .await - .unwrap(); - assert!(raw_cipher.0.starts_with("v1:")); - assert!(raw_refresh_cipher.0.starts_with("v1:")); - assert!(!raw_refresh_cipher.0.contains("manual-refresh-secret")); - - let acquired = state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account; - assert_eq!(acquired.id, created_id); - assert_eq!(acquired.email.as_deref(), Some("jwt@example.com")); - assert_eq!(acquired.account_id.as_deref(), Some("jwt-account")); - assert_eq!(acquired.user_id.as_deref(), Some("jwt-user")); - assert_eq!(acquired.plan_type.as_deref(), Some("team")); -} - -#[tokio::test] -async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_unbound_tokens() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-create-invalid.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([93u8; 32]), - ApiKeyHasher::new([94u8; 32]), - ); - let app = router::router().with_state(state); - - let cases = [ - ("missing tokens", json!({})), - ("invalid jwt", json!({"token": "not-a-jwt"})), - ( - "expired jwt", - json!({"token": test_jwt_with_exp( - Some("expired-account"), - Some("expired-user"), - Some("expired@example.com"), - Some("plus"), - 1_600_000_000, - )}), - ), - ( - "missing account claim", - json!({"token": test_jwt_with_exp( - None, - Some("claimless-user"), - Some("claimless@example.com"), - Some("free"), - 4_102_444_800, - )}), - ), - ]; - - for (name, payload) in cases { - let response = post_admin_account(&app, payload).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{name}"); - } -} - -#[tokio::test] -async fn admin_account_manual_create_refresh_only_should_exchange_rotate_and_sync_pool() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-refresh-only.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([95u8; 32]); - let token = test_jwt( - "rt-account", - Some("rt-user"), - Some("rt@example.com"), - Some("plus"), - ); - let calls = Arc::new(Mutex::new(Vec::new())); - let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([96u8; 32]), - StaticTokenRefresher { - result: Ok(TokenPair { - access_token: token.clone(), - refresh_token: Some("rotated-refresh".to_string()), - }), - calls: calls.clone(), - }, - ); - let app = router::router().with_state(state.clone()); - - let response = post_admin_account( - &app, - json!({ - "refreshToken": "initial-refresh", - "email": "caller@example.com", - "planType": "caller-plan" - }), - ) - .await; - let status = response.status(); - let body = response_json(response).await; - let created_id = body["data"]["id"].as_str().unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["email"], "rt@example.com"); - assert_eq!(body["data"]["accountId"], "rt-account"); - assert!(body["data"].get("token").is_none()); - assert!(body["data"].get("refreshToken").is_none()); - assert_eq!(*calls.lock().await, vec!["initial-refresh".to_string()]); - - let stored = SqliteAccountStore::new(pool, secret_box) - .get(created_id) - .await - .unwrap() - .unwrap(); - assert_eq!(stored.access_token.expose_secret(), &token); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "rotated-refresh" - ); - - let acquired = state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account; - assert_eq!(acquired.id, created_id); - assert_eq!(acquired.refresh_token.as_deref(), Some("rotated-refresh")); -} - -#[tokio::test] -async fn admin_account_manual_create_refresh_only_should_preserve_input_refresh_without_rotation() { - let dir = tempfile::tempdir().unwrap(); - let db = dir - .path() - .join("admin-account-refresh-preserve-input.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([97u8; 32]); - let token = test_jwt( - "rt-preserve-account", - Some("rt-preserve-user"), - Some("preserve@example.com"), - Some("free"), - ); - let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([98u8; 32]), - StaticTokenRefresher { - result: Ok(TokenPair { - access_token: token, - refresh_token: None, - }), - calls: Arc::new(Mutex::new(Vec::new())), - }, - ); - let app = router::router().with_state(state); - - let response = post_admin_account(&app, json!({"refreshToken": "preserved-refresh"})).await; - let body = response_json(response).await; - let created_id = body["data"]["id"].as_str().unwrap(); - - let stored = SqliteAccountStore::new(pool, secret_box) - .get(created_id) - .await - .unwrap() - .unwrap(); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "preserved-refresh" - ); -} - -#[tokio::test] -async fn admin_account_manual_create_should_update_existing_and_preserve_refresh() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-update-existing.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([99u8; 32]); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([100u8; 32]), - ); - let app = router::router().with_state(state.clone()); - - let first_token = test_jwt( - "team-account", - Some("same-user"), - Some("first@example.com"), - Some("free"), - ); - let first_response = post_admin_account( - &app, - json!({ - "token": first_token, - "refreshToken": "first-refresh" - }), - ) - .await; - let first_body = response_json(first_response).await; - let first_id = first_body["data"]["id"].as_str().unwrap().to_string(); - - let second_token = test_jwt( - "team-account", - Some("same-user"), - Some("second@example.com"), - Some("team"), - ); - let second_response = post_admin_account(&app, json!({"token": second_token})).await; - let status = second_response.status(); - let second_body = response_json(second_response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(second_body["data"]["id"], first_id); - assert_eq!(second_body["data"]["email"], "second@example.com"); - assert_eq!(second_body["data"]["planType"], "team"); - - let count: (i64,) = sqlx::query_as("select count(*) from accounts") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count.0, 1); - let stored = SqliteAccountStore::new(pool, secret_box) - .get(&first_id) - .await - .unwrap() - .unwrap(); - assert_eq!(stored.access_token.expose_secret(), &second_token); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "first-refresh" - ); - - let acquired = state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account; - assert_eq!(acquired.id, first_id); - assert_eq!(acquired.access_token, second_token); - assert_eq!(acquired.refresh_token.as_deref(), Some("first-refresh")); -} - -#[tokio::test] -async fn admin_account_manual_create_refresh_only_should_preserve_existing_refresh_without_rotation( -) { - let dir = tempfile::tempdir().unwrap(); - let db = dir - .path() - .join("admin-account-refresh-preserve-existing.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([101u8; 32]); - let refreshed_token = test_jwt( - "rt-existing-account", - Some("rt-existing-user"), - Some("second@example.com"), - Some("team"), - ); - let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([102u8; 32]), - StaticTokenRefresher { - result: Ok(TokenPair { - access_token: refreshed_token.clone(), - refresh_token: None, - }), - calls: Arc::new(Mutex::new(Vec::new())), - }, - ); - let app = router::router().with_state(state); - let first_token = test_jwt( - "rt-existing-account", - Some("rt-existing-user"), - Some("first@example.com"), - Some("free"), - ); - let first_response = post_admin_account( - &app, - json!({ - "token": first_token, - "refreshToken": "old-refresh" - }), - ) - .await; - let first_body = response_json(first_response).await; - let account_id = first_body["data"]["id"].as_str().unwrap().to_string(); - - let second_response = - post_admin_account(&app, json!({"refreshToken": "incoming-refresh"})).await; - assert_eq!(second_response.status(), StatusCode::OK); - - let stored = SqliteAccountStore::new(pool, secret_box) - .get(&account_id) - .await - .unwrap() - .unwrap(); - assert_eq!(stored.access_token.expose_secret(), &refreshed_token); - assert_eq!(stored.refresh_token.unwrap().expose_secret(), "old-refresh"); - assert_eq!(stored.email.as_deref(), Some("second@example.com")); - assert_eq!(stored.plan_type.as_deref(), Some("team")); -} - -#[tokio::test] -async fn admin_accounts_import_cli_should_read_auth_file_store_encrypted_and_sync_pool() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-import-cli.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([103u8; 32]); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool.clone(), - secret_box.clone(), - ApiKeyHasher::new([104u8; 32]), - ); - let app = router::router().with_state(state.clone()); - let codex_home = dir.path().join("codex-home"); - fs::create_dir_all(&codex_home).unwrap(); - let token = test_jwt( - "cli-account", - Some("cli-user"), - Some("cli@example.com"), - Some("plus"), - ); - fs::write( - codex_home.join("auth.json"), - json!({ - "access_token": token, - "refresh_token": "cli-refresh-secret" - }) - .to_string(), - ) - .unwrap(); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/accounts/import-cli") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_import_cli") - .body(Body::from( - json!({ - "codexHome": codex_home.display().to_string(), - "id": "caller-id", - "email": "caller@example.com", - "accountId": "caller-account", - "userId": "caller-user", - "label": "caller-label", - "planType": "caller-plan" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["requestId"], "req_import_cli"); - assert_eq!(body["data"]["sourceFormat"], "codex_cli"); - assert_eq!(body["data"]["imported"], 1); - assert!(body["data"].get("token").is_none()); - assert!(body["data"].get("refreshToken").is_none()); - - let stored = SqliteAccountStore::new(pool.clone(), secret_box) - .list(None, 10) - .await - .unwrap() - .items - .remove(0); - assert_ne!(stored.id, "caller-id"); - assert_eq!(stored.email.as_deref(), Some("cli@example.com")); - assert_eq!(stored.account_id.as_deref(), Some("cli-account")); - assert_eq!(stored.user_id.as_deref(), Some("cli-user")); - assert_eq!(stored.label, None); - assert_eq!(stored.plan_type.as_deref(), Some("plus")); - assert_eq!(stored.access_token.expose_secret(), &token); - assert_eq!( - stored.refresh_token.unwrap().expose_secret(), - "cli-refresh-secret" - ); - let raw: (String, String) = sqlx::query_as( - "select access_token_cipher, refresh_token_cipher from accounts where id = ?", - ) - .bind(&stored.id) - .fetch_one(&pool) - .await - .unwrap(); - assert!(raw.0.starts_with("v1:")); - assert!(!raw.0.contains(&token)); - assert!(raw.1.starts_with("v1:")); - assert!(!raw.1.contains("cli-refresh-secret")); - - let acquired = state - .services - .account_pool - .acquire("gpt-5.5", Utc::now()) - .await - .unwrap() - .account; - assert_eq!(acquired.id, stored.id); -} - -#[tokio::test] -async fn admin_account_quota_warnings_should_require_admin_session_cookie() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota-warnings-auth.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - SecretBox::new([87u8; 32]), - ApiKeyHasher::new([88u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/quota-warnings") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn admin_account_quota_warnings_should_return_threshold_matches_from_cached_quota() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota-warnings.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([89u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_warn".to_string(), - email: Some("warn@example.com".to_string()), - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-warn".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_quiet".to_string(), - email: Some("quiet@example.com".to_string()), - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-quiet".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - sqlx::query( - "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", - ) - .bind( - json!({ - "rate_limit": { - "used_percent": 85, - "reset_at": 1770000100 - }, - "secondary_rate_limit": { - "used_percent": 91, - "reset_at": 1770000200 - } - }) - .to_string(), - ) - .bind("2026-06-13T00:00:00Z") - .bind("2026-06-13T00:00:00Z") - .bind("acct_warn") - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", - ) - .bind( - json!({ - "rate_limit": { - "used_percent": 25, - "reset_at": 1770000300 - }, - "secondary_rate_limit": null - }) - .to_string(), - ) - .bind("2026-06-13T01:00:00Z") - .bind("2026-06-13T01:00:00Z") - .bind("acct_quiet") - .execute(&pool) - .await - .unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - secret_box, - ApiKeyHasher::new([90u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/quota-warnings") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["updatedAt"], "2026-06-13T00:00:00+00:00"); - let warnings = body["data"]["warnings"].as_array().unwrap(); - assert_eq!(warnings.len(), 2); - assert!(warnings - .iter() - .all(|warning| warning["accountId"] == "acct_warn")); - assert!(warnings.iter().any(|warning| { - warning["window"] == "primary" - && warning["level"] == "warning" - && warning["usedPercent"] == 85.0 - && warning["resetAt"] == 1770000100 - })); - assert!(warnings.iter().any(|warning| { - warning["window"] == "secondary" - && warning["level"] == "critical" - && warning["usedPercent"] == 91.0 - && warning["resetAt"] == 1770000200 - })); -} - -#[tokio::test] -async fn admin_account_quota_warnings_should_ignore_invalid_and_below_threshold_snapshots() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-account-quota-warnings-edge.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_session(&pool, "session_1").await; - let secret_box = SecretBox::new([85u8; 32]); - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_invalid_quota".to_string(), - email: Some("invalid-quota@example.com".to_string()), - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-invalid-quota".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - seed_encrypted_account( - &pool, - secret_box.clone(), - NewAccount { - id: "acct_below_threshold".to_string(), - email: Some("below-threshold@example.com".to_string()), - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: SecretString::new("access-below-threshold".to_string().into()), - refresh_token: None, - access_token_expires_at: None, - status: AccountStatus::Active, - }, - ) - .await; - sqlx::query( - "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", - ) - .bind("{not valid json") - .bind("2026-06-13T00:00:00Z") - .bind("2026-06-13T00:00:00Z") - .bind("acct_invalid_quota") - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", - ) - .bind( - json!({ - "rate_limit": { - "used_percent": 79, - "reset_at": 1770000600 - }, - "secondary_rate_limit": { - "used_percent": 79, - "reset_at": 1770000700 - } - }) - .to_string(), - ) - .bind("2026-06-13T01:00:00Z") - .bind("2026-06-13T01:00:00Z") - .bind("acct_below_threshold") - .execute(&pool) - .await - .unwrap(); - let state = AppState::with_pool_secret_and_api_key_hasher( - test_config(url), - pool, - secret_box, - ApiKeyHasher::new([86u8; 32]), - ); - let app = router::router().with_state(state); - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/accounts/quota-warnings") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(body["data"]["warnings"].as_array().unwrap().is_empty()); - assert!(body["data"]["updatedAt"].is_null()); -} - async fn response_json(response: axum::response::Response) -> Value { let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); serde_json::from_slice(&bytes).unwrap() diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs new file mode 100644 index 000000000..8cd0f2784 --- /dev/null +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs @@ -0,0 +1,759 @@ +use super::*; + +#[tokio::test] +async fn admin_accounts_export_should_return_native_account_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts-export.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([77u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_export".to_string(), + email: Some("export@example.com".to_string()), + account_id: Some("chatgpt_export".to_string()), + user_id: Some("user_export".to_string()), + label: Some("primary".to_string()), + plan_type: Some("plus".to_string()), + access_token: SecretString::new("access-export".to_string().into()), + refresh_token: Some(SecretString::new("refresh-export".to_string().into())), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + secret_box, + ApiKeyHasher::new([78u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?ids=acct_export") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_export") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["requestId"], "req_accounts_export"); + assert_eq!(body["data"]["sourceFormat"], "native"); + assert_eq!(body["data"]["accounts"][0]["id"], "acct_export"); + assert_eq!(body["data"]["accounts"][0]["token"], "access-export"); + assert_eq!( + body["data"]["accounts"][0]["refreshToken"], + "refresh-export" + ); +} + +#[tokio::test] +async fn admin_accounts_import_should_store_native_account_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts-import.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([79u8; 32]); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([80u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import") + .body(Body::from( + json!({ + "accounts": [{ + "id": "acct_import", + "email": "import@example.com", + "accountId": "chatgpt_import", + "userId": "user_import", + "label": "secondary", + "planType": "team", + "token": "access-import", + "refreshToken": "refresh-import", + "accessTokenExpiresAt": "2026-06-18T02:00:00Z", + "status": "disabled" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let stored = SqliteAccountStore::new(pool, secret_box) + .get("acct_import") + .await + .unwrap() + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["requestId"], "req_accounts_import"); + assert_eq!(body["data"]["imported"], 1); + assert_eq!(body["data"]["skipped"], 0); + assert_eq!(body["data"]["sourceFormat"], "native"); + assert_eq!(stored.access_token.expose_secret(), "access-import"); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "refresh-import" + ); + assert_eq!(stored.status, AccountStatus::Disabled); +} + +#[tokio::test] +async fn admin_accounts_import_should_require_admin_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts-import-auth.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([111u8; 32]), + ApiKeyHasher::new([112u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("x-request-id", "req_accounts_import_auth") + .body(Body::from(r#"{"accounts":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], 40101); + assert_eq!(body["requestId"], "req_accounts_import_auth"); +} + +#[tokio::test] +async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() { + let (app, _state, pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-import-sanitized.sqlite", 113).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import_sanitized") + .body(Body::from( + json!({ + "accounts": [{ + "id": "acct_imported_sanitized", + "email": "user@example.com", + "accountId": "chatgpt-account", + "userId": "chatgpt-user", + "label": "primary", + "planType": "plus", + "token": "access-secret", + "refreshToken": "refresh-secret", + "status": "active" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["imported"], 1); + assert_eq!(body["data"]["skipped"], 0); + + let stored: (String, String) = sqlx::query_as( + "select access_token_cipher, refresh_token_cipher from accounts where id = ?", + ) + .bind("acct_imported_sanitized") + .fetch_one(&pool) + .await + .unwrap(); + assert!(stored.0.starts_with("v1:")); + assert!(!stored.0.contains("access-secret")); + assert!(stored.1.starts_with("v1:")); + assert!(!stored.1.contains("refresh-secret")); + + let list_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import_list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_status = list_response.status(); + let list_body = response_json(list_response).await; + + assert_eq!(list_status, StatusCode::OK); + assert_eq!(list_body["data"][0]["id"], "acct_imported_sanitized"); + assert_eq!(list_body["data"][0]["email"], "user@example.com"); + assert!(list_body["data"][0].get("token").is_none()); + assert!(list_body["data"][0].get("refreshToken").is_none()); + assert_eq!(list_body["page"]["limit"], 10); +} + +#[tokio::test] +async fn admin_accounts_import_should_reject_non_native_export_shape() { + let (app, _state, pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-import-external-shape.sqlite", 114).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_external_import") + .body(Body::from( + json!({ + "type": "external-data", + "version": 1, + "legacy": [], + "accounts": [{ + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": "Bearer external-access-secret", + "refresh_token": "rt_external", + "email": "team@example.com", + "chatgpt_account_id": "chatgpt-account", + "chatgpt_user_id": "chatgpt-user", + "plan_type": "team" + } + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let stored: (i64,) = sqlx::query_as("select count(*) from accounts") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["message"], "No importable accounts found"); + assert_eq!(stored.0, 0); +} + +#[tokio::test] +async fn admin_accounts_import_should_reject_native_payload_with_unknown_account_fields() { + let (app, _state, _pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-import-unknown-account.sqlite", 115).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_native_unknown_account") + .body(Body::from( + json!({ + "accounts": [{ + "id": "acct_native_unknown", + "token": "native-access-secret", + "refreshToken": "native-refresh-secret", + "email": "native@example.com", + "accountId": "native-account", + "userId": "native-user", + "label": "Native Unknown", + "planType": "plus", + "legacyField": "ignored-secret", + "status": "active" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["message"], "No importable accounts found"); +} + +#[tokio::test] +async fn admin_accounts_import_should_reject_native_payload_with_unknown_container_fields() { + let (app, _state, _pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-import-unknown-container.sqlite", 116).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_native_unknown_container") + .body(Body::from( + json!({ + "accounts": [{ + "id": "acct_native_extra_container", + "token": "native-access-secret", + "refreshToken": "native-refresh-secret", + "email": "native@example.com", + "accountId": "native-account", + "userId": "native-user", + "label": "Native", + "planType": "plus", + "status": "active" + }], + "legacyContainer": true + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["message"], "No importable accounts found"); +} + +#[tokio::test] +async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_filter_ids() { + let (app, _state, pool, _dir, secret_box) = + admin_accounts_test_app("admin-accounts-export-filter.sqlite", 117).await; + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_export_a".to_string(), + email: Some("export-a@example.com".to_string()), + account_id: Some("chatgpt-export-a".to_string()), + user_id: Some("user-export-a".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new("access-acct_export_a".to_string().into()), + refresh_token: Some(SecretString::new( + "refresh-acct_export_a".to_string().into(), + )), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + seed_encrypted_account( + &pool, + secret_box, + NewAccount { + id: "acct_export_b".to_string(), + email: Some("export-b@example.com".to_string()), + account_id: Some("chatgpt-export-b".to_string()), + user_id: Some("user-export-b".to_string()), + label: None, + plan_type: Some("team".to_string()), + access_token: SecretString::new("access-acct_export_b".to_string().into()), + refresh_token: Some(SecretString::new( + "refresh-acct_export_b".to_string().into(), + )), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?ids=acct_export_a") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["sourceFormat"], "native"); + assert_eq!(body["data"]["accounts"].as_array().unwrap().len(), 1); + assert_eq!(body["data"]["accounts"][0]["id"], "acct_export_a"); + assert_eq!(body["data"]["accounts"][0]["token"], "access-acct_export_a"); + assert_eq!( + body["data"]["accounts"][0]["refreshToken"], + "refresh-acct_export_a" + ); + assert!(body["data"]["accounts"][0].get("legacyField").is_none()); +} + +#[tokio::test] +async fn admin_accounts_export_should_reject_unsupported_external_format() { + let (app, _state, _pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-export-external.sqlite", 118).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?format=external") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_accounts_export_should_reject_unsupported_full_format() { + let (app, _state, _pool, _dir, _secret_box) = + admin_accounts_test_app("admin-accounts-export-full.sqlite", 119).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?format=full") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_auth_device_poll_should_return_pending_without_importing_account() { + let poll_calls = Arc::new(Mutex::new(Vec::new())); + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::SlowDown), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), + poll_calls: poll_calls.clone(), + exchange_calls: Arc::new(Mutex::new(Vec::new())), + }; + let (app, _state, pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-device-pending.sqlite", 112, client) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/auth/device-poll/device-secret") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_device_pending") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["pending"], true); + assert_eq!(body["data"]["success"], false); + assert_eq!(body["data"]["code"], "slow_down"); + assert_eq!(*poll_calls.lock().await, vec!["device-secret".to_string()]); + let account_count: (i64,) = sqlx::query_as("select count(*) from accounts") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(account_count.0, 0); +} + +#[tokio::test] +async fn admin_auth_device_poll_should_import_tokens_without_returning_secrets() { + let token = test_jwt( + "device-account", + Some("device-user"), + Some("device@example.com"), + Some("plus"), + ); + let poll_calls = Arc::new(Mutex::new(Vec::new())); + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Ok(TokenPair { + access_token: token.clone(), + refresh_token: Some("device-refresh-secret".to_string()), + }), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), + poll_calls: poll_calls.clone(), + exchange_calls: Arc::new(Mutex::new(Vec::new())), + }; + let (app, state, pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-device-success.sqlite", 113, client) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/auth/device-poll/device-success") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_device_success") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["success"], true); + assert_eq!(body["data"]["pending"], false); + assert!(body["data"].get("accessToken").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + assert_eq!(*poll_calls.lock().await, vec!["device-success".to_string()]); + let stored = SqliteAccountStore::new(pool.clone(), SecretBox::new([113u8; 32])) + .get( + state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account + .id + .as_str(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.email.as_deref(), Some("device@example.com")); + assert_eq!(stored.account_id.as_deref(), Some("device-account")); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "device-refresh-secret" + ); +} + +#[tokio::test] +async fn admin_auth_code_relay_should_exchange_code_and_import_account() { + let token = test_jwt( + "pkce-account", + Some("pkce-user"), + Some("pkce@example.com"), + Some("plus"), + ); + let exchange_calls = Arc::new(Mutex::new(Vec::new())); + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Ok(TokenPair { + access_token: token, + refresh_token: Some("pkce-refresh-secret".to_string()), + }), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: exchange_calls.clone(), + }; + let (app, state, _pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-code-relay.sqlite", 116, client) + .await; + let start = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "127.0.0.1:8080") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let start_body = response_json(start).await; + let state_value = start_body["data"]["state"].as_str().unwrap(); + let callback_url = + format!("http://localhost:1455/auth/callback?code=oauth-code&state={state_value}"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/code-relay") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_code_relay") + .body(Body::from(json!({"callbackUrl": callback_url}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["success"], true); + assert!(body["data"].get("accessToken").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + let calls = exchange_calls.lock().await; + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].code, "oauth-code"); + assert!(!calls[0].code_verifier.is_empty()); + assert_eq!(calls[0].redirect_uri, "http://localhost:1455/auth/callback"); + drop(calls); + let acquired = state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account; + assert_eq!(acquired.account_id.as_deref(), Some("pkce-account")); +} + +#[tokio::test] +async fn admin_accounts_import_cli_should_read_auth_file_store_encrypted_and_sync_pool() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-import-cli.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([103u8; 32]); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([104u8; 32]), + ); + let app = router::router().with_state(state.clone()); + let codex_home = dir.path().join("codex-home"); + fs::create_dir_all(&codex_home).unwrap(); + let token = test_jwt( + "cli-account", + Some("cli-user"), + Some("cli@example.com"), + Some("plus"), + ); + fs::write( + codex_home.join("auth.json"), + json!({ + "access_token": token, + "refresh_token": "cli-refresh-secret" + }) + .to_string(), + ) + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import-cli") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_import_cli") + .body(Body::from( + json!({ + "codexHome": codex_home.display().to_string(), + "id": "caller-id", + "email": "caller@example.com", + "accountId": "caller-account", + "userId": "caller-user", + "label": "caller-label", + "planType": "caller-plan" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["requestId"], "req_import_cli"); + assert_eq!(body["data"]["sourceFormat"], "codex_cli"); + assert_eq!(body["data"]["imported"], 1); + assert!(body["data"].get("token").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + + let stored = SqliteAccountStore::new(pool.clone(), secret_box) + .list(None, 10) + .await + .unwrap() + .items + .remove(0); + assert_ne!(stored.id, "caller-id"); + assert_eq!(stored.email.as_deref(), Some("cli@example.com")); + assert_eq!(stored.account_id.as_deref(), Some("cli-account")); + assert_eq!(stored.user_id.as_deref(), Some("cli-user")); + assert_eq!(stored.label, None); + assert_eq!(stored.plan_type.as_deref(), Some("plus")); + assert_eq!(stored.access_token.expose_secret(), &token); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "cli-refresh-secret" + ); + let raw: (String, String) = sqlx::query_as( + "select access_token_cipher, refresh_token_cipher from accounts where id = ?", + ) + .bind(&stored.id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(raw.0.starts_with("v1:")); + assert!(!raw.0.contains(&token)); + assert!(raw.1.starts_with("v1:")); + assert!(!raw.1.contains("cli-refresh-secret")); + + let acquired = state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account; + assert_eq!(acquired.id, stored.id); +} diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_lifecycle.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_lifecycle.rs new file mode 100644 index 000000000..7a2d4f4c8 --- /dev/null +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_lifecycle.rs @@ -0,0 +1,1148 @@ +use super::*; + +#[tokio::test] +async fn admin_accounts_lifecycle_should_update_and_delete_accounts() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts-lifecycle.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + sqlx::query( + "insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", + ) + .bind("acct_lifecycle") + .bind("life@example.com") + .bind("cipher") + .bind("active") + .bind("2026-06-18T00:00:00Z") + .bind("2026-06-18T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([75u8; 32]), + ApiKeyHasher::new([76u8; 32]), + ); + let app = router::router().with_state(state); + + let label = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/admin/accounts/acct_lifecycle/label") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(json!({"label": "primary"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let label_status = label.status(); + let label_body = response_json(label).await; + + assert_eq!(label_status, StatusCode::OK); + assert_eq!(label_body["data"]["label"], "primary"); + + let status = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/admin/accounts/acct_lifecycle/status") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(json!({"status": "disabled"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status_code = status.status(); + let status_body = response_json(status).await; + + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status_body["data"]["status"], "disabled"); + + let deleted = app + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/admin/accounts/acct_lifecycle") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let delete_status = deleted.status(); + let delete_body = response_json(deleted).await; + + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(delete_body["data"]["deleted"], true); +} + +#[tokio::test] +async fn admin_account_cookies_should_store_encrypted_cookie_header() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-cookies.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([85u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_cookies".to_string(), + email: Some("cookies@example.com".to_string()), + account_id: None, + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-cookies".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool.clone(), + secret_box, + ApiKeyHasher::new([86u8; 32]), + ); + let app = router::router().with_state(state); + + let set = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/acct_cookies/cookies") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({"cookies":"cf_clearance=clear-secret; __cf_bm=bm-secret"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let set_status = set.status(); + let set_body = response_json(set).await; + + assert_eq!(set_status, StatusCode::OK); + assert_eq!( + set_body["data"]["cookies"], + "__cf_bm=bm-secret; cf_clearance=clear-secret" + ); + let stored = sqlx::query_as::<_, (String, String)>( + "select name, value_cipher from account_cookies where account_id = ? order by name asc", + ) + .bind("acct_cookies") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(stored.len(), 2); + assert!(stored.iter().all(|(_, cipher)| cipher.starts_with("v1:"))); + assert!(stored + .iter() + .all(|(_, cipher)| !cipher.contains("clear-secret") && !cipher.contains("bm-secret"))); + + let get = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/acct_cookies/cookies") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let get_status = get.status(); + let get_body = response_json(get).await; + + assert_eq!(get_status, StatusCode::OK); + assert_eq!( + get_body["data"]["cookies"], + "__cf_bm=bm-secret; cf_clearance=clear-secret" + ); + + let delete = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/admin/accounts/acct_cookies/cookies") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let delete_status = delete.status(); + let delete_body = response_json(delete).await; + + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(delete_body["data"]["deleted"], true); +} + +#[tokio::test] +async fn admin_account_cookies_should_reject_missing_account_and_empty_payload() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-cookies-invalid.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([87u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_cookie_invalid".to_string(), + email: Some("cookie-invalid@example.com".to_string()), + account_id: None, + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-cookie-invalid".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + secret_box, + ApiKeyHasher::new([88u8; 32]), + ); + let app = router::router().with_state(state); + + let missing = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/missing/cookies") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + let empty = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/acct_cookie_invalid/cookies") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(json!({"cookies": ""}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(empty.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_account_health_check_should_skip_account_without_refresh_token() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rate_limit": { + "primary_window": { + "used_percent": 1 + } + } + }))) + .expect(0) + .mount(&upstream) + .await; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-health-no-refresh.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([93u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_health".to_string(), + email: Some("health@example.com".to_string()), + account_id: Some("chatgpt-health".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-health".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let mut config = test_config(url); + config.api.base_url = upstream.uri(); + let state = AppState::with_pool_secret_and_api_key_hasher( + config, + pool, + secret_box, + ApiKeyHasher::new([94u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/health-check") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({ + "ids": ["acct_health"], + "concurrency": 1, + "staggerMs": 500 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["summary"]["total"], 1); + assert_eq!(body["data"]["summary"]["alive"], 0); + assert_eq!(body["data"]["summary"]["dead"], 0); + assert_eq!(body["data"]["summary"]["skipped"], 1); + assert_eq!(body["data"]["results"][0]["id"], "acct_health"); + assert_eq!(body["data"]["results"][0]["result"], "skipped"); + assert_eq!(body["data"]["results"][0]["status"], "active"); + assert_eq!(body["data"]["results"][0]["error"], "no refresh token"); +} + +#[tokio::test] +async fn admin_accounts_health_check_should_reject_unsupported_stagger_ms_field() { + let (app, _state, _pool, _dir, _secret_box) = + admin_accounts_test_app("admin-account-health-unsupported-field.sqlite", 125).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/health-check") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"stagger_ms":1000}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response_json(response).await; + assert_eq!(body["code"], 40001); +} + +#[tokio::test] +async fn admin_accounts_batch_status_should_update_found_accounts_and_report_invalid_requests() { + let (app, state, pool, _dir, secret_box) = + admin_accounts_test_app("admin-accounts-batch-status-route.sqlite", 120).await; + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_batch_status_a".to_string(), + email: Some("batch-a@example.com".to_string()), + account_id: Some("batch-a".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-batch-a".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + seed_encrypted_account( + &pool, + secret_box, + NewAccount { + id: "acct_batch_status_b".to_string(), + email: Some("batch-b@example.com".to_string()), + account_id: Some("batch-b".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-batch-b".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let restored = state.restore_account_pool_from_repository().await.unwrap(); + assert_eq!(restored, 2); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/batch-status") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({ + "ids": ["acct_batch_status_a", "ghost"], + "status": "disabled" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["updated"], 1); + assert_eq!(body["data"]["notFound"], json!(["ghost"])); + let statuses = + sqlx::query_as::<_, (String, String)>("select id, status from accounts order by id asc") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + statuses, + vec![ + ("acct_batch_status_a".to_string(), "disabled".to_string()), + ("acct_batch_status_b".to_string(), "active".to_string()) + ] + ); + let invalid = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/batch-status") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({ + "ids": ["acct_batch_status_a"], + "status": "expired" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + + let empty = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/batch-status") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"ids":[],"status":"active"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(empty.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_account_refresh_should_update_tokens_and_runtime_pool_without_returning_secrets() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-refresh-route.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([121u8; 32]); + let refreshed_access_token = test_jwt( + "refresh-account", + Some("refresh-user"), + Some("refresh@example.com"), + Some("plus"), + ); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_refresh_route".to_string(), + email: Some("old-refresh@example.com".to_string()), + account_id: Some("old-refresh-account".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("old-access-token".to_string().into()), + refresh_token: Some(SecretString::new("old-refresh-token".to_string().into())), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([122u8; 32]), + StaticTokenRefresher { + result: Ok(TokenPair { + access_token: refreshed_access_token.clone(), + refresh_token: Some("new-admin-refresh-rt".to_string()), + }), + calls: Arc::new(Mutex::new(Vec::new())), + }, + ); + let app = router::router().with_state(state.clone()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/acct_refresh_route/refresh") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_refresh_account") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["id"], "acct_refresh_route"); + assert_eq!(body["data"]["result"], "alive"); + assert_eq!(body["data"]["previousStatus"], "active"); + let serialized = serde_json::to_string(&body).unwrap(); + assert!(!serialized.contains(&refreshed_access_token)); + assert!(!serialized.contains("new-admin-refresh-rt")); + + let stored = SqliteAccountStore::new(pool.clone(), secret_box) + .get("acct_refresh_route") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.access_token.expose_secret(), refreshed_access_token); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "new-admin-refresh-rt" + ); + let raw: (String, String) = sqlx::query_as( + "select access_token_cipher, refresh_token_cipher from accounts where id = ?", + ) + .bind("acct_refresh_route") + .fetch_one(&pool) + .await + .unwrap(); + assert!(raw.0.starts_with("v1:")); + assert!(!raw.0.contains("old-access-token")); + assert!(!raw.0.contains(&refreshed_access_token)); + assert!(raw.1.starts_with("v1:")); + assert!(!raw.1.contains("new-admin-refresh-rt")); + let acquired = state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account; + assert_eq!(acquired.access_token, refreshed_access_token); +} + +#[tokio::test] +async fn admin_account_refresh_should_mark_invalid_refresh_token_as_expired() { + let dir = tempfile::tempdir().unwrap(); + let db = dir + .path() + .join("admin-account-refresh-invalid-route.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([123u8; 32]); + seed_encrypted_account( + &pool, + secret_box, + NewAccount { + id: "acct_refresh_invalid_route".to_string(), + email: Some("refresh-invalid@example.com".to_string()), + account_id: Some("refresh-invalid-account".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("invalid-old-access".to_string().into()), + refresh_token: Some(SecretString::new( + "invalid-refresh-token".to_string().into(), + )), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( + test_config(url), + pool.clone(), + SecretBox::new([123u8; 32]), + ApiKeyHasher::new([124u8; 32]), + StaticTokenRefresher { + result: Err(RefreshFailure::InvalidGrant), + calls: Arc::new(Mutex::new(Vec::new())), + }, + ); + let restored = state.restore_account_pool_from_repository().await.unwrap(); + assert_eq!(restored, 1); + let app = router::router().with_state(state.clone()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/acct_refresh_invalid_route/refresh") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_refresh_invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["result"], "dead"); + assert_eq!(body["data"]["status"], "expired"); + let stored_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_refresh_invalid_route") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_status.0, "expired"); + assert!(state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .is_none()); +} + +#[tokio::test] +async fn admin_account_reset_usage_should_clear_local_counters_and_pool_last_used() { + let (app, state, pool, _dir, secret_box) = + admin_accounts_test_app("admin-account-reset-usage-route.sqlite", 125).await; + let window_reset_at = Utc::now() + Duration::minutes(5); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_reset_usage_route".to_string(), + email: Some("reset-usage@example.com".to_string()), + account_id: Some("reset-usage-account".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-reset-usage".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + sqlx::query( + "insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds, last_used_at) values (?, 7, 11, 13, 17, 5, 19, 23, 29, ?, ?, 300, ?)", + ) + .bind("acct_reset_usage_route") + .bind("2026-06-12T12:30:00Z") + .bind(window_reset_at.to_rfc3339()) + .bind("2026-06-12T12:00:00Z") + .execute(&pool) + .await + .unwrap(); + state.restore_account_pool_from_repository().await.unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/acct_reset_usage_route/reset-usage") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_reset_usage") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["id"], "acct_reset_usage_route"); + assert_eq!(body["data"]["reset"], true); + type ResetUsageRow = ( + i64, + i64, + i64, + i64, + i64, + i64, + i64, + i64, + Option, + Option, + ); + let usage: ResetUsageRow = sqlx::query_as( + "select request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_reset_at, last_used_at from account_usage where account_id = ?", + ) + .bind("acct_reset_usage_route") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + usage, + ( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + Some(window_reset_at.to_rfc3339()), + None + ) + ); + let pool_account = SqliteAccountStore::new(pool, secret_box) + .get_pool_account("acct_reset_usage_route") + .await + .unwrap() + .unwrap(); + assert_eq!(pool_account.request_count, 0); + assert_eq!(pool_account.window_request_count, 0); + assert_eq!(pool_account.window_reset_at, Some(window_reset_at)); + assert!(pool_account.last_used_at.is_none()); +} + +#[tokio::test] +async fn admin_account_create_should_derive_claims_and_store_encrypted_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-create.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([91u8; 32]); + let token = test_jwt( + "jwt-account", + Some("jwt-user"), + Some("jwt@example.com"), + Some("team"), + ); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([92u8; 32]), + ); + let app = router::router().with_state(state.clone()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({ + "id": "caller-id", + "email": "caller@example.com", + "accountId": "caller-account", + "userId": "caller-user", + "label": "Caller Label", + "planType": "caller-plan", + "token": format!("Bearer {token}"), + "refreshToken": "manual-refresh-secret", + "status": "disabled" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let created_id = body["data"]["id"].as_str().unwrap(); + assert_ne!(created_id, "caller-id"); + assert_eq!(body["data"]["email"], "jwt@example.com"); + assert_eq!(body["data"]["accountId"], "jwt-account"); + assert_eq!(body["data"]["userId"], "jwt-user"); + assert_eq!(body["data"]["planType"], "team"); + assert!(body["data"]["label"].is_null()); + assert_eq!(body["data"]["status"], "active"); + assert!(body["data"].get("token").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + + let stored = SqliteAccountStore::new(pool.clone(), secret_box) + .get(created_id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.access_token.expose_secret(), &token); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "manual-refresh-secret" + ); + + let raw_cipher: (String,) = + sqlx::query_as("select access_token_cipher from accounts where id = ?") + .bind(created_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!raw_cipher.0.contains(&token)); + let raw_refresh_cipher: (String,) = + sqlx::query_as("select refresh_token_cipher from accounts where id = ?") + .bind(created_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(raw_cipher.0.starts_with("v1:")); + assert!(raw_refresh_cipher.0.starts_with("v1:")); + assert!(!raw_refresh_cipher.0.contains("manual-refresh-secret")); + + let acquired = state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account; + assert_eq!(acquired.id, created_id); + assert_eq!(acquired.email.as_deref(), Some("jwt@example.com")); + assert_eq!(acquired.account_id.as_deref(), Some("jwt-account")); + assert_eq!(acquired.user_id.as_deref(), Some("jwt-user")); + assert_eq!(acquired.plan_type.as_deref(), Some("team")); +} + +#[tokio::test] +async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_unbound_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-create-invalid.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([93u8; 32]), + ApiKeyHasher::new([94u8; 32]), + ); + let app = router::router().with_state(state); + + let cases = [ + ("missing tokens", json!({})), + ("invalid jwt", json!({"token": "not-a-jwt"})), + ( + "expired jwt", + json!({"token": test_jwt_with_exp( + Some("expired-account"), + Some("expired-user"), + Some("expired@example.com"), + Some("plus"), + 1_600_000_000, + )}), + ), + ( + "missing account claim", + json!({"token": test_jwt_with_exp( + None, + Some("claimless-user"), + Some("claimless@example.com"), + Some("free"), + 4_102_444_800, + )}), + ), + ]; + + for (name, payload) in cases { + let response = post_admin_account(&app, payload).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{name}"); + } +} + +#[tokio::test] +async fn admin_account_manual_create_refresh_only_should_exchange_rotate_and_sync_pool() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-refresh-only.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([95u8; 32]); + let token = test_jwt( + "rt-account", + Some("rt-user"), + Some("rt@example.com"), + Some("plus"), + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([96u8; 32]), + StaticTokenRefresher { + result: Ok(TokenPair { + access_token: token.clone(), + refresh_token: Some("rotated-refresh".to_string()), + }), + calls: calls.clone(), + }, + ); + let app = router::router().with_state(state.clone()); + + let response = post_admin_account( + &app, + json!({ + "refreshToken": "initial-refresh", + "email": "caller@example.com", + "planType": "caller-plan" + }), + ) + .await; + let status = response.status(); + let body = response_json(response).await; + let created_id = body["data"]["id"].as_str().unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["email"], "rt@example.com"); + assert_eq!(body["data"]["accountId"], "rt-account"); + assert!(body["data"].get("token").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + assert_eq!(*calls.lock().await, vec!["initial-refresh".to_string()]); + + let stored = SqliteAccountStore::new(pool, secret_box) + .get(created_id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.access_token.expose_secret(), &token); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "rotated-refresh" + ); + + let acquired = state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account; + assert_eq!(acquired.id, created_id); + assert_eq!(acquired.refresh_token.as_deref(), Some("rotated-refresh")); +} + +#[tokio::test] +async fn admin_account_manual_create_refresh_only_should_preserve_input_refresh_without_rotation() { + let dir = tempfile::tempdir().unwrap(); + let db = dir + .path() + .join("admin-account-refresh-preserve-input.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([97u8; 32]); + let token = test_jwt( + "rt-preserve-account", + Some("rt-preserve-user"), + Some("preserve@example.com"), + Some("free"), + ); + let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([98u8; 32]), + StaticTokenRefresher { + result: Ok(TokenPair { + access_token: token, + refresh_token: None, + }), + calls: Arc::new(Mutex::new(Vec::new())), + }, + ); + let app = router::router().with_state(state); + + let response = post_admin_account(&app, json!({"refreshToken": "preserved-refresh"})).await; + let body = response_json(response).await; + let created_id = body["data"]["id"].as_str().unwrap(); + + let stored = SqliteAccountStore::new(pool, secret_box) + .get(created_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "preserved-refresh" + ); +} + +#[tokio::test] +async fn admin_account_manual_create_should_update_existing_and_preserve_refresh() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-update-existing.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([99u8; 32]); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([100u8; 32]), + ); + let app = router::router().with_state(state.clone()); + + let first_token = test_jwt( + "team-account", + Some("same-user"), + Some("first@example.com"), + Some("free"), + ); + let first_response = post_admin_account( + &app, + json!({ + "token": first_token, + "refreshToken": "first-refresh" + }), + ) + .await; + let first_body = response_json(first_response).await; + let first_id = first_body["data"]["id"].as_str().unwrap().to_string(); + + let second_token = test_jwt( + "team-account", + Some("same-user"), + Some("second@example.com"), + Some("team"), + ); + let second_response = post_admin_account(&app, json!({"token": second_token})).await; + let status = second_response.status(); + let second_body = response_json(second_response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(second_body["data"]["id"], first_id); + assert_eq!(second_body["data"]["email"], "second@example.com"); + assert_eq!(second_body["data"]["planType"], "team"); + + let count: (i64,) = sqlx::query_as("select count(*) from accounts") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count.0, 1); + let stored = SqliteAccountStore::new(pool, secret_box) + .get(&first_id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.access_token.expose_secret(), &second_token); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "first-refresh" + ); + + let acquired = state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .unwrap() + .account; + assert_eq!(acquired.id, first_id); + assert_eq!(acquired.access_token, second_token); + assert_eq!(acquired.refresh_token.as_deref(), Some("first-refresh")); +} + +#[tokio::test] +async fn admin_account_manual_create_refresh_only_should_preserve_existing_refresh_without_rotation( +) { + let dir = tempfile::tempdir().unwrap(); + let db = dir + .path() + .join("admin-account-refresh-preserve-existing.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([101u8; 32]); + let refreshed_token = test_jwt( + "rt-existing-account", + Some("rt-existing-user"), + Some("second@example.com"), + Some("team"), + ); + let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([102u8; 32]), + StaticTokenRefresher { + result: Ok(TokenPair { + access_token: refreshed_token.clone(), + refresh_token: None, + }), + calls: Arc::new(Mutex::new(Vec::new())), + }, + ); + let app = router::router().with_state(state); + let first_token = test_jwt( + "rt-existing-account", + Some("rt-existing-user"), + Some("first@example.com"), + Some("free"), + ); + let first_response = post_admin_account( + &app, + json!({ + "token": first_token, + "refreshToken": "old-refresh" + }), + ) + .await; + let first_body = response_json(first_response).await; + let account_id = first_body["data"]["id"].as_str().unwrap().to_string(); + + let second_response = + post_admin_account(&app, json!({"refreshToken": "incoming-refresh"})).await; + assert_eq!(second_response.status(), StatusCode::OK); + + let stored = SqliteAccountStore::new(pool, secret_box) + .get(&account_id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.access_token.expose_secret(), &refreshed_token); + assert_eq!(stored.refresh_token.unwrap().expose_secret(), "old-refresh"); + assert_eq!(stored.email.as_deref(), Some("second@example.com")); + assert_eq!(stored.plan_type.as_deref(), Some("team")); +} diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_list.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_list.rs new file mode 100644 index 000000000..1a89da1a3 --- /dev/null +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_list.rs @@ -0,0 +1,48 @@ +use super::*; + +#[tokio::test] +async fn admin_accounts_list_should_not_decrypt_account_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-accounts.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + sqlx::query( + "insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", + ) + .bind("acct_corrupt") + .bind("user@example.com") + .bind("not-a-secret-box-cipher") + .bind("active") + .bind("2026-06-18T00:00:00Z") + .bind("2026-06-18T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([63u8; 32]), + ApiKeyHasher::new([64u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["requestId"], "req_accounts_list"); + assert_eq!(body["data"][0]["id"], "acct_corrupt"); + assert_eq!(body["data"][0]["email"], "user@example.com"); +} diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs new file mode 100644 index 000000000..d30eda60e --- /dev/null +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs @@ -0,0 +1,535 @@ +use super::*; + +#[tokio::test] +async fn admin_auth_login_start_should_return_pkce_auth_url_and_state() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-auth-login-start.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([95u8; 32]), + ApiKeyHasher::new([96u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "127.0.0.1:8080") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + let state = body["data"]["state"].as_str().unwrap(); + let auth_url = body["data"]["authUrl"].as_str().unwrap(); + assert_eq!(state.len(), 32); + assert!(auth_url.starts_with("https://auth.openai.com/oauth/authorize?")); + assert!(auth_url.contains("response_type=code")); + assert!(auth_url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann")); + assert!(auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback")); + assert!(auth_url.contains("scope=openid%20profile%20email%20offline_access")); + assert!(auth_url.contains("code_challenge_method=S256")); + assert!(auth_url.contains("originator=codex_cli_rs")); + assert!(auth_url.contains(&format!("state={state}"))); +} + +#[tokio::test] +async fn admin_auth_status_should_require_admin_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-auth-status-auth.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([105u8; 32]), + ApiKeyHasher::new([106u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/auth/status") + .header("x-request-id", "req_auth_status_no_session") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], 40101); + assert_eq!(body["requestId"], "req_auth_status_no_session"); +} + +#[tokio::test] +async fn admin_auth_status_should_report_user_and_pool_summary_without_secrets() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-auth-status-summary.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([107u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_auth_active".to_string(), + email: Some("active-auth@example.com".to_string()), + account_id: Some("auth-active-account".to_string()), + user_id: Some("auth-active-user".to_string()), + label: None, + plan_type: Some("plus".to_string()), + access_token: SecretString::new("access-auth-active".to_string().into()), + refresh_token: Some(SecretString::new("refresh-auth-active".to_string().into())), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_auth_disabled".to_string(), + email: Some("disabled-auth@example.com".to_string()), + account_id: Some("auth-disabled-account".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-auth-disabled".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Disabled, + }, + ) + .await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + secret_box, + ApiKeyHasher::new([108u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/auth/status") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_auth_status_summary") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["authenticated"], true); + assert_eq!(body["data"]["pool"]["total"], 2); + assert_eq!(body["data"]["pool"]["active"], 1); + assert_eq!(body["data"]["pool"]["disabled"], 1); + assert_eq!(body["data"]["user"]["email"], "active-auth@example.com"); + assert!(body["data"]["user"].get("accessToken").is_none()); + assert!(body["data"]["user"].get("token").is_none()); + assert!(body["data"]["user"].get("refreshToken").is_none()); +} + +#[tokio::test] +async fn admin_auth_logout_should_clear_accounts_usage_cookies_and_runtime_pool() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-auth-logout.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([109u8; 32]); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([110u8; 32]), + ); + let app = router::router().with_state(state.clone()); + let imported = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({ + "accounts": [{ + "id": "acct_auth_logout", + "email": "logout@example.com", + "planType": "plus", + "token": "access-auth-logout", + "refreshToken": "refresh-auth-logout", + "status": "active" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::OK); + sqlx::query( + "insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens) values (?, 2, 3, 4, 1)", + ) + .bind("acct_auth_logout") + .execute(&pool) + .await + .unwrap(); + let set_cookies = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/acct_auth_logout/cookies") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"cookies":"cf_clearance=secret"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(set_cookies.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/logout") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_auth_logout") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["success"], true); + assert_eq!(body["data"]["deleted"], 1); + let account_count: (i64,) = sqlx::query_as("select count(*) from accounts") + .fetch_one(&pool) + .await + .unwrap(); + let usage_count: (i64,) = sqlx::query_as("select count(*) from account_usage") + .fetch_one(&pool) + .await + .unwrap(); + let cookie_count: (i64,) = sqlx::query_as("select count(*) from account_cookies") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(account_count.0, 0); + assert_eq!(usage_count.0, 0); + assert_eq!(cookie_count.0, 0); + assert!(state + .services + .account_pool + .acquire("gpt-5.5", Utc::now()) + .await + .is_none()); +} + +#[tokio::test] +async fn admin_auth_device_login_should_return_openai_device_code() { + let client = StaticOAuthClient { + device_response: Ok(DeviceCode { + device_code: "device-secret".to_string(), + user_code: "ABCD-EFGH".to_string(), + verification_uri: "https://auth.openai.com/activate".to_string(), + verification_uri_complete: "https://auth.openai.com/activate?user_code=ABCD-EFGH" + .to_string(), + expires_in: 900, + interval: 5, + }), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: Arc::new(Mutex::new(Vec::new())), + }; + let (app, _state, _pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-device-login.sqlite", 111, client) + .await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/device-login") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_device_login") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["userCode"], "ABCD-EFGH"); + assert_eq!( + body["data"]["verificationUri"], + "https://auth.openai.com/activate" + ); + assert_eq!(body["data"]["deviceCode"], "device-secret"); + assert_eq!(body["data"]["expiresIn"], 900); + assert_eq!(body["data"]["interval"], 5); +} + +#[tokio::test] +async fn admin_auth_login_start_should_use_configured_oauth_authorize_endpoint() { + let dir = tempfile::tempdir().unwrap(); + let db = dir + .path() + .join("admin-auth-login-start-configured-oauth.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let mut config = test_config(url); + config.auth.oauth_client_id = "app_configured_client".to_string(); + config.auth.oauth_auth_endpoint = "https://auth.example.test/oauth/authorize".to_string(); + config.auth.oauth_token_endpoint = "https://auth.example.test/oauth/token".to_string(); + let state = AppState::with_pool_secret_and_api_key_hasher( + config, + pool, + SecretBox::new([114u8; 32]), + ApiKeyHasher::new([115u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "127.0.0.1:8080") + .header("x-request-id", "req_login_start_configured_oauth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let auth_url = body["data"]["authUrl"].as_str().unwrap(); + + assert!(auth_url.starts_with("https://auth.example.test/oauth/authorize?")); + assert!(auth_url.contains("client_id=app_configured_client")); +} + +#[tokio::test] +async fn admin_auth_code_relay_should_reject_invalid_callback_url() { + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: Arc::new(Mutex::new(Vec::new())), + }; + let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_client( + "admin-auth-code-relay-invalid.sqlite", + 117, + client, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/code-relay") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_code_relay_invalid") + .body(Body::from(json!({"callbackUrl": "not a url"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host() { + let token = test_jwt( + "callback-account", + Some("callback-user"), + Some("callback@example.com"), + Some("plus"), + ); + let exchange_calls = Arc::new(Mutex::new(Vec::new())); + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Ok(TokenPair { + access_token: token, + refresh_token: Some("callback-refresh-secret".to_string()), + }), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: exchange_calls.clone(), + }; + let (app, _state, pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-callback.sqlite", 118, client).await; + let start = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "codex.local:1455") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let start_body = response_json(start).await; + let state_value = start_body["data"]["state"].as_str().unwrap(); + let callback_path = format!("/auth/callback?code=callback-code&state={state_value}"); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(callback_path) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "http://codex.local:1455/" + ); + assert_eq!(exchange_calls.lock().await[0].code, "callback-code"); + let count: (i64,) = sqlx::query_as("select count(*) from accounts where account_id = ?") + .bind("callback-account") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count.0, 1); +} + +#[tokio::test] +async fn admin_accounts_health_check_should_refresh_oauth_without_touching_codex_backend() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&upstream) + .await; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-health-refresh.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([124u8; 32]); + for (id, refresh_token) in [ + ("acct_health_alive", "refresh-acct_health_alive"), + ("acct_health_dead", "refresh-acct_health_dead"), + ] { + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: id.to_string(), + email: Some(format!("{id}@example.com")), + account_id: Some(format!("old-{id}")), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new(format!("old-access-{id}").into()), + refresh_token: Some(SecretString::new(refresh_token.to_string().into())), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + } + let calls = Arc::new(Mutex::new(Vec::new())); + let mut config = test_config(url); + config.api.base_url = upstream.uri(); + let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher( + config, + pool.clone(), + secret_box.clone(), + ApiKeyHasher::new([124u8; 32]), + HealthCheckTokenRefresher { + calls: calls.clone(), + }, + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/health-check") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_health_refresh") + .body(Body::from( + json!({ + "ids": ["acct_health_alive", "acct_health_dead"], + "concurrency": 2, + "staggerMs": 500 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["summary"]["total"], 2); + assert_eq!(body["data"]["summary"]["alive"], 1); + assert_eq!(body["data"]["summary"]["dead"], 1); + assert_eq!(body["data"]["summary"]["skipped"], 0); + let mut refresh_calls = calls.lock().await.clone(); + refresh_calls.sort(); + assert_eq!( + refresh_calls, + vec![ + "refresh-acct_health_alive".to_string(), + "refresh-acct_health_dead".to_string(), + ] + ); + let serialized = serde_json::to_string(&body).unwrap(); + assert!(!serialized.contains("new-health-access")); + assert!(!serialized.contains("new-health-refresh")); + let dead_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_health_dead") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(dead_status.0, "expired"); +} diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs new file mode 100644 index 000000000..5eb63bbba --- /dev/null +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs @@ -0,0 +1,780 @@ +use super::*; + +#[tokio::test] +async fn admin_usage_stats_should_return_page_and_summary() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-usage.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + sqlx::query( + "insert into accounts (id, email, label, plan_type, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind("acct_usage") + .bind("usage@example.com") + .bind("primary") + .bind("plus") + .bind("cipher") + .bind("active") + .bind("2026-06-18T00:00:00Z") + .bind("2026-06-18T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "insert into account_usage (account_id, request_count, empty_response_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, last_used_at) values (?, 3, 1, 21, 8, 5, 7, 2, 1, 0, ?)", + ) + .bind("acct_usage") + .bind("2026-06-18T00:10:00Z") + .execute(&pool) + .await + .unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([73u8; 32]), + ApiKeyHasher::new([74u8; 32]), + ); + let app = router::router().with_state(state); + + let page_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage-stats?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_page") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let page_status = page_response.status(); + let page_body = response_json(page_response).await; + + assert_eq!(page_status, StatusCode::OK); + assert_eq!(page_body["data"][0]["accountId"], "acct_usage"); + assert_eq!(page_body["data"][0]["email"], "usage@example.com"); + assert_eq!(page_body["data"][0]["requestCount"], 3); + assert_eq!(page_body["data"][0]["inputTokens"], 21); + + let summary_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage-stats/summary") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_summary") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let summary_status = summary_response.status(); + let summary_body = response_json(summary_response).await; + + assert_eq!(summary_status, StatusCode::OK); + assert_eq!(summary_body["data"]["accountCount"], 1); + assert_eq!(summary_body["data"]["requestCount"], 3); + assert_eq!(summary_body["data"]["outputTokens"], 8); +} + +#[tokio::test] +async fn admin_usage_stats_should_require_admin_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-usage-auth.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([125u8; 32]), + ApiKeyHasher::new([126u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage-stats") + .header("x-request-id", "req_usage_auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], 40101); + assert_eq!(body["requestId"], "req_usage_auth"); +} + +#[tokio::test] +async fn admin_usage_stats_should_cursor_page_account_usage() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-usage-cursor.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + seed_usage_account( + &pool, + "acct_a", + "a@example.com", + "primary", + "plus", + 3, + 0, + 12, + 5, + 1, + "2026-06-11T00:00:00Z", + ) + .await; + seed_usage_account( + &pool, + "acct_b", + "b@example.com", + "backup", + "free", + 2, + 1, + 7, + 3, + 2, + "2026-06-11T00:01:00Z", + ) + .await; + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([127u8; 32]), + ApiKeyHasher::new([128u8; 32]), + ); + let app = router::router().with_state(state); + + let first_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/usage-stats?limit=1") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_usage_cursor") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let first_status = first_response.status(); + let first_body = response_json(first_response).await; + + assert_eq!(first_status, StatusCode::OK); + assert_eq!(first_body["code"], 200); + assert_eq!(first_body["requestId"], "req_usage_cursor"); + assert_eq!(first_body["data"].as_array().unwrap().len(), 1); + assert_eq!(first_body["data"][0]["accountId"], "acct_b"); + assert_eq!(first_body["data"][0]["requestCount"], 2); + assert_eq!(first_body["data"][0]["emptyResponseCount"], 1); + assert_eq!(first_body["data"][0]["inputTokens"], 7); + assert_eq!(first_body["page"]["limit"], 1); + let cursor = first_body["page"]["nextCursor"].as_str().unwrap(); + + let second_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/admin/usage-stats?limit=1&cursor={cursor}")) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let second_status = second_response.status(); + let second_body = response_json(second_response).await; + + assert_eq!(second_status, StatusCode::OK); + assert_eq!(second_body["data"][0]["accountId"], "acct_a"); + assert!(second_body["page"]["nextCursor"].is_null()); +} + +#[tokio::test] +async fn admin_account_quota_should_fetch_usage_store_quota_and_not_return_secrets() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer access-quota")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plan_type": "plus", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 25, + "reset_at": 1770000400, + "limit_window_seconds": 3600 + } + } + }))) + .expect(1) + .mount(&upstream) + .await; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([95u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_quota".to_string(), + email: Some("quota@example.com".to_string()), + account_id: Some("chatgpt-quota".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-quota".to_string().into()), + refresh_token: Some(SecretString::new("refresh-quota".to_string().into())), + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let mut config = test_config(url); + config.api.base_url = upstream.uri(); + let state = AppState::with_pool_secret_and_api_key_hasher( + config, + pool.clone(), + secret_box, + ApiKeyHasher::new([96u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/acct_quota/quota") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_quota") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + + assert_eq!(status, StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["quota"]["plan_type"], "plus"); + assert_eq!(body["data"]["quota"]["rate_limit"]["remaining_percent"], 75); + assert_eq!( + body["data"]["raw"]["rate_limit"]["primary_window"]["used_percent"], + 25 + ); + let serialized = serde_json::to_string(&body).unwrap(); + assert!(!serialized.contains("access-quota")); + assert!(!serialized.contains("refresh-quota")); + + let stored: (String,) = sqlx::query_as("select quota_json from accounts where id = ?") + .bind("acct_quota") + .fetch_one(&pool) + .await + .unwrap(); + assert!(stored.0.contains("\"remaining_percent\":75")); + assert!(!stored.0.contains("access-quota")); +} + +#[tokio::test] +async fn admin_account_quota_should_return_bad_gateway_when_usage_fetch_fails() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer access-quota-fail")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": { + "message": "quota unavailable" + } + }))) + .expect(1) + .mount(&upstream) + .await; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota-fail.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([97u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_quota_fail".to_string(), + email: Some("quota-fail@example.com".to_string()), + account_id: Some("chatgpt-quota-fail".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-quota-fail".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + let mut config = test_config(url); + config.api.base_url = upstream.uri(); + let state = AppState::with_pool_secret_and_api_key_hasher( + config, + pool, + secret_box, + ApiKeyHasher::new([98u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/acct_quota_fail/quota") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_quota_fail") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!(body["code"], 50201); + assert_eq!(body["message"], "Failed to fetch quota from Codex API"); + assert!(body["data"]["error"] + .as_str() + .is_some_and(|error| error.contains("quota unavailable"))); + assert_eq!(body["requestId"], "req_quota_fail"); +} + +#[tokio::test] +async fn admin_account_quota_should_reject_inactive_account_without_calling_upstream() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(0) + .mount(&upstream) + .await; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota-inactive.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([99u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_quota_inactive".to_string(), + email: Some("quota-inactive@example.com".to_string()), + account_id: Some("chatgpt-quota-inactive".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-quota-inactive".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Disabled, + }, + ) + .await; + let mut config = test_config(url); + config.api.base_url = upstream.uri(); + let state = AppState::with_pool_secret_and_api_key_hasher( + config, + pool.clone(), + secret_box, + ApiKeyHasher::new([100u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/acct_quota_inactive/quota") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_quota_inactive") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let quota_json: (Option,) = + sqlx::query_as("select quota_json from accounts where id = ?") + .bind("acct_quota_inactive") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], 40901); + assert_eq!(body["message"], "Account is disabled, cannot query quota"); + assert_eq!(body["requestId"], "req_quota_inactive"); + assert!(quota_json.0.is_none()); +} + +#[tokio::test] +async fn admin_account_quota_should_return_store_error_when_quota_persistence_fails() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer access-quota-store-fail")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plan_type": "plus", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 40, + "reset_at": 1770000500, + "limit_window_seconds": 3600 + } + } + }))) + .expect(1) + .mount(&upstream) + .await; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota-store-fail.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([101u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_quota_store_fail".to_string(), + email: Some("quota-store-fail@example.com".to_string()), + account_id: Some("chatgpt-quota-store-fail".to_string()), + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-quota-store-fail".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + sqlx::query( + "create trigger quota_write_denied before update of quota_json on accounts begin select raise(abort, 'quota write denied'); end", + ) + .execute(&pool) + .await + .unwrap(); + let mut config = test_config(url); + config.api.base_url = upstream.uri(); + let state = AppState::with_pool_secret_and_api_key_hasher( + config, + pool.clone(), + secret_box, + ApiKeyHasher::new([102u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/acct_quota_store_fail/quota") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_quota_store_fail") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let quota_json: (Option,) = + sqlx::query_as("select quota_json from accounts where id = ?") + .bind("acct_quota_store_fail") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body["code"], 50001); + assert_eq!(body["message"], "Failed to store account quota"); + assert_eq!(body["requestId"], "req_quota_store_fail"); + assert!(quota_json.0.is_none()); +} + +#[tokio::test] +async fn admin_account_quota_warnings_should_require_admin_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota-warnings-auth.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([87u8; 32]), + ApiKeyHasher::new([88u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/quota-warnings") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn admin_account_quota_warnings_should_return_threshold_matches_from_cached_quota() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota-warnings.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([89u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_warn".to_string(), + email: Some("warn@example.com".to_string()), + account_id: None, + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-warn".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_quiet".to_string(), + email: Some("quiet@example.com".to_string()), + account_id: None, + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-quiet".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + sqlx::query( + "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", + ) + .bind( + json!({ + "rate_limit": { + "used_percent": 85, + "reset_at": 1770000100 + }, + "secondary_rate_limit": { + "used_percent": 91, + "reset_at": 1770000200 + } + }) + .to_string(), + ) + .bind("2026-06-13T00:00:00Z") + .bind("2026-06-13T00:00:00Z") + .bind("acct_warn") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", + ) + .bind( + json!({ + "rate_limit": { + "used_percent": 25, + "reset_at": 1770000300 + }, + "secondary_rate_limit": null + }) + .to_string(), + ) + .bind("2026-06-13T01:00:00Z") + .bind("2026-06-13T01:00:00Z") + .bind("acct_quiet") + .execute(&pool) + .await + .unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + secret_box, + ApiKeyHasher::new([90u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/quota-warnings") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["updatedAt"], "2026-06-13T00:00:00+00:00"); + let warnings = body["data"]["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 2); + assert!(warnings + .iter() + .all(|warning| warning["accountId"] == "acct_warn")); + assert!(warnings.iter().any(|warning| { + warning["window"] == "primary" + && warning["level"] == "warning" + && warning["usedPercent"] == 85.0 + && warning["resetAt"] == 1770000100 + })); + assert!(warnings.iter().any(|warning| { + warning["window"] == "secondary" + && warning["level"] == "critical" + && warning["usedPercent"] == 91.0 + && warning["resetAt"] == 1770000200 + })); +} + +#[tokio::test] +async fn admin_account_quota_warnings_should_ignore_invalid_and_below_threshold_snapshots() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-account-quota-warnings-edge.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let secret_box = SecretBox::new([85u8; 32]); + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_invalid_quota".to_string(), + email: Some("invalid-quota@example.com".to_string()), + account_id: None, + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-invalid-quota".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + seed_encrypted_account( + &pool, + secret_box.clone(), + NewAccount { + id: "acct_below_threshold".to_string(), + email: Some("below-threshold@example.com".to_string()), + account_id: None, + user_id: None, + label: None, + plan_type: None, + access_token: SecretString::new("access-below-threshold".to_string().into()), + refresh_token: None, + access_token_expires_at: None, + status: AccountStatus::Active, + }, + ) + .await; + sqlx::query( + "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", + ) + .bind("{not valid json") + .bind("2026-06-13T00:00:00Z") + .bind("2026-06-13T00:00:00Z") + .bind("acct_invalid_quota") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", + ) + .bind( + json!({ + "rate_limit": { + "used_percent": 79, + "reset_at": 1770000600 + }, + "secondary_rate_limit": { + "used_percent": 79, + "reset_at": 1770000700 + } + }) + .to_string(), + ) + .bind("2026-06-13T01:00:00Z") + .bind("2026-06-13T01:00:00Z") + .bind("acct_below_threshold") + .execute(&pool) + .await + .unwrap(); + let state = AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + secret_box, + ApiKeyHasher::new([86u8; 32]), + ); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/quota-warnings") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(body["data"]["warnings"].as_array().unwrap().is_empty()); + assert!(body["data"]["updatedAt"].is_null()); +} diff --git a/crates/server/tests/admin_client_keys_routes.rs b/crates/server/tests/admin_client_keys_routes.rs index 69edc78ea..d747e8965 100644 --- a/crates/server/tests/admin_client_keys_routes.rs +++ b/crates/server/tests/admin_client_keys_routes.rs @@ -20,533 +20,12 @@ use serde_json::{json, Value}; use sqlx::SqlitePool; use tower::util::ServiceExt; -#[tokio::test] -async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() { - let (app, _dir) = admin_client_key_test_app("admin-client-key-create.sqlite").await; - - let rejected = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", "Bearer cpr_not_stored") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); - - let create_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/api-keys") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_api_key") - .body(Body::from(r#"{"name":"cursor"}"#)) - .unwrap(), - ) - .await - .unwrap(); - let create_status = create_response.status(); - let create_body = response_json(create_response).await; - - assert_eq!(create_status, StatusCode::OK); - assert!(create_body["data"]["plaintext"] - .as_str() - .is_some_and(|plaintext| plaintext.starts_with("cpr_"))); - assert_eq!(create_body["requestId"], "req_api_key"); - - let plaintext = create_body["data"]["plaintext"] - .as_str() - .unwrap() - .to_string(); - let list_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/api-keys?limit=10") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let list_status = list_response.status(); - let list_body = response_json(list_response).await; - - assert_eq!(list_status, StatusCode::OK); - assert_eq!(list_body["data"][0]["name"], "cursor"); - assert!(list_body["data"][0].get("plaintext").is_none()); - assert!(list_body["data"][0].get("keyHash").is_none()); - - let models_response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(models_response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn admin_client_keys_status_should_disable_and_enable_authorization() { - let (app, _dir) = admin_client_key_test_app("admin-client-key-status.sqlite").await; - let (key_id, plaintext) = create_admin_client_key(&app, "status-key").await; - - let disabled = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/admin/api-keys/{key_id}/status")) - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"status":"disabled"}"#)) - .unwrap(), - ) - .await - .unwrap(); - let disabled_status = disabled.status(); - let disabled_body = response_json(disabled).await; - - assert_eq!(disabled_status, StatusCode::OK); - assert_eq!(disabled_body["data"]["enabled"], false); - - let rejected = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); - - let enabled = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/admin/api-keys/{key_id}/status")) - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"status":"active"}"#)) - .unwrap(), - ) - .await - .unwrap(); - let enabled_status = enabled.status(); - let enabled_body = response_json(enabled).await; - - assert_eq!(enabled_status, StatusCode::OK); - assert_eq!(enabled_body["data"]["enabled"], true); - - let accepted = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(accepted.status(), StatusCode::OK); - - let invalid = app - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/admin/api-keys/{key_id}/status")) - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"status":"expired"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_client_keys_delete_should_remove_authorization() { - let (app, _dir) = admin_client_key_test_app("admin-client-key-delete.sqlite").await; - let (key_id, plaintext) = create_admin_client_key(&app, "delete-key").await; - - let deleted = app - .clone() - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/api/admin/api-keys/{key_id}")) - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let delete_status = deleted.status(); - let delete_body = response_json(deleted).await; - - assert_eq!(delete_status, StatusCode::OK); - assert_eq!(delete_body["data"]["deleted"], true); - - let list_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/api-keys?limit=10") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let list_body = response_json(list_response).await; - assert_eq!(list_body["data"].as_array().unwrap().len(), 0); - - let rejected = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); - - let missing = app - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/api/admin/api-keys/{key_id}")) - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(missing.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn admin_client_keys_label_should_update_clear_and_validate_label() { - let (app, _dir) = admin_client_key_test_app("admin-client-key-label.sqlite").await; - let (key_id, _plaintext) = create_admin_client_key(&app, "label-key").await; - - let renamed = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/admin/api-keys/{key_id}/label")) - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"label":"automation"}"#)) - .unwrap(), - ) - .await - .unwrap(); - let renamed_status = renamed.status(); - let renamed_body = response_json(renamed).await; - - assert_eq!(renamed_status, StatusCode::OK); - assert_eq!(renamed_body["data"]["name"], "label-key"); - assert_eq!(renamed_body["data"]["label"], "automation"); - - let cleared = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/admin/api-keys/{key_id}/label")) - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"label":null}"#)) - .unwrap(), - ) - .await - .unwrap(); - let cleared_status = cleared.status(); - let cleared_body = response_json(cleared).await; - - assert_eq!(cleared_status, StatusCode::OK); - assert_eq!(cleared_body["data"]["name"], "label-key"); - assert!(cleared_body["data"]["label"].is_null()); - - let too_long = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/admin/api-keys/{key_id}/label")) - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(json!({ "label": "x".repeat(65) }).to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(too_long.status(), StatusCode::BAD_REQUEST); - - let missing = app - .oneshot( - Request::builder() - .method("PATCH") - .uri("/api/admin/api-keys/missing/label") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"label":"automation"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(missing.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn admin_client_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() { - let (app, _dir) = admin_client_key_test_app("admin-client-key-batch-delete.sqlite").await; - let (key_a, plaintext_a) = create_admin_client_key(&app, "batch-a").await; - let (key_b, plaintext_b) = create_admin_client_key(&app, "batch-b").await; - - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/api-keys/batch-delete") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from( - json!({ - "ids": [key_a, "ghost", key_b] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["deleted"], 2); - assert_eq!(body["data"]["notFound"], json!(["ghost"])); - - for plaintext in [plaintext_a, plaintext_b] { - let rejected = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); - } - - let empty = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/api-keys/batch-delete") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"ids":[]}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(empty.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn admin_client_keys_export_should_return_metadata_without_secret_material() { - let (app, _dir) = admin_client_key_test_app("admin-client-key-export.sqlite").await; - let (key_id, plaintext) = create_admin_client_key(&app, "export-key").await; - - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/api/admin/api-keys/export?ids={key_id}")) - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_api_key_export") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["requestId"], "req_api_key_export"); - assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys"); - assert_eq!(body["data"]["rotationRequired"], true); - assert_eq!(body["data"]["apiKeys"][0]["id"], key_id); - assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key"); - assert!(body["data"]["apiKeys"][0]["prefix"] - .as_str() - .is_some_and(|prefix| prefix.starts_with("cpr_"))); - assert!(body["data"]["apiKeys"][0].get("plaintext").is_none()); - assert!(body["data"]["apiKeys"][0].get("keyHash").is_none()); - assert!(body["data"].get("pepper").is_none()); - assert!(!body.to_string().contains(&plaintext)); -} - -#[tokio::test] -async fn admin_client_keys_import_should_rotate_exported_metadata_and_return_plaintext_once() { - let source_dir = tempfile::tempdir().unwrap(); - let source_db = source_dir - .path() - .join("admin-client-key-export-source.sqlite"); - let source_url = format!("sqlite://{}", source_db.display()); - let source_pool = connect_sqlite(&source_url).await.unwrap(); - seed_admin_session(&source_pool, "session_1").await; - let source_state = AppState::with_pool_secret_and_api_key_hasher( - test_config(source_url), - source_pool, - SecretBox::new([51u8; 32]), - ApiKeyHasher::new([52u8; 32]), - ); - let source_app = router::router().with_state(source_state); - let (source_key_id, source_plaintext) = - create_admin_client_key(&source_app, "rotated-key").await; - - let export_response = source_app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/api-keys/export") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(export_response.status(), StatusCode::OK); - let export_body = response_json(export_response).await; - - let target_dir = tempfile::tempdir().unwrap(); - let target_db = target_dir - .path() - .join("admin-client-key-import-target.sqlite"); - let target_url = format!("sqlite://{}", target_db.display()); - let target_pool = connect_sqlite(&target_url).await.unwrap(); - seed_admin_session(&target_pool, "session_1").await; - let target_state = AppState::with_pool_secret_and_api_key_hasher( - test_config(target_url), - target_pool, - SecretBox::new([61u8; 32]), - ApiKeyHasher::new([62u8; 32]), - ); - let target_app = router::router().with_state(target_state); - - let import_response = target_app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/admin/api-keys/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_api_key_import") - .body(Body::from(export_body["data"].to_string())) - .unwrap(), - ) - .await - .unwrap(); - let status = import_response.status(); - let import_body = response_json(import_response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(import_body["requestId"], "req_api_key_import"); - assert_eq!(import_body["data"]["imported"], 1); - assert_eq!(import_body["data"]["skipped"], 0); - assert_eq!(import_body["data"]["rotated"], true); - assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id); - assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key"); - assert!(import_body["data"]["keys"][0].get("keyHash").is_none()); - let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"] - .as_str() - .unwrap() - .to_string(); - assert!(rotated_plaintext.starts_with("cpr_")); - assert_ne!(rotated_plaintext, source_plaintext); - - let list_response = target_app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/admin/api-keys?limit=10") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let list_body = response_json(list_response).await; - assert!(list_body["data"][0].get("plaintext").is_none()); - - let old_rejected = target_app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {source_plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED); - - let new_accepted = target_app - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {rotated_plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(new_accepted.status(), StatusCode::OK); -} +#[path = "admin_client_keys_routes/authorization.rs"] +mod authorization; +#[path = "admin_client_keys_routes/import_export.rs"] +mod import_export; +#[path = "admin_client_keys_routes/lifecycle.rs"] +mod lifecycle; async fn admin_client_key_test_app(db_name: &str) -> (axum::Router, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/server/tests/admin_client_keys_routes/authorization.rs b/crates/server/tests/admin_client_keys_routes/authorization.rs new file mode 100644 index 000000000..08690e5ad --- /dev/null +++ b/crates/server/tests/admin_client_keys_routes/authorization.rs @@ -0,0 +1,234 @@ +use super::*; + +#[tokio::test] +async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() { + let (app, _dir) = admin_client_key_test_app("admin-client-key-create.sqlite").await; + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", "Bearer cpr_not_stored") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/api-keys") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_api_key") + .body(Body::from(r#"{"name":"cursor"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let create_status = create_response.status(); + let create_body = response_json(create_response).await; + + assert_eq!(create_status, StatusCode::OK); + assert!(create_body["data"]["plaintext"] + .as_str() + .is_some_and(|plaintext| plaintext.starts_with("cpr_"))); + assert_eq!(create_body["requestId"], "req_api_key"); + + let plaintext = create_body["data"]["plaintext"] + .as_str() + .unwrap() + .to_string(); + let list_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/api-keys?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_status = list_response.status(); + let list_body = response_json(list_response).await; + + assert_eq!(list_status, StatusCode::OK); + assert_eq!(list_body["data"][0]["name"], "cursor"); + assert!(list_body["data"][0].get("plaintext").is_none()); + assert!(list_body["data"][0].get("keyHash").is_none()); + + let models_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(models_response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn admin_client_keys_status_should_disable_and_enable_authorization() { + let (app, _dir) = admin_client_key_test_app("admin-client-key-status.sqlite").await; + let (key_id, plaintext) = create_admin_client_key(&app, "status-key").await; + + let disabled = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/admin/api-keys/{key_id}/status")) + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"status":"disabled"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let disabled_status = disabled.status(); + let disabled_body = response_json(disabled).await; + + assert_eq!(disabled_status, StatusCode::OK); + assert_eq!(disabled_body["data"]["enabled"], false); + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + + let enabled = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/admin/api-keys/{key_id}/status")) + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"status":"active"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let enabled_status = enabled.status(); + let enabled_body = response_json(enabled).await; + + assert_eq!(enabled_status, StatusCode::OK); + assert_eq!(enabled_body["data"]["enabled"], true); + + let accepted = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::OK); + + let invalid = app + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/admin/api-keys/{key_id}/status")) + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"status":"expired"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_client_keys_delete_should_remove_authorization() { + let (app, _dir) = admin_client_key_test_app("admin-client-key-delete.sqlite").await; + let (key_id, plaintext) = create_admin_client_key(&app, "delete-key").await; + + let deleted = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/admin/api-keys/{key_id}")) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let delete_status = deleted.status(); + let delete_body = response_json(deleted).await; + + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(delete_body["data"]["deleted"], true); + + let list_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/api-keys?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_body = response_json(list_response).await; + assert_eq!(list_body["data"].as_array().unwrap().len(), 0); + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + + let missing = app + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/admin/api-keys/{key_id}")) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/server/tests/admin_client_keys_routes/import_export.rs b/crates/server/tests/admin_client_keys_routes/import_export.rs new file mode 100644 index 000000000..3e7453671 --- /dev/null +++ b/crates/server/tests/admin_client_keys_routes/import_export.rs @@ -0,0 +1,159 @@ +use super::*; + +#[tokio::test] +async fn admin_client_keys_export_should_return_metadata_without_secret_material() { + let (app, _dir) = admin_client_key_test_app("admin-client-key-export.sqlite").await; + let (key_id, plaintext) = create_admin_client_key(&app, "export-key").await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/admin/api-keys/export?ids={key_id}")) + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_api_key_export") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["requestId"], "req_api_key_export"); + assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys"); + assert_eq!(body["data"]["rotationRequired"], true); + assert_eq!(body["data"]["apiKeys"][0]["id"], key_id); + assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key"); + assert!(body["data"]["apiKeys"][0]["prefix"] + .as_str() + .is_some_and(|prefix| prefix.starts_with("cpr_"))); + assert!(body["data"]["apiKeys"][0].get("plaintext").is_none()); + assert!(body["data"]["apiKeys"][0].get("keyHash").is_none()); + assert!(body["data"].get("pepper").is_none()); + assert!(!body.to_string().contains(&plaintext)); +} + +#[tokio::test] +async fn admin_client_keys_import_should_rotate_exported_metadata_and_return_plaintext_once() { + let source_dir = tempfile::tempdir().unwrap(); + let source_db = source_dir + .path() + .join("admin-client-key-export-source.sqlite"); + let source_url = format!("sqlite://{}", source_db.display()); + let source_pool = connect_sqlite(&source_url).await.unwrap(); + seed_admin_session(&source_pool, "session_1").await; + let source_state = AppState::with_pool_secret_and_api_key_hasher( + test_config(source_url), + source_pool, + SecretBox::new([51u8; 32]), + ApiKeyHasher::new([52u8; 32]), + ); + let source_app = router::router().with_state(source_state); + let (source_key_id, source_plaintext) = + create_admin_client_key(&source_app, "rotated-key").await; + + let export_response = source_app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/api-keys/export") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(export_response.status(), StatusCode::OK); + let export_body = response_json(export_response).await; + + let target_dir = tempfile::tempdir().unwrap(); + let target_db = target_dir + .path() + .join("admin-client-key-import-target.sqlite"); + let target_url = format!("sqlite://{}", target_db.display()); + let target_pool = connect_sqlite(&target_url).await.unwrap(); + seed_admin_session(&target_pool, "session_1").await; + let target_state = AppState::with_pool_secret_and_api_key_hasher( + test_config(target_url), + target_pool, + SecretBox::new([61u8; 32]), + ApiKeyHasher::new([62u8; 32]), + ); + let target_app = router::router().with_state(target_state); + + let import_response = target_app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/api-keys/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_api_key_import") + .body(Body::from(export_body["data"].to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = import_response.status(); + let import_body = response_json(import_response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(import_body["requestId"], "req_api_key_import"); + assert_eq!(import_body["data"]["imported"], 1); + assert_eq!(import_body["data"]["skipped"], 0); + assert_eq!(import_body["data"]["rotated"], true); + assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id); + assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key"); + assert!(import_body["data"]["keys"][0].get("keyHash").is_none()); + let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"] + .as_str() + .unwrap() + .to_string(); + assert!(rotated_plaintext.starts_with("cpr_")); + assert_ne!(rotated_plaintext, source_plaintext); + + let list_response = target_app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/api-keys?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let list_body = response_json(list_response).await; + assert!(list_body["data"][0].get("plaintext").is_none()); + + let old_rejected = target_app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {source_plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED); + + let new_accepted = target_app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {rotated_plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(new_accepted.status(), StatusCode::OK); +} diff --git a/crates/server/tests/admin_client_keys_routes/lifecycle.rs b/crates/server/tests/admin_client_keys_routes/lifecycle.rs new file mode 100644 index 000000000..68a3e377a --- /dev/null +++ b/crates/server/tests/admin_client_keys_routes/lifecycle.rs @@ -0,0 +1,138 @@ +use super::*; + +#[tokio::test] +async fn admin_client_keys_label_should_update_clear_and_validate_label() { + let (app, _dir) = admin_client_key_test_app("admin-client-key-label.sqlite").await; + let (key_id, _plaintext) = create_admin_client_key(&app, "label-key").await; + + let renamed = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/admin/api-keys/{key_id}/label")) + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"label":"automation"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let renamed_status = renamed.status(); + let renamed_body = response_json(renamed).await; + + assert_eq!(renamed_status, StatusCode::OK); + assert_eq!(renamed_body["data"]["name"], "label-key"); + assert_eq!(renamed_body["data"]["label"], "automation"); + + let cleared = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/admin/api-keys/{key_id}/label")) + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"label":null}"#)) + .unwrap(), + ) + .await + .unwrap(); + let cleared_status = cleared.status(); + let cleared_body = response_json(cleared).await; + + assert_eq!(cleared_status, StatusCode::OK); + assert_eq!(cleared_body["data"]["name"], "label-key"); + assert!(cleared_body["data"]["label"].is_null()); + + let too_long = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/admin/api-keys/{key_id}/label")) + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(json!({ "label": "x".repeat(65) }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(too_long.status(), StatusCode::BAD_REQUEST); + + let missing = app + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/admin/api-keys/missing/label") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"label":"automation"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn admin_client_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() { + let (app, _dir) = admin_client_key_test_app("admin-client-key-batch-delete.sqlite").await; + let (key_a, plaintext_a) = create_admin_client_key(&app, "batch-a").await; + let (key_b, plaintext_b) = create_admin_client_key(&app, "batch-b").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/api-keys/batch-delete") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from( + json!({ + "ids": [key_a, "ghost", key_b] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["deleted"], 2); + assert_eq!(body["data"]["notFound"], json!(["ghost"])); + + for plaintext in [plaintext_a, plaintext_b] { + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + } + + let empty = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/api-keys/batch-delete") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"ids":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(empty.status(), StatusCode::BAD_REQUEST); +} diff --git a/crates/server/tests/fixtures/chat/success.sse b/crates/server/tests/fixtures/chat/success.sse new file mode 100644 index 000000000..85d082845 --- /dev/null +++ b/crates/server/tests/fixtures/chat/success.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"hello"} + +event: response.completed +data: {"response":{"id":"resp_1","output_text":"hello","usage":{"input_tokens":9,"output_tokens":3}}} diff --git a/tests/fixtures/responses/golden/reasoning_replay_request.json b/crates/server/tests/fixtures/responses/golden/reasoning_replay_request.json similarity index 100% rename from tests/fixtures/responses/golden/reasoning_replay_request.json rename to crates/server/tests/fixtures/responses/golden/reasoning_replay_request.json diff --git a/crates/server/tests/fixtures/responses/http_sse/after_401.sse b/crates/server/tests/fixtures/responses/http_sse/after_401.sse new file mode 100644 index 000000000..201c7d963 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/after_401.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"auth fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_after_401","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":1}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/after_402.sse b/crates/server/tests/fixtures/responses/http_sse/after_402.sse new file mode 100644 index 000000000..448132d28 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/after_402.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"quota fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/after_403.sse b/crates/server/tests/fixtures/responses/http_sse/after_403.sse new file mode 100644 index 000000000..fa20326d9 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/after_403.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"banned fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":1}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/after_5xx_retry.sse b/crates/server/tests/fixtures/responses/http_sse/after_5xx_retry.sse new file mode 100644 index 000000000..3d296dd55 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/after_5xx_retry.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"retry hello"} + +event: response.completed +data: {"response":{"id":"resp_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/after_cloudflare.sse b/crates/server/tests/fixtures/responses/http_sse/after_cloudflare.sse new file mode 100644 index 000000000..aaee516ce --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/after_cloudflare.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"cloudflare fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/after_model_unsupported.sse b/crates/server/tests/fixtures/responses/http_sse/after_model_unsupported.sse new file mode 100644 index 000000000..fe9d85435 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/after_model_unsupported.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"model fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/completed_image_usage.sse b/crates/server/tests/fixtures/responses/http_sse/completed_image_usage.sse new file mode 100644 index 000000000..c551f8b34 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/completed_image_usage.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"image done"} + +event: response.completed +data: {"response":{"id":"resp_image","object":"response","status":"completed","output_text":"image done","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}},"tool_usage":{"image_gen":{"input_tokens":31,"output_tokens":9}}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/completed_usage.sse b/crates/server/tests/fixtures/responses/http_sse/completed_usage.sse new file mode 100644 index 000000000..6c595fdd2 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/completed_usage.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"usage hello"} + +event: response.completed +data: {"response":{"id":"resp_usage","object":"response","status":"completed","output_text":"usage hello","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/done_item_completed.sse b/crates/server/tests/fixtures/responses/http_sse/done_item_completed.sse new file mode 100644 index 000000000..f9bc50a61 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/done_item_completed.sse @@ -0,0 +1,5 @@ +event: response.output_item.done +data: {"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"from done item"}]}} + +event: response.completed +data: {"response":{"id":"resp_item","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}} diff --git a/tests/fixtures/responses/http_sse/empty_completed.sse b/crates/server/tests/fixtures/responses/http_sse/empty_completed.sse similarity index 55% rename from tests/fixtures/responses/http_sse/empty_completed.sse rename to crates/server/tests/fixtures/responses/http_sse/empty_completed.sse index 656e3eefa..e80392ca2 100644 --- a/tests/fixtures/responses/http_sse/empty_completed.sse +++ b/crates/server/tests/fixtures/responses/http_sse/empty_completed.sse @@ -1,2 +1,2 @@ event: response.completed -data: {"response":{"id":"resp_empty","object":"response","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":0}}} +data: {"response":{"id":"resp_empty","object":"response","status":"completed"}} diff --git a/crates/server/tests/fixtures/responses/http_sse/failed_auth.sse b/crates/server/tests/fixtures/responses/http_sse/failed_auth.sse new file mode 100644 index 000000000..d2dfa0463 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/failed_auth.sse @@ -0,0 +1,2 @@ +event: response.failed +data: {"response":{"id":"resp_sse_auth_failed","object":"response","status":"failed","error":{"code":"token_invalid","message":"token revoked"}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/failed_model_unsupported.sse b/crates/server/tests/fixtures/responses/http_sse/failed_model_unsupported.sse new file mode 100644 index 000000000..d2daa5daf --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/failed_model_unsupported.sse @@ -0,0 +1,2 @@ +event: response.failed +data: {"response":{"id":"resp_model_unsupported_failed","object":"response","status":"failed","error":{"code":"model_not_supported","message":"Model gpt-5.5 is not supported on this account plan"}}} diff --git a/tests/fixtures/responses/http_sse/response_failed_quota.sse b/crates/server/tests/fixtures/responses/http_sse/failed_quota.sse similarity index 100% rename from tests/fixtures/responses/http_sse/response_failed_quota.sse rename to crates/server/tests/fixtures/responses/http_sse/failed_quota.sse diff --git a/crates/server/tests/fixtures/responses/http_sse/stream_after_401.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_401.sse new file mode 100644 index 000000000..d56d141d7 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_401.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"auth stream fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_stream_after_401","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/stream_after_402.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_402.sse new file mode 100644 index 000000000..3472203a3 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_402.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"quota stream fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_stream_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":3}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/stream_after_403.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_403.sse new file mode 100644 index 000000000..fbca9a1ee --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_403.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"banned stream fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_stream_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} diff --git a/tests/fixtures/responses/http_sse/stream_after_429.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_429.sse similarity index 53% rename from tests/fixtures/responses/http_sse/stream_after_429.sse rename to crates/server/tests/fixtures/responses/http_sse/stream_after_429.sse index 5f0ba21ba..164add60f 100644 --- a/tests/fixtures/responses/http_sse/stream_after_429.sse +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_429.sse @@ -1,6 +1,5 @@ event: response.output_text.delta -data: {"delta":"ok"} +data: {"delta":"rate fallback hello"} event: response.completed -data: {"response":{"id":"resp_stream_after_429","object":"response","usage":{"input_tokens":4,"output_tokens":1}}} - +data: {"response":{"id":"resp_stream_after_429","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":1}}} diff --git a/tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse similarity index 52% rename from tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse rename to crates/server/tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse index 7a3ebf772..2ea4ecfbe 100644 --- a/tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse @@ -1,5 +1,5 @@ event: response.output_text.delta -data: {"delta":"ok"} +data: {"delta":"retry stream hello"} event: response.completed -data: {"response":{"id":"resp_stream_after_5xx_retry","object":"response","usage":{"input_tokens":7,"output_tokens":2}}} +data: {"response":{"id":"resp_stream_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":7,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/stream_after_cloudflare.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_cloudflare.sse new file mode 100644 index 000000000..632335133 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_cloudflare.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"cloudflare stream fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_stream_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/stream_after_model_unsupported.sse b/crates/server/tests/fixtures/responses/http_sse/stream_after_model_unsupported.sse new file mode 100644 index 000000000..1b33fcf47 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/stream_after_model_unsupported.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"model stream fallback hello"} + +event: response.completed +data: {"response":{"id":"resp_stream_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/stream_usage.sse b/crates/server/tests/fixtures/responses/http_sse/stream_usage.sse new file mode 100644 index 000000000..ddcbce193 --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/stream_usage.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"stream hello"} + +event: response.completed +data: {"response":{"id":"resp_stream_usage","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":5}}} diff --git a/crates/server/tests/fixtures/responses/http_sse/success.sse b/crates/server/tests/fixtures/responses/http_sse/success.sse new file mode 100644 index 000000000..273f5ec4c --- /dev/null +++ b/crates/server/tests/fixtures/responses/http_sse/success.sse @@ -0,0 +1,5 @@ +event: response.output_text.delta +data: {"delta":"response hello"} + +event: response.completed +data: {"response":{"id":"resp_response_1","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} diff --git a/tests/fixtures/responses/http_sse/text_deltas_completed.sse b/crates/server/tests/fixtures/responses/http_sse/text_deltas_completed.sse similarity index 52% rename from tests/fixtures/responses/http_sse/text_deltas_completed.sse rename to crates/server/tests/fixtures/responses/http_sse/text_deltas_completed.sse index 98ca02dc5..e5b61890c 100644 --- a/tests/fixtures/responses/http_sse/text_deltas_completed.sse +++ b/crates/server/tests/fixtures/responses/http_sse/text_deltas_completed.sse @@ -1,9 +1,8 @@ event: response.output_text.delta -data: {"delta":"你好,我是"} +data: {"delta":"hello "} event: response.output_text.delta -data: {"delta":"一个中文助手。"} +data: {"delta":"from deltas"} event: response.completed -data: {"response":{"id":"resp_text","object":"response","status":"completed","output":[],"usage":{"input_tokens":6,"output_tokens":4}}} - +data: {"response":{"id":"resp_text","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}} diff --git a/tests/fixtures/responses/http_sse/completed_tuple_object.sse b/crates/server/tests/fixtures/responses/http_sse/tuple_object.sse similarity index 100% rename from tests/fixtures/responses/http_sse/completed_tuple_object.sse rename to crates/server/tests/fixtures/responses/http_sse/tuple_object.sse diff --git a/tests/fixtures/responses/websocket/completed_with_reasoning_replay.json b/crates/server/tests/fixtures/responses/websocket/completed_with_reasoning_replay.json similarity index 100% rename from tests/fixtures/responses/websocket/completed_with_reasoning_replay.json rename to crates/server/tests/fixtures/responses/websocket/completed_with_reasoning_replay.json diff --git a/tests/fixtures/responses/websocket/first_account_limited.json b/crates/server/tests/fixtures/responses/websocket/first_account_limited.json similarity index 100% rename from tests/fixtures/responses/websocket/first_account_limited.json rename to crates/server/tests/fixtures/responses/websocket/first_account_limited.json diff --git a/tests/fixtures/responses/websocket/history_rate_limited.json b/crates/server/tests/fixtures/responses/websocket/history_rate_limited.json similarity index 100% rename from tests/fixtures/responses/websocket/history_rate_limited.json rename to crates/server/tests/fixtures/responses/websocket/history_rate_limited.json diff --git a/tests/fixtures/responses/websocket/invalid_encrypted_content.json b/crates/server/tests/fixtures/responses/websocket/invalid_encrypted_content.json similarity index 100% rename from tests/fixtures/responses/websocket/invalid_encrypted_content.json rename to crates/server/tests/fixtures/responses/websocket/invalid_encrypted_content.json diff --git a/tests/fixtures/responses/websocket/previous_response_not_found.json b/crates/server/tests/fixtures/responses/websocket/previous_response_not_found.json similarity index 100% rename from tests/fixtures/responses/websocket/previous_response_not_found.json rename to crates/server/tests/fixtures/responses/websocket/previous_response_not_found.json diff --git a/tests/fixtures/responses/websocket/rate_limited.json b/crates/server/tests/fixtures/responses/websocket/rate_limited.json similarity index 100% rename from tests/fixtures/responses/websocket/rate_limited.json rename to crates/server/tests/fixtures/responses/websocket/rate_limited.json diff --git a/tests/fixtures/responses/websocket/second_account_limited.json b/crates/server/tests/fixtures/responses/websocket/second_account_limited.json similarity index 100% rename from tests/fixtures/responses/websocket/second_account_limited.json rename to crates/server/tests/fixtures/responses/websocket/second_account_limited.json diff --git a/tests/fixtures/responses/websocket/token_revoked.json b/crates/server/tests/fixtures/responses/websocket/token_revoked.json similarity index 100% rename from tests/fixtures/responses/websocket/token_revoked.json rename to crates/server/tests/fixtures/responses/websocket/token_revoked.json diff --git a/tests/fixtures/responses/websocket/unanswered_function_call.json b/crates/server/tests/fixtures/responses/websocket/unanswered_function_call.json similarity index 100% rename from tests/fixtures/responses/websocket/unanswered_function_call.json rename to crates/server/tests/fixtures/responses/websocket/unanswered_function_call.json diff --git a/crates/server/tests/openai_chat_upstream.rs b/crates/server/tests/openai_chat_upstream.rs index 05e6acb8b..6d7ffe275 100644 --- a/crates/server/tests/openai_chat_upstream.rs +++ b/crates/server/tests/openai_chat_upstream.rs @@ -58,7183 +58,136 @@ use wiremock::{ const TEST_INSTALLATION_ID: &str = "b4f9d503-07b1-457b-a0da-87e6836b1c43"; -const CHAT_SUCCESS_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"hello"} - -event: response.completed -data: {"response":{"id":"resp_1","output_text":"hello","usage":{"input_tokens":9,"output_tokens":3}}} - -"#; - -const RESPONSES_SUCCESS_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"response hello"} - -event: response.completed -data: {"response":{"id":"resp_response_1","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} - -"#; - -const RESPONSES_COMPLETED_USAGE_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"usage hello"} - -event: response.completed -data: {"response":{"id":"resp_usage","object":"response","status":"completed","output_text":"usage hello","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}} - -"#; - -const RESPONSES_COMPLETED_IMAGE_USAGE_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"image done"} - -event: response.completed -data: {"response":{"id":"resp_image","object":"response","status":"completed","output_text":"image done","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}},"tool_usage":{"image_gen":{"input_tokens":31,"output_tokens":9}}}} - -"#; - -const RESPONSES_EMPTY_COMPLETED_SSE: &str = r#"event: response.completed -data: {"response":{"id":"resp_empty","object":"response","status":"completed"}} - -"#; - -const RESPONSES_TEXT_DELTAS_COMPLETED_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"hello "} - -event: response.output_text.delta -data: {"delta":"from deltas"} - -event: response.completed -data: {"response":{"id":"resp_text","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}} - -"#; - -const RESPONSES_DONE_ITEM_COMPLETED_SSE: &str = r#"event: response.output_item.done -data: {"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"from done item"}]}} - -event: response.completed -data: {"response":{"id":"resp_item","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}} - -"#; - -const RESPONSES_TUPLE_OBJECT_SSE: &str = r#"event: response.output_text.delta -data: {"type":"response.output_text.delta","delta":"{\"point\":{\"0\":1,\"1\":2}}"} - -event: response.completed -data: {"response":{"id":"resp_tuple","object":"response","status":"completed","output_text":"{\"point\":{\"0\":0,\"1\":0}}","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\"point\":{\"0\":1,\"1\":2}}"}]}],"usage":{"input_tokens":1,"output_tokens":1}}} - -"#; - -const RESPONSES_AFTER_5XX_RETRY_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"retry hello"} - -event: response.completed -data: {"response":{"id":"resp_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}} - -"#; - -const RESPONSES_AFTER_402_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"quota fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}} - -"#; - -const RESPONSES_FAILED_QUOTA_SSE: &str = r#"event: response.failed -data: {"response":{"id":"resp_sse_quota_failed","object":"response","status":"failed","error":{"code":"quota_exceeded","message":"quota exhausted"}}} - -"#; - -const RESPONSES_FAILED_AUTH_SSE: &str = r#"event: response.failed -data: {"response":{"id":"resp_sse_auth_failed","object":"response","status":"failed","error":{"code":"token_invalid","message":"token revoked"}}} - -"#; - -const RESPONSES_AFTER_401_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"auth fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_after_401","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":1}}} - -"#; - -const RESPONSES_AFTER_403_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"banned fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":1}}} - -"#; - -const RESPONSES_STREAM_USAGE_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"stream hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_usage","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":5}}} - -"#; - -const RESPONSES_STREAM_AFTER_429_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"rate fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_429","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":1}}} - -"#; - -const RESPONSES_STREAM_AFTER_5XX_RETRY_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"retry stream hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":7,"output_tokens":2}}} - -"#; - -const RESPONSES_STREAM_AFTER_402_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"quota stream fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":3}}} - -"#; - -const RESPONSES_STREAM_AFTER_401_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"auth stream fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_401","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}} - -"#; - -const RESPONSES_STREAM_AFTER_403_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"banned stream fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} - -"#; - -const RESPONSES_AFTER_CLOUDFLARE_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"cloudflare fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} - -"#; - -const RESPONSES_STREAM_AFTER_CLOUDFLARE_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"cloudflare stream fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}} - -"#; - -const RESPONSES_AFTER_MODEL_UNSUPPORTED_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"model fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}} - -"#; - -const RESPONSES_STREAM_AFTER_MODEL_UNSUPPORTED_SSE: &str = r#"event: response.output_text.delta -data: {"delta":"model stream fallback hello"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} - -"#; - -const RESPONSES_FAILED_MODEL_UNSUPPORTED_SSE: &str = r#"event: response.failed -data: {"response":{"id":"resp_model_unsupported_failed","object":"response","status":"failed","error":{"code":"model_not_supported","message":"Model gpt-5.5 is not supported on this account plan"}}} - -"#; - -const WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY: &str = include_str!( - "../../../tests/fixtures/responses/websocket/completed_with_reasoning_replay.json" +const CHAT_SUCCESS_SSE: &str = concat!(include_str!("fixtures/chat/success.sse"), "\n"); +const RESPONSES_SUCCESS_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/success.sse"), + "\n" ); +const RESPONSES_COMPLETED_USAGE_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/completed_usage.sse"), + "\n" +); +const RESPONSES_COMPLETED_IMAGE_USAGE_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/completed_image_usage.sse"), + "\n" +); +const RESPONSES_EMPTY_COMPLETED_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/empty_completed.sse"), + "\n" +); +const RESPONSES_TEXT_DELTAS_COMPLETED_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/text_deltas_completed.sse"), + "\n" +); +const RESPONSES_DONE_ITEM_COMPLETED_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/done_item_completed.sse"), + "\n" +); +const RESPONSES_TUPLE_OBJECT_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/tuple_object.sse"), + "\n" +); +const RESPONSES_AFTER_5XX_RETRY_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/after_5xx_retry.sse"), + "\n" +); +const RESPONSES_AFTER_402_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/after_402.sse"), + "\n" +); +const RESPONSES_FAILED_QUOTA_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/failed_quota.sse"), + "\n" +); +const RESPONSES_FAILED_AUTH_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/failed_auth.sse"), + "\n" +); +const RESPONSES_AFTER_401_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/after_401.sse"), + "\n" +); +const RESPONSES_AFTER_403_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/after_403.sse"), + "\n" +); +const RESPONSES_STREAM_USAGE_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_usage.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_429_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_429.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_5XX_RETRY_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_5xx_retry.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_402_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_402.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_401_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_401.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_403_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_403.sse"), + "\n" +); +const RESPONSES_AFTER_CLOUDFLARE_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/after_cloudflare.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_CLOUDFLARE_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_cloudflare.sse"), + "\n" +); +const RESPONSES_AFTER_MODEL_UNSUPPORTED_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/after_model_unsupported.sse"), + "\n" +); +const RESPONSES_STREAM_AFTER_MODEL_UNSUPPORTED_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/stream_after_model_unsupported.sse"), + "\n" +); +const RESPONSES_FAILED_MODEL_UNSUPPORTED_SSE: &str = concat!( + include_str!("fixtures/responses/http_sse/failed_model_unsupported.sse"), + "\n" +); + +const WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY: &str = + include_str!("fixtures/responses/websocket/completed_with_reasoning_replay.json"); const WEBSOCKET_HISTORY_RATE_LIMITED: &str = - include_str!("../../../tests/fixtures/responses/websocket/history_rate_limited.json"); -const WEBSOCKET_RATE_LIMITED: &str = - include_str!("../../../tests/fixtures/responses/websocket/rate_limited.json"); + include_str!("fixtures/responses/websocket/history_rate_limited.json"); +const WEBSOCKET_RATE_LIMITED: &str = include_str!("fixtures/responses/websocket/rate_limited.json"); const WEBSOCKET_TOKEN_REVOKED: &str = - include_str!("../../../tests/fixtures/responses/websocket/token_revoked.json"); + include_str!("fixtures/responses/websocket/token_revoked.json"); const WEBSOCKET_FIRST_ACCOUNT_LIMITED: &str = - include_str!("../../../tests/fixtures/responses/websocket/first_account_limited.json"); + include_str!("fixtures/responses/websocket/first_account_limited.json"); const WEBSOCKET_SECOND_ACCOUNT_LIMITED: &str = - include_str!("../../../tests/fixtures/responses/websocket/second_account_limited.json"); + include_str!("fixtures/responses/websocket/second_account_limited.json"); const WEBSOCKET_INVALID_ENCRYPTED_CONTENT: &str = - include_str!("../../../tests/fixtures/responses/websocket/invalid_encrypted_content.json"); + include_str!("fixtures/responses/websocket/invalid_encrypted_content.json"); const WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND: &str = - include_str!("../../../tests/fixtures/responses/websocket/previous_response_not_found.json"); + include_str!("fixtures/responses/websocket/previous_response_not_found.json"); const WEBSOCKET_UNANSWERED_FUNCTION_CALL: &str = - include_str!("../../../tests/fixtures/responses/websocket/unanswered_function_call.json"); + include_str!("fixtures/responses/websocket/unanswered_function_call.json"); const REASONING_REPLAY_REQUEST_GOLDEN: &str = - include_str!("../../../tests/fixtures/responses/golden/reasoning_replay_request.json"); - -#[tokio::test] -async fn chat_completions_should_dispatch_to_codex_and_return_openai_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .and(header("chatgpt-account-id", "chatgpt-account")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [ - {"role": "system", "content": "You are concise."}, - {"role": "user", "content": "Say hello"} - ] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["object"], "chat.completion"); - assert_eq!(body["model"], "gpt-5.5"); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - assert_eq!(body["usage"]["prompt_tokens"], 9); - assert_eq!(body["usage"]["completion_tokens"], 3); - let usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(usage, (1, 9, 3)); -} - -#[tokio::test] -async fn chat_completions_should_forward_runtime_installation_id_to_codex() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("x-codex-installation-id", TEST_INSTALLATION_ID)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, _dir) = - test_app_with_account_and_installation_id(server.uri(), TEST_INSTALLATION_ID.to_string()) - .await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [ - {"role": "user", "content": "Say hello"} - ] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn chat_completions_should_dispatch_from_restored_runtime_account_pool() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .and(header("chatgpt-account-id", "chatgpt-account")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, _dir) = - test_app_with_restored_pool_then_disabled_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [ - {"role": "user", "content": "Say hello"} - ] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn chat_completions_should_fallback_to_next_account_after_rate_limit() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(429) - .insert_header("retry-after", "120") - .set_body_json(json!({ - "error": { - "message": "rate limited", - "resets_in_seconds": 120 - } - })), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let authorizations = received_authorizations(&server).await; - - assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}"); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - let persisted_quota_state: (i64, Option) = sqlx::query_as( - "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", - ) - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(persisted_quota_state.0, 1); - let cooldown_until = - chrono::DateTime::parse_from_rfc3339(persisted_quota_state.1.as_deref().unwrap()) - .unwrap() - .with_timezone(&Utc); - assert!(cooldown_until > Utc::now()); -} - -#[tokio::test] -async fn chat_completions_should_return_rate_limit_error_when_429_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "error": {"message": "rate limited"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let quota_state: (i64, Option) = sqlx::query_as( - "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - let message = body["error"]["message"].as_str().unwrap_or_default(); - - assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); - assert!(message.contains("All accounts exhausted (1 rate-limited)")); - assert!(message.contains("rate limited")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(quota_state.0, 1); - assert!(quota_state.1.is_some()); -} - -#[tokio::test] -async fn chat_completions_should_fallback_after_http_model_unsupported() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": { - "code": "model_not_supported", - "message": "Model gpt-5.5 is not supported on this account plan" - } - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - assert_eq!(primary_status.0, "active"); -} - -#[tokio::test] -async fn chat_completions_should_return_model_unsupported_error_when_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": { - "code": "model_not_supported", - "message": "Model gpt-5.5 is not supported on this account plan" - } - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let message = body["error"]["message"].as_str().unwrap_or_default(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert!(message.contains("All accounts exhausted (1 model-unsupported)")); - assert!(message.contains("model_not_supported")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(account_status.0, "active"); -} - -#[tokio::test] -async fn chat_completions_should_mark_expired_after_401_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"code": "token_revoked", "message": "token revoked"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - assert_eq!(account_status.0, "expired"); -} - -#[tokio::test] -async fn chat_completions_should_return_auth_error_when_401_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"code": "token_revoked", "message": "token revoked"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let message = body["error"]["message"].as_str().unwrap_or_default(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert!(message.contains("All accounts exhausted (1 expired)")); - assert!(message.contains("token_revoked")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(account_status.0, "expired"); -} - -#[tokio::test] -async fn chat_completions_should_mark_banned_when_401_says_account_deactivated() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"message": "account deactivated"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert_eq!(account_status.0, "banned"); -} - -#[tokio::test] -async fn chat_completions_should_mark_banned_after_403_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({ - "error": {"message": "account banned"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - assert_eq!(account_status.0, "banned"); -} - -#[tokio::test] -async fn chat_completions_should_cool_down_cloudflare_403_and_fallback() { - let started_at = Utc::now(); - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(403).set_body_string( - "Just a moment...cf_chl challenge", - )) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_primary", "cf_clearance=old") - .await - .unwrap(); - cookie_store - .set_cookie_header("acct_secondary", "cf_clearance=keep") - .await - .unwrap(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let primary_state: (String, Option) = - sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap()) - .unwrap() - .with_timezone(&Utc); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - assert_eq!(primary_state.0, "active"); - assert!(cooldown_until > started_at); - assert_eq!( - cookie_store - .cookie_header("acct_primary", "chatgpt.com") - .await - .unwrap(), - None - ); - assert_eq!( - cookie_store - .cookie_header("acct_secondary", "chatgpt.com") - .await - .unwrap() - .as_deref(), - Some("cf_clearance=keep") - ); -} - -#[tokio::test] -async fn chat_completions_should_mark_quota_exhausted_after_402_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(402).set_body_json(json!({ - "error": {"message": "quota reached"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(CHAT_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["choices"][0]["message"]["content"], "hello"); - assert_eq!(account_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn chat_completions_should_return_quota_error_when_402_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(402).set_body_json(json!({ - "error": {"message": "quota reached"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - let message = body["error"]["message"].as_str().unwrap_or_default(); - - assert_eq!(status, StatusCode::PAYMENT_REQUIRED); - assert!(message.contains("All accounts exhausted (1 quota-exhausted)")); - assert!(message.contains("quota reached")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(account_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn responses_should_dispatch_to_codex_and_return_completed_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .and(header("chatgpt-account-id", "chatgpt-account")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_SUCCESS_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_response_1"); - assert_eq!(body["output_text"], "response hello"); - assert_eq!(body["usage"]["input_tokens"], 5); - assert_eq!(body["usage"]["output_tokens"], 2); -} - -#[tokio::test] -async fn responses_should_reject_invalid_json_without_upstream_request() { - let server = MockServer::start().await; - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from("{")) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let requests = server.received_requests().await.unwrap(); - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["error"]["code"], "invalid_request"); - assert!(requests.is_empty()); -} - -#[tokio::test] -async fn responses_should_reject_non_object_json_without_upstream_request() { - let server = MockServer::start().await; - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from("[]")) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let requests = server.received_requests().await.unwrap(); - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["error"]["code"], "invalid_request"); - assert!(requests.is_empty()); -} - -#[tokio::test] -async fn responses_should_return_no_available_accounts_error_when_no_accounts_are_available() { - let server = MockServer::start().await; - let (app, api_key, _dir) = test_app_without_accounts(server.uri()).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(body["type"], "error"); - assert_eq!(body["error"]["type"], "server_error"); - assert_eq!(body["error"]["code"], "no_available_accounts"); -} - -#[tokio::test] -async fn responses_should_honor_explicit_http_sse_transport() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_text(response).await; - let requests = server.received_requests().await.unwrap(); - let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(body.contains("event: response.output_text.delta")); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].method.as_str(), "POST"); - assert_eq!(upstream_body["model"], "gpt-5.5"); - assert!(upstream_body.get("use_websocket").is_none()); -} - -#[tokio::test] -async fn responses_should_stagger_same_account_requests_before_sending_upstream() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let request_times = Arc::new(Mutex::new(Vec::new())); - let request_times_for_server = Arc::clone(&request_times); - let (first_seen_tx, first_seen_rx) = oneshot::channel(); - let (release_first_tx, release_first_rx) = oneshot::channel(); - let upstream = tokio::spawn(async move { - let (mut first_socket, _) = listener.accept().await.unwrap(); - request_times_for_server - .lock() - .unwrap() - .push(Instant::now()); - first_seen_tx.send(()).unwrap(); - read_http_request(&mut first_socket).await; - - let (mut second_socket, _) = listener.accept().await.unwrap(); - request_times_for_server - .lock() - .unwrap() - .push(Instant::now()); - read_http_request(&mut second_socket).await; - write_http_sse_response(&mut second_socket, RESPONSES_COMPLETED_USAGE_SSE).await; - - release_first_rx.await.unwrap(); - write_http_sse_response(&mut first_socket, RESPONSES_COMPLETED_USAGE_SSE).await; - }); - - let (app, api_key, _pool, _dir) = test_app_with_account_pool_config(base_url, |config| { - config.auth.max_concurrent_per_account = 2; - config.auth.request_interval_ms = 300; - }) - .await; - let first_app = app.clone(); - let first_api_key = api_key.clone(); - let first_response = tokio::spawn(async move { - first_app - .oneshot(responses_http_sse_request( - &first_api_key, - "req_stagger_first", - )) - .await - .unwrap() - }); - first_seen_rx.await.unwrap(); - - let second = app - .clone() - .oneshot(responses_http_sse_request(&api_key, "req_stagger_second")) - .await - .unwrap(); - assert_eq!(second.status(), StatusCode::OK); - release_first_tx.send(()).unwrap(); - let first = first_response.await.unwrap(); - assert_eq!(first.status(), StatusCode::OK); - upstream.await.unwrap(); - - let times = request_times.lock().unwrap(); - assert_eq!(times.len(), 2); - let elapsed = times[1].duration_since(times[0]); - assert!( - elapsed >= StdDuration::from_millis(180), - "second upstream request was sent too early: {elapsed:?}" - ); -} - -#[tokio::test] -async fn responses_should_use_websocket_upstream_by_default_while_serving_sse() { - let (base_url, upstream) = spawn_single_websocket_completed_upstream("resp_ws_default").await; - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "generate": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.completed")); - assert!(body.contains("\"id\":\"resp_ws_default\"")); - assert_eq!(captured.payload["type"], "response.create"); - assert_eq!(captured.payload["model"], "gpt-5.5"); - assert_eq!(captured.payload["generate"], false); - assert!(captured.payload.get("previous_response_id").is_none()); - assert!(captured.payload["prompt_cache_key"] - .as_str() - .is_some_and(|value| value.starts_with("cp_"))); - assert!(captured.payload["client_metadata"]["x-codex-installation-id"].is_string()); - assert!(captured.payload["client_metadata"]["x-codex-window-id"] - .as_str() - .is_some_and(|value| value.starts_with("cp_") && value.ends_with(":0"))); -} - -#[tokio::test] -async fn responses_should_ignore_camel_case_use_websocket_field() { - let (base_url, upstream) = - spawn_single_websocket_completed_upstream("resp_ws_camel_case").await; - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "useWebSocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let captured = upstream.await.unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(captured.payload["type"], "response.create"); - assert!(captured.payload.get("useWebSocket").is_none()); - assert!(captured.payload.get("use_websocket").is_none()); -} - -#[tokio::test] -async fn responses_websocket_should_stream_first_frame_before_terminal_event() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let (first_frame_tx, first_frame_rx) = oneshot::channel(); - let (terminal_tx, terminal_rx) = oneshot::channel(); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let message = websocket.next().await.unwrap().unwrap(); - let payload = serde_json::from_str::(&message.into_text().unwrap()).unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.output_text.delta", - "delta": "first websocket frame" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - first_frame_tx.send(()).unwrap(); - terminal_rx.await.unwrap(); - websocket - .send(Message::Text( - response_completed_websocket_message("resp_ws_streaming").into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - payload - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - let response_task = tokio::spawn(async move { - app.oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap() - }); - - first_frame_rx.await.unwrap(); - let response = timeout(StdDuration::from_millis(250), response_task) - .await - .expect("websocket response should be returned after the first non-error frame") - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let first_chunk = timeout( - StdDuration::from_secs(1), - first_response_body_chunk(response), - ) - .await - .expect("downstream should receive a websocket-backed SSE chunk before completion") - .expect("response body should produce a chunk"); - terminal_tx.send(()).unwrap(); - let payload = upstream.await.unwrap(); - - assert!(first_chunk.contains("event: response.output_text.delta")); - assert_eq!(payload["type"], "response.create"); -} - -#[tokio::test] -async fn responses_websocket_stream_should_synthesize_response_failed_when_closed_before_terminal() -{ - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let message = websocket.next().await.unwrap().unwrap(); - let payload = serde_json::from_str::(&message.into_text().unwrap()).unwrap(); - websocket - .send(Message::Text( - json!({ - "type": "response.output_text.delta", - "delta": "partial before websocket close" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - payload - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_text(response).await; - let payload = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(payload["type"], "response.create"); - assert!(body.contains("event: response.output_text.delta")); - assert!(body.contains("event: response.failed")); - assert!(body.contains("stream_disconnected")); -} - -#[tokio::test] -async fn responses_websocket_should_reuse_connection_for_recorded_conversation() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_message = websocket.next().await.unwrap().unwrap(); - let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) - .expect("first websocket payload should be json"); - websocket - .send(Message::Text( - response_completed_websocket_message("resp_pool_first").into(), - )) - .await - .unwrap(); - - loop { - tokio::select! { - message = websocket.next() => { - match message { - Some(Ok(message)) if message.is_text() => { - let second_payload = serde_json::from_str::( - &message.into_text().unwrap(), - ) - .expect("second websocket payload should be json"); - websocket - .send(Message::Text( - response_completed_websocket_message("resp_pool_second").into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - break (true, first_payload, second_payload); - } - Some(_) => continue, - None => { - let second_payload = accept_successful_websocket_response( - &listener, - "resp_pool_second", - ) - .await; - break (false, first_payload, second_payload); - } - } - } - accepted = listener.accept() => { - let (stream, _) = accepted.unwrap(); - let mut second_websocket = accept_async(stream).await.unwrap(); - let second_message = second_websocket.next().await.unwrap().unwrap(); - let second_payload = serde_json::from_str::( - &second_message.into_text().unwrap(), - ) - .expect("second websocket payload should be json"); - second_websocket - .send(Message::Text( - response_completed_websocket_message("resp_pool_second").into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - break (false, first_payload, second_payload); - } - } - } - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{ - "role": "user", - "content": "reuse this upstream websocket" - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_pool_first\"")); - - let second_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "previous_response_id": "resp_pool_first" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_pool_second\"")); - let (reused_connection, first_payload, second_payload) = upstream.await.unwrap(); - - assert!(reused_connection, "second request opened a new websocket"); - assert_eq!( - second_payload["prompt_cache_key"], first_payload["prompt_cache_key"], - "pooled websocket reuse should stay on the recorded conversation key" - ); - assert_eq!(second_payload["previous_response_id"], "resp_pool_first"); -} - -#[tokio::test] -async fn responses_websocket_should_not_reuse_connection_when_pool_is_disabled() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_message = websocket.next().await.unwrap().unwrap(); - let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) - .expect("first websocket payload should be json"); - websocket - .send(Message::Text( - response_completed_websocket_message("resp_disabled_pool_first").into(), - )) - .await - .unwrap(); - - loop { - tokio::select! { - message = websocket.next() => { - match message { - Some(Ok(message)) if message.is_text() => { - let second_payload = serde_json::from_str::( - &message.into_text().unwrap(), - ) - .expect("second websocket payload should be json"); - websocket - .send(Message::Text( - response_completed_websocket_message( - "resp_disabled_pool_second", - ) - .into(), - )) - .await - .unwrap(); - websocket.close(None).await.unwrap(); - break (true, first_payload, second_payload); - } - Some(_) => continue, - None => { - let second_payload = accept_successful_websocket_response( - &listener, - "resp_disabled_pool_second", - ) - .await; - break (false, first_payload, second_payload); - } - } - } - accepted = listener.accept() => { - let (stream, _) = accepted.unwrap(); - let mut second_websocket = accept_async(stream).await.unwrap(); - let second_message = second_websocket.next().await.unwrap().unwrap(); - let second_payload = serde_json::from_str::( - &second_message.into_text().unwrap(), - ) - .expect("second websocket payload should be json"); - second_websocket - .send(Message::Text( - response_completed_websocket_message("resp_disabled_pool_second") - .into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - break (false, first_payload, second_payload); - } - } - } - }); - let (app, api_key, _pool, _dir) = test_app_with_account_pool_config(base_url, |config| { - config.ws_pool.enabled = false; - }) - .await; - - let first_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{ - "role": "user", - "content": "do not reuse this upstream websocket" - }] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_disabled_pool_first\"")); - - let second_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "previous_response_id": "resp_disabled_pool_first" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_disabled_pool_second\"")); - let (reused_connection, first_payload, second_payload) = upstream.await.unwrap(); - - assert!( - !reused_connection, - "disabled pool reused the upstream websocket" - ); - assert_eq!( - second_payload["prompt_cache_key"], first_payload["prompt_cache_key"], - "disabling the pool must not change the recorded conversation key" - ); - assert_eq!( - second_payload["previous_response_id"], - "resp_disabled_pool_first" - ); -} - -#[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] -async fn responses_websocket_stream_should_record_metadata_turn_state_for_continuation() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let first_message = first_websocket.next().await.unwrap().unwrap(); - let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) - .expect("first websocket payload should be json"); - first_websocket - .send(Message::Text( - json!({ - "type": "response.metadata", - "headers": { - "x-codex-turn-state": "turn-from-metadata" - } - }) - .to_string() - .into(), - )) - .await - .unwrap(); - first_websocket - .send(Message::Text( - response_completed_websocket_message("resp_metadata_turn_state").into(), - )) - .await - .unwrap(); - first_websocket.close(None).await.unwrap(); - - let (second_stream, _) = listener.accept().await.unwrap(); - let second_headers = Arc::new(Mutex::new(Vec::new())); - let second_headers_for_callback = Arc::clone(&second_headers); - let mut second_websocket = accept_hdr_async( - second_stream, - move |request: &WsRequest, response: WsResponse| { - *second_headers_for_callback.lock().unwrap() = request_headers(request); - Ok(response) - }, - ) - .await - .unwrap(); - let second_message = second_websocket.next().await.unwrap().unwrap(); - let second_payload = serde_json::from_str::(&second_message.into_text().unwrap()) - .expect("second websocket payload should be json"); - second_websocket - .send(Message::Text( - response_completed_websocket_message("resp_metadata_turn_state_next").into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - let second_headers = second_headers.lock().unwrap().clone(); - (first_payload, second_payload, second_headers) - }); - let (app, api_key, _pool, _dir) = test_app_with_account_pool_config(base_url, |config| { - config.ws_pool.enabled = false; - }) - .await; - - let first_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - let first_body = response_text(first_response).await; - assert!(first_body.contains("\"id\":\"resp_metadata_turn_state\"")); - assert!(!first_body.contains("response.metadata")); - - let second_response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "previous_response_id": "resp_metadata_turn_state" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_metadata_turn_state_next\"")); - let (first_payload, second_payload, second_headers) = upstream.await.unwrap(); - - assert!(first_payload.get("previous_response_id").is_none()); - assert_eq!( - second_payload["previous_response_id"], - "resp_metadata_turn_state" - ); - assert_eq!( - captured_header(&second_headers, "x-codex-turn-state"), - Some("turn-from-metadata") - ); -} - -#[tokio::test] -async fn responses_websocket_should_implicitly_resume_full_history_with_reasoning_replay() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), - ) - .await; - let second_payload = accept_followup_websocket_response( - &listener, - &mut websocket, - websocket_completed_response("resp_implicit_resume_second", 4, 1), - ) - .await; - let _ = websocket.close(None).await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [{"role": "user", "content": "remember this"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [ - {"role": "user", "content": "remember this"}, - {"role": "assistant", "content": "cached answer"}, - {"role": "user", "content": "continue"} - ] - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - - let (first_payload, mut second_payload) = upstream.await.unwrap(); - assert!(first_payload["prompt_cache_key"].as_str().is_some()); - assert_eq!( - second_payload["previous_response_id"], - "resp_implicit_resume_first" - ); - assert_eq!( - second_payload["prompt_cache_key"], - first_payload["prompt_cache_key"] - ); - assert_eq!( - second_payload["input"][0]["encrypted_content"], - "enc_reasoning_replay" - ); - assert_eq!(second_payload["input"][1]["content"], "continue"); - assert_eq!(second_payload["input"].as_array().unwrap().len(), 2); - - second_payload - .as_object_mut() - .unwrap() - .remove("prompt_cache_key"); - second_payload - .as_object_mut() - .unwrap() - .remove("client_metadata"); - let expected: Value = serde_json::from_str(REASONING_REPLAY_REQUEST_GOLDEN).unwrap(); - assert_eq!(second_payload, expected); -} - -#[tokio::test] -async fn responses_websocket_should_not_implicitly_resume_unmatched_function_call_output() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - websocket_completed_function_call_response("resp_call_first", "call_expected"), - ) - .await; - let second_payload = accept_followup_websocket_response( - &listener, - &mut websocket, - websocket_completed_response("resp_call_mismatch_second", 4, 1), - ) - .await; - let _ = websocket.close(None).await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "use the lookup tool", - "stream": false, - "input": [{"role": "user", "content": "call the lookup tool"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "use the lookup tool", - "stream": false, - "input": [ - {"role": "user", "content": "call the lookup tool"}, - { - "type": "function_call", - "call_id": "call_expected", - "name": "lookup", - "arguments": "{}" - }, - { - "type": "function_call_output", - "call_id": "call_missing", - "output": "tool output" - } - ] - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - - let (first_payload, second_payload) = upstream.await.unwrap(); - assert!(first_payload["prompt_cache_key"].as_str().is_some()); - assert!(second_payload.get("previous_response_id").is_none()); - assert_eq!(second_payload["input"].as_array().unwrap().len(), 3); - assert_eq!(second_payload["input"][2]["call_id"], "call_missing"); -} - -#[tokio::test] -async fn responses_websocket_should_implicitly_resume_after_sqlite_affinity_restore() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let server_base_url = base_url.clone(); - let upstream = tokio::spawn(async move { - let first_payload = accept_websocket_response_with_message( - &listener, - WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), - ) - .await; - let second_payload = accept_websocket_response_with_message( - &listener, - websocket_completed_response("resp_restored_implicit_resume", 4, 1), - ) - .await; - (first_payload, second_payload) - }); - let (app, api_key, pool, dir) = test_app_with_account_pool_config(base_url, |_| {}).await; - - let first_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [{"role": "user", "content": "remember this"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let db = dir.path().join("openai-record-affinity.sqlite"); - let restored_state = AppState::with_pool_secret_api_key_hasher_and_installation_id( - test_config(format!("sqlite://{}", db.display()), server_base_url), - pool.clone(), - SecretBox::new([83u8; 32]), - ApiKeyHasher::new([84u8; 32]), - TEST_INSTALLATION_ID.to_string(), - ); - assert_eq!( - restored_state - .restore_account_pool_from_repository() - .await - .unwrap(), - 1 - ); - assert_eq!( - restored_state - .restore_session_affinity_from_repository_now() - .await - .unwrap(), - 1 - ); - let restored_app = router::router().with_state(restored_state); - - let second_response = restored_app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [ - {"role": "user", "content": "remember this"}, - {"role": "assistant", "content": "cached answer"}, - {"role": "user", "content": "continue"} - ] - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - - let (first_payload, second_payload) = upstream.await.unwrap(); - assert_eq!( - second_payload["prompt_cache_key"], - first_payload["prompt_cache_key"] - ); - assert_eq!( - second_payload["previous_response_id"], - "resp_implicit_resume_first" - ); - assert_eq!(second_payload["input"].as_array().unwrap().len(), 1); - assert_eq!(second_payload["input"][0]["content"], "continue"); -} - -#[tokio::test] -async fn responses_websocket_pool_should_be_evicted_after_admin_account_status_cycle() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let _first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - websocket_completed_response("resp_pool_status_first", 4, 1), - ) - .await; - - tokio::select! { - message = websocket.next() => { - match message { - Some(Ok(message)) if message.is_text() => { - websocket - .send(Message::Text( - websocket_completed_response("resp_pool_status_second", 3, 1).into(), - )) - .await - .unwrap(); - let _ = websocket.close(None).await; - true - } - _ => { - accept_websocket_response_with_message( - &listener, - websocket_completed_response("resp_pool_status_second", 3, 1), - ) - .await; - false - } - } - } - accepted = listener.accept() => { - let (stream, _) = accepted.unwrap(); - let mut second_websocket = accept_async(stream).await.unwrap(); - send_websocket_response_and_capture_payload( - &mut second_websocket, - websocket_completed_response("resp_pool_status_second", 3, 1), - ) - .await; - second_websocket.close(None).await.unwrap(); - let _ = websocket.close(None).await; - false - } - } - }); - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; - seed_openai_admin_session(&pool, "session_status_cycle").await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "prompt_cache_key": "status-cycle" - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_pool_status_first\"")); - - update_admin_account_status(&app, "acct_chat", "disabled").await; - update_admin_account_status(&app, "acct_chat", "active").await; - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "previous_response_id": "resp_pool_status_first" - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_pool_status_second\"")); - - let reused_connection = upstream.await.unwrap(); - assert!( - !reused_connection, - "admin status lifecycle should evict the old pooled websocket" - ); -} - -#[tokio::test] -async fn responses_websocket_should_not_implicitly_resume_self_contained_function_call_replay() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - websocket_completed_function_call_response("resp_self_contained_first", "call_self"), - ) - .await; - let second_payload = accept_followup_websocket_response( - &listener, - &mut websocket, - websocket_completed_response("resp_self_contained_second", 4, 1), - ) - .await; - let _ = websocket.close(None).await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "use the lookup tool", - "stream": false, - "input": [{"role": "user", "content": "call the lookup tool"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "use the lookup tool", - "stream": false, - "input": [ - {"role": "user", "content": "call the lookup tool"}, - { - "type": "function_call", - "call_id": "call_self", - "name": "lookup", - "arguments": "{}" - }, - { - "type": "function_call_output", - "call_id": "call_self", - "output": "tool output" - } - ] - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - - let (first_payload, second_payload) = upstream.await.unwrap(); - assert!(first_payload["prompt_cache_key"].as_str().is_some()); - assert!(second_payload.get("previous_response_id").is_none()); - assert_eq!(second_payload["input"].as_array().unwrap().len(), 3); - assert_eq!(second_payload["input"][2]["call_id"], "call_self"); -} - -#[tokio::test] -async fn responses_websocket_should_not_implicitly_resume_across_codex_windows() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), - ) - .await; - let second_payload = accept_followup_websocket_response( - &listener, - &mut websocket, - websocket_completed_response("resp_window_b", 8, 2), - ) - .await; - let _ = websocket.close(None).await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "prompt_cache_key": "shared-variant-session", - "codexWindowId": "window-a", - "stream": false, - "input": [{"role": "user", "content": "remember this"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "prompt_cache_key": "shared-variant-session", - "codexWindowId": "window-b", - "stream": false, - "input": [ - {"role": "user", "content": "remember this"}, - {"role": "assistant", "content": "cached answer"}, - {"role": "user", "content": "continue in another window"} - ] - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - - let (first_payload, second_payload) = upstream.await.unwrap(); - assert_eq!( - second_payload["prompt_cache_key"], - first_payload["prompt_cache_key"] - ); - assert!(second_payload.get("previous_response_id").is_none()); - assert_eq!(second_payload["input"].as_array().unwrap().len(), 3); -} - -#[tokio::test] -async fn responses_websocket_should_evict_reasoning_replay_after_invalid_encrypted_content() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), - ) - .await; - let invalid_payload = accept_followup_websocket_response( - &listener, - &mut websocket, - WEBSOCKET_INVALID_ENCRYPTED_CONTENT.trim().to_string(), - ) - .await; - let _ = websocket.close(None).await; - let retried_payload = accept_websocket_response_with_message( - &listener, - websocket_completed_response("resp_after_replay_eviction", 4, 1), - ) - .await; - (first_payload, invalid_payload, retried_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [{"role": "user", "content": "remember this"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [ - {"role": "user", "content": "remember this"}, - {"role": "assistant", "content": "cached answer"}, - {"role": "user", "content": "continue"} - ] - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - - let (first_payload, invalid_payload, retried_payload) = upstream.await.unwrap(); - assert_eq!( - invalid_payload["previous_response_id"], - "resp_implicit_resume_first" - ); - assert_eq!( - invalid_payload["input"][0]["encrypted_content"], - "enc_reasoning_replay" - ); - assert_eq!( - retried_payload["prompt_cache_key"], - first_payload["prompt_cache_key"] - ); - assert!(retried_payload.get("previous_response_id").is_none()); - let retried_input = retried_payload["input"].as_array().unwrap(); - assert!(retried_input - .iter() - .all(|item| item.get("encrypted_content").is_none())); - assert_eq!(retried_input.last().unwrap()["content"], "continue"); -} - -#[tokio::test] -async fn responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_async(stream).await.unwrap(); - let first_payload = send_websocket_response_and_capture_payload( - &mut websocket, - WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), - ) - .await; - let implicit_payload = accept_followup_websocket_response( - &listener, - &mut websocket, - WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND.trim().to_string(), - ) - .await; - let _ = websocket.close(None).await; - let restored_payload = accept_websocket_response_with_message( - &listener, - websocket_completed_response("resp_implicit_resume_restored", 10, 2), - ) - .await; - (first_payload, implicit_payload, restored_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [{"role": "user", "content": "remember this"}] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "stream": false, - "input": [ - {"role": "user", "content": "remember this"}, - {"role": "assistant", "content": "cached answer"}, - {"role": "user", "content": "continue"} - ] - }), - )) - .await - .unwrap(); - let body = response_json(second_response).await; - assert_eq!(body["id"], "resp_implicit_resume_restored"); - - let (_first_payload, implicit_payload, restored_payload) = upstream.await.unwrap(); - assert_eq!( - implicit_payload["previous_response_id"], - "resp_implicit_resume_first" - ); - assert_eq!(implicit_payload["input"].as_array().unwrap().len(), 2); - assert!(restored_payload.get("previous_response_id").is_none()); - assert_eq!(restored_payload["input"].as_array().unwrap().len(), 3); - assert_eq!(restored_payload["input"][0]["role"], "user"); - assert_eq!(restored_payload["input"][1]["role"], "assistant"); - assert_eq!(restored_payload["input"][2]["content"], "continue"); -} - -#[tokio::test] -async fn responses_websocket_should_route_previous_response_id_to_recorded_account() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (_reused_connection, first_payload, second_payload) = - accept_two_successful_websocket_responses_with_authorization( - &listener, - "Bearer access-primary", - "resp_affinity_first", - "resp_affinity_second", - ) - .await; - (first_payload, second_payload) - }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - - let first_response = app - .clone() - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "instructions": "answer briefly", - "input": [{ - "role": "user", - "content": "keep this conversation on the same account" - }] - }), - )) - .await - .unwrap(); - assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_affinity_first\"")); - let stored_affinity: (String, String, String, Option, String) = sqlx::query_as( - "select account_id, conversation_id, function_call_ids_json, input_tokens, expires_at from session_affinities where response_id = ?", - ) - .bind("resp_affinity_first") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(stored_affinity.0, "acct_primary"); - assert!(!stored_affinity.1.is_empty()); - assert_eq!(stored_affinity.2, "[]"); - assert_eq!(stored_affinity.3, Some(3)); - assert!(!stored_affinity.4.is_empty()); - - let second_response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "previous_response_id": "resp_affinity_first" - }), - )) - .await - .unwrap(); - assert_eq!(second_response.status(), StatusCode::OK); - let second_body = response_text(second_response).await; - assert!( - second_body.contains("\"id\":\"resp_affinity_second\""), - "{second_body}" - ); - let (first_payload, second_payload) = upstream.await.unwrap(); - - assert_ne!(first_payload["prompt_cache_key"], Value::Null); - assert_eq!( - second_payload["previous_response_id"], - "resp_affinity_first" - ); - assert_eq!( - second_payload["prompt_cache_key"], first_payload["prompt_cache_key"], - "previous_response_id should inherit the recorded conversation identity" - ); -} - -#[tokio::test] -async fn responses_websocket_non_stream_previous_response_not_found_should_strip_history_and_retry_same_account( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let first_payload = accept_websocket_response_with_authorization_and_message( - &listener, - "Bearer access-secret", - WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND.trim().to_string(), - ) - .await; - let second_payload = accept_websocket_response_with_authorization_and_message( - &listener, - "Bearer access-secret", - websocket_completed_response("resp_after_history_strip", 3, 1), - ) - .await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "previous_response_id": "resp_missing" - }), - )) - .await - .unwrap(); - let body = response_json(response).await; - let (first_payload, second_payload) = upstream.await.unwrap(); - - assert_eq!(body["id"], "resp_after_history_strip"); - assert_eq!(first_payload["previous_response_id"], "resp_missing"); - assert!(second_payload.get("previous_response_id").is_none()); -} - -#[tokio::test] -async fn responses_websocket_stream_previous_response_not_found_should_strip_history_and_retry_same_account( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let first_payload = accept_websocket_response_with_authorization_and_message( - &listener, - "Bearer access-secret", - WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND.trim().to_string(), - ) - .await; - let second_payload = accept_websocket_response_with_authorization_and_message( - &listener, - "Bearer access-secret", - websocket_completed_response("resp_stream_after_history_strip", 3, 1), - ) - .await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "previous_response_id": "resp_missing" - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - let (first_payload, second_payload) = upstream.await.unwrap(); - - assert!(body.contains("\"id\":\"resp_stream_after_history_strip\"")); - assert_eq!(first_payload["previous_response_id"], "resp_missing"); - assert!(second_payload.get("previous_response_id").is_none()); -} - -#[tokio::test] -async fn responses_websocket_non_stream_unanswered_function_call_should_strip_history_and_retry_same_account( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let first_payload = accept_websocket_response_with_authorization_and_message( - &listener, - "Bearer access-secret", - WEBSOCKET_UNANSWERED_FUNCTION_CALL.trim().to_string(), - ) - .await; - let second_payload = accept_websocket_response_with_authorization_and_message( - &listener, - "Bearer access-secret", - websocket_completed_response("resp_after_function_call_strip", 3, 1), - ) - .await; - (first_payload, second_payload) - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "previous_response_id": "resp_with_call" - }), - )) - .await - .unwrap(); - let body = response_json(response).await; - let (first_payload, second_payload) = upstream.await.unwrap(); - - assert_eq!(body["id"], "resp_after_function_call_strip"); - assert_eq!(first_payload["previous_response_id"], "resp_with_call"); - assert!(second_payload.get("previous_response_id").is_none()); -} - -#[tokio::test] -async fn responses_websocket_previous_response_id_should_retry_fallback_account_after_429() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-primary", - 429, - "Too Many Requests", - Some(77), - WEBSOCKET_HISTORY_RATE_LIMITED, - ) - .await; - accept_successful_websocket_response_with_authorization( - &listener, - "Bearer access-secondary", - "resp_history_fallback", - ) - .await - }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "previous_response_id": "resp_prev" - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - let fallback_payload = upstream.await.unwrap(); - let secondary_usage: (i64,) = - sqlx::query_as("select request_count from account_usage where account_id = ?") - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - - assert!(body.contains("\"id\":\"resp_history_fallback\"")); - assert_eq!(fallback_payload["previous_response_id"], "resp_prev"); - assert_eq!(secondary_usage.0, 1); -} - -#[tokio::test] -async fn responses_websocket_non_stream_previous_response_id_should_retry_fallback_account_after_429( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-primary", - 429, - "Too Many Requests", - Some(77), - WEBSOCKET_HISTORY_RATE_LIMITED, - ) - .await; - accept_successful_websocket_response_with_authorization( - &listener, - "Bearer access-secondary", - "resp_history_fallback_non_stream", - ) - .await - }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "previous_response_id": "resp_prev" - }), - )) - .await - .unwrap(); - let body = response_json(response).await; - let fallback_payload = upstream.await.unwrap(); - let secondary_usage: (i64,) = - sqlx::query_as("select request_count from account_usage where account_id = ?") - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(body["id"], "resp_history_fallback_non_stream"); - assert_eq!(fallback_payload["previous_response_id"], "resp_prev"); - assert_eq!(secondary_usage.0, 1); -} - -#[tokio::test] -async fn responses_websocket_without_history_should_mark_expired_after_fallback_401() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-primary", - 429, - "Too Many Requests", - Some(30), - WEBSOCKET_RATE_LIMITED, - ) - .await; - reject_next_websocket_upgrade( - &listener, - "Bearer access-secondary", - 401, - "Unauthorized", - None, - WEBSOCKET_TOKEN_REVOKED, - ) - .await; - }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - upstream.await.unwrap(); - let secondary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - let primary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert!(body.contains("event: response.failed")); - assert!(body.contains("\"type\":\"invalid_request_error\"")); - assert!(body.contains("\"code\":\"authentication_error\"")); - assert!(body.contains("All accounts exhausted")); - assert!(body.contains("token_revoked")); - assert_eq!(secondary_status.0, "expired"); - assert_eq!(primary_usage, (1, 0, 0)); -} - -#[tokio::test] -async fn responses_websocket_without_history_should_return_rate_limit_stream_error_when_fallback_accounts_exhausted( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-primary", - 429, - "Too Many Requests", - Some(11), - WEBSOCKET_FIRST_ACCOUNT_LIMITED, - ) - .await; - reject_next_websocket_upgrade( - &listener, - "Bearer access-secondary", - 429, - "Too Many Requests", - Some(22), - WEBSOCKET_SECOND_ACCOUNT_LIMITED, - ) - .await; - }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - upstream.await.unwrap(); - let primary_usage: (i64,) = - sqlx::query_as("select request_count from account_usage where account_id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let secondary_usage: (i64,) = - sqlx::query_as("select request_count from account_usage where account_id = ?") - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_response_failed_stream( - &body, - "rate_limit_error", - "rate_limit_exceeded", - &[ - "All accounts exhausted (2 rate-limited)", - "second account limited", - ], - ); - assert_eq!(primary_usage.0, 1); - assert_eq!(secondary_usage.0, 1); -} - -#[tokio::test] -async fn responses_websocket_response_failed_quota_should_retry_fallback_account() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - let mut first_websocket = - accept_websocket_with_authorization(first_stream, "Bearer access-primary").await; - let _first_payload = send_websocket_response_and_capture_payload( - &mut first_websocket, - json!({ - "type": "response.failed", - "response": { - "id": "resp_ws_quota_failed", - "error": { - "code": "insufficient_quota", - "message": "quota exhausted" - } - } - }) - .to_string(), - ) - .await; - - let (second_stream, _) = listener.accept().await.unwrap(); - let mut second_websocket = - accept_websocket_with_authorization(second_stream, "Bearer access-secondary").await; - send_websocket_response_and_capture_payload( - &mut second_websocket, - websocket_completed_response("resp_after_ws_quota", 3, 1), - ) - .await; - second_websocket.close(None).await.unwrap(); - }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": true - }), - )) - .await - .unwrap(); - let body = response_json(response).await; - upstream.await.unwrap(); - let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(body["id"], "resp_after_ws_quota"); - assert_eq!(primary_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn responses_websocket_without_history_should_return_quota_stream_error_when_402_has_no_fallback( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-secret", - 402, - "Payment Required", - None, - r#"{"error":{"message":"quota reached"}}"#, - ) - .await; - }); - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - upstream.await.unwrap(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_response_failed_stream( - &body, - "invalid_request_error", - "codex_api_error", - &[ - "All accounts exhausted (1 quota-exhausted)", - "quota reached", - ], - ); - assert_eq!(account_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn responses_websocket_without_history_should_return_model_unsupported_stream_error_when_no_fallback( -) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-secret", - 400, - "Bad Request", - None, - r#"{"error":{"code":"model_not_available","message":"Model gpt-5.5 is not available on this account plan"}}"#, - ) - .await; - }); - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": true - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - upstream.await.unwrap(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_response_failed_stream( - &body, - "invalid_request_error", - "codex_api_error", - &[ - "No accounts available", - "model_not_available", - "not available", - ], - ); - assert_eq!(account_status.0, "active"); -} - -#[tokio::test] -async fn responses_websocket_with_history_should_return_path_block_stream_error_when_no_fallback() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - reject_next_websocket_upgrade( - &listener, - "Bearer access-secret", - 404, - "Not Found", - None, - "", - ) - .await; - }); - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - - let response = app - .oneshot(responses_json_request( - &api_key, - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "previous_response_id": "resp_prev" - }), - )) - .await - .unwrap(); - let body = response_text(response).await; - upstream.await.unwrap(); - - assert_response_failed_stream( - &body, - "server_error", - "codex_api_error", - &["No accounts available", "Cloudflare path-block"], - ); -} - -#[tokio::test] -async fn responses_should_convert_tuple_schema_before_upstream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_TUPLE_OBJECT_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from(tuple_response_request_body(false))) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - - assert_eq!(status, StatusCode::OK); - let requests = server.received_requests().await.unwrap(); - let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - let schema = &upstream_body["text"]["format"]["schema"]; - assert!(schema["properties"]["point"].get("prefixItems").is_none()); - assert!(schema["properties"]["point"].get("items").is_none()); - assert_eq!( - schema["properties"]["point"], - json!({ - "type": "object", - "properties": { - "0": {"type": "number"}, - "1": {"type": "number"} - }, - "required": ["0", "1"], - "additionalProperties": false - }) - ); -} - -#[tokio::test] -async fn responses_should_reconvert_tuple_schema_output_for_client() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_TUPLE_OBJECT_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from(tuple_response_request_body(false))) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_tuple"); - assert_eq!(body["output_text"], "{\"point\":[1,2]}"); - assert_eq!(body["output"][0]["content"][0]["text"], "{\"point\":[1,2]}"); -} - -#[tokio::test] -async fn responses_stream_should_reconvert_tuple_schema_output_for_client() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_TUPLE_OBJECT_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from(tuple_response_request_body(true))) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_text(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(body.contains(r#""delta":"{\"point\":[1,2]}""#)); - assert!(body.contains(r#""output_text":"{\"point\":[1,2]}""#)); - assert!(!body.contains(r#""point\":{\"0":1,"1":2}"#)); - assert!(body.ends_with("data: [DONE]\n\n")); -} - -#[tokio::test] -async fn responses_should_forward_parity_fields_context_headers_and_account_scoped_identity() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .header("x-codex-turn-state", "turn-header") - .header("x-codex-turn-metadata", "meta-header") - .header("x-codex-beta-features", "beta-header") - .header("x-responsesapi-include-timing-metrics", "false") - .header("version", "header-version") - .header("x-codex-window-id", "window-header") - .header("x-codex-parent-thread-id", "parent-header") - .header("x-openai-subagent", "review") - .body(Body::from( - json!({ - "model": "gpt-5.5-high-fast", - "stream": false, - "use_websocket": false, - "input": [], - "prompt_cache_key": "pcache", - "client_metadata": {"safe": "yes", "drop": 42}, - "turnState": "turn-body", - "turnMetadata": "meta-body", - "betaFeatures": "beta-body", - "includeTimingMetrics": "true", - "version": "2026-06-12", - "codexWindowId": "window-body", - "parentThreadId": "parent-body", - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_response_1"); - let requests = server.received_requests().await.unwrap(); - let upstream = requests - .iter() - .find(|request| request.url.path() == "/codex/responses") - .expect("responses upstream request should be sent"); - let upstream_body: Value = serde_json::from_slice(&upstream.body).unwrap(); - let identity = build_conversation_identity(Some("pcache"), Some("window-body"), "acct_chat"); - let conversation_id = identity - .conversation_id - .as_deref() - .expect("conversation identity should be scoped"); - let window_id = identity - .window_id - .as_deref() - .expect("window identity should be scoped"); - let upstream_header = |name: &str| { - upstream - .headers - .get(name) - .and_then(|value| value.to_str().ok()) - }; - - assert_eq!(upstream_body["model"], "gpt-5.5"); - assert_eq!(upstream_body["service_tier"], "priority"); - assert_eq!( - upstream_body["reasoning"], - json!({"summary": "auto", "effort": "high"}) - ); - assert_eq!( - upstream_body["include"], - json!(["reasoning.encrypted_content"]) - ); - assert_eq!(upstream_body["prompt_cache_key"], conversation_id); - assert_eq!(upstream_body["client_metadata"]["safe"], "yes"); - assert_eq!( - upstream_body["client_metadata"]["x-openai-subagent"], - "review" - ); - assert_eq!( - upstream_body["client_metadata"]["x-codex-installation-id"], - TEST_INSTALLATION_ID - ); - assert_eq!( - upstream_body["client_metadata"]["x-codex-window-id"], - window_id - ); - assert_eq!( - upstream_body["client_metadata"]["x-codex-turn-metadata"], - "meta-body" - ); - assert_eq!( - upstream_body["client_metadata"]["x-codex-parent-thread-id"], - "parent-body" - ); - assert_eq!(upstream_header("session_id"), Some(conversation_id)); - assert_eq!(upstream_header("x-codex-window-id"), Some(window_id)); - assert_eq!(upstream_header("x-codex-turn-state"), Some("turn-body")); - assert_eq!(upstream_header("x-codex-turn-metadata"), Some("meta-body")); - assert_eq!(upstream_header("x-codex-beta-features"), Some("beta-body")); - assert_eq!( - upstream_header("x-responsesapi-include-timing-metrics"), - Some("true") - ); - assert_eq!(upstream_header("version"), Some("2026-06-12")); - assert_eq!( - upstream_header("x-codex-parent-thread-id"), - Some("parent-body") - ); - assert_eq!(upstream_header("x-openai-subagent"), Some("review")); - for local_field in [ - "turnState", - "turnMetadata", - "betaFeatures", - "includeTimingMetrics", - "version", - "codexWindowId", - "parentThreadId", - "use_websocket", - ] { - assert!(upstream_body.get(local_field).is_none()); - } -} - -#[tokio::test] -async fn responses_should_preserve_non_empty_include_when_reasoning_defaults_apply() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5-high", - "stream": false, - "use_websocket": false, - "input": [], - "include": ["file_search_call.results"], - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let requests = server.received_requests().await.unwrap(); - let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - upstream_body["reasoning"], - json!({"summary": "auto", "effort": "high"}) - ); - assert_eq!( - upstream_body["include"], - json!(["file_search_call.results"]) - ); -} - -#[tokio::test] -async fn responses_should_sanitize_reasoning_and_compaction_input_before_upstream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "stream": false, - "use_websocket": false, - "input": [ - { - "type": "reasoning", - "id": "rs_1", - "status": "completed", - "summary": [ - {"type": "summary_text", "text": "valid summary"}, - {"type": "ignored", "text": "drop"} - ], - "encrypted_content": "enc_reasoning", - "content": [ - {"type": "reasoning_text", "text": "valid reasoning"}, - {"type": "ignored", "text": "drop"} - ], - "extra": "drop" - }, - { - "type": "reasoning", - "id": "", - "summary": [{"type": "summary_text", "text": "drop"}] - }, - { - "type": "compaction", - "id": "cmp_1", - "encrypted_content": "enc_compaction", - "extra": "drop" - }, - {"type": "compaction", "id": "cmp_drop"}, - {"type": "message", "role": "user", "content": "keep me", "extra": 42} - ] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let requests = server.received_requests().await.unwrap(); - let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - upstream_body["input"], - json!([ - { - "type": "reasoning", - "id": "rs_1", - "summary": [{"type": "summary_text", "text": "valid summary"}], - "status": "completed", - "encrypted_content": "enc_reasoning", - "content": [{"type": "reasoning_text", "text": "valid reasoning"}] - }, - { - "type": "compaction", - "encrypted_content": "enc_compaction", - "id": "cmp_1" - }, - {"type": "message", "role": "user", "content": "keep me", "extra": 42} - ]) - ); -} - -#[tokio::test] -async fn responses_should_reconstruct_non_stream_output_text_from_sse_deltas() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_TEXT_DELTAS_COMPLETED_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json(response).await; - - assert_eq!(body["id"], "resp_text"); - assert_eq!(body["output"][0]["role"], "assistant"); - assert_eq!(body["output"][0]["content"][0]["text"], "hello from deltas"); - assert_eq!(body["output_text"], "hello from deltas"); -} - -#[tokio::test] -async fn responses_should_use_done_output_items_when_completed_output_is_empty() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_DONE_ITEM_COMPLETED_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json(response).await; - - assert_eq!(body["id"], "resp_item"); - assert_eq!(body["output"][0]["content"][0]["text"], "from done item"); - assert_eq!(body["output_text"], "from done item"); -} - -#[tokio::test] -async fn responses_review_route_should_force_review_subagent_upstream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("x-openai-subagent", "review")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses/review") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_response_1"); - let requests = server.received_requests().await.unwrap(); - let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - upstream_body["client_metadata"]["x-openai-subagent"], - "review" - ); -} - -#[tokio::test] -async fn responses_compact_should_post_json_to_codex_compact_upstream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses/compact")) - .and(header("authorization", "Bearer access-secret")) - .and(header("chatgpt-account-id", "chatgpt-account")) - .and(header("content-type", "application/json")) - .and(header("openai-beta", "responses_websockets=2026-02-06")) - .and(header("x-openai-internal-codex-residency", "us")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": [ - { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "compacted"}] - } - ] - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _dir) = test_app_with_account(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses/compact") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5-fast", - "instructions": "compress the session", - "input": [ - {"role": "user", "content": "hello"}, - { - "type": "reasoning", - "id": "rs_1", - "status": "completed", - "summary": [{"type": "summary_text", "text": "kept"}], - "ignored": "drop" - }, - {"type": "compaction", "encrypted_content": "enc_compact"}, - {"type": "compaction", "id": "drop_missing_encrypted"} - ], - "tools": [{"type": "function", "name": "lookup"}], - "parallel_tool_calls": false, - "reasoning": {"effort": "high", "summary": "auto", "extra": "drop"}, - "text": { - "format": { - "type": "json_schema", - "name": "Compact", - "schema": {"type": "object"}, - "strict": true - } - }, - "stream": true, - "use_websocket": false, - "store": true, - "prompt_cache_key": "must_not_forward" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["output"][0]["content"][0]["text"], "compacted"); - let requests = server.received_requests().await.unwrap(); - let compact_request = requests - .iter() - .find(|request| request.url.path() == "/codex/responses/compact") - .expect("compact upstream request should be sent"); - assert_ne!( - compact_request - .headers - .get("accept") - .and_then(|value| value.to_str().ok()), - Some("text/event-stream") - ); - assert!(compact_request - .headers - .get("x-codex-installation-id") - .is_some()); - let upstream_body: Value = serde_json::from_slice(&compact_request.body).unwrap(); - assert_eq!(upstream_body["model"], "gpt-5.5"); - assert_eq!(upstream_body["instructions"], "compress the session"); - assert_eq!(upstream_body["parallel_tool_calls"], false); - assert_eq!( - upstream_body["reasoning"], - json!({"effort": "high", "summary": "auto"}) - ); - assert_eq!( - upstream_body["tools"], - json!([{"type": "function", "name": "lookup"}]) - ); - assert_eq!(upstream_body["text"]["format"]["type"], "json_schema"); - assert!(upstream_body.get("stream").is_none()); - assert!(upstream_body.get("store").is_none()); - assert!(upstream_body.get("prompt_cache_key").is_none()); - assert_eq!(upstream_body["input"].as_array().unwrap().len(), 3); - assert!(upstream_body["input"][1].get("ignored").is_none()); - assert_eq!( - upstream_body["input"][2]["encrypted_content"], - "enc_compact" - ); -} - -#[tokio::test] -async fn responses_compact_should_return_rate_limit_error_when_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses/compact")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "error": { - "code": "rate_limit_exceeded", - "message": "compact quota reached" - } - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses/compact") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let message = body["error"]["message"].as_str().unwrap_or_default(); - - assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); - assert_eq!(body["type"], "error"); - assert_eq!(body["error"]["type"], "rate_limit_error"); - assert_eq!(body["error"]["code"], "rate_limit_exceeded"); - assert!(message.contains("All accounts exhausted (1 rate-limited)")); - assert!(message.contains("compact quota reached")); -} - -#[tokio::test] -async fn responses_compact_should_return_responses_error_when_no_accounts_are_available() { - let server = MockServer::start().await; - let (app, api_key, _dir) = test_app_without_accounts(server.uri()).await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses/compact") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "hello"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(body["type"], "error"); - assert_eq!(body["error"]["type"], "server_error"); - assert_eq!(body["error"]["code"], "no_available_accounts"); -} - -#[tokio::test] -async fn responses_should_use_imported_account_record_usage_cookie_and_event_log() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .and(header("chatgpt-account-id", "chatgpt-account")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header( - "set-cookie", - "cf_clearance=new; Domain=.chatgpt.com; Path=/", - ) - .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .header("x-request-id", "req_non_stream_usage_log") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_usage"); - assert_eq!(body["usage"]["input_tokens"], 7); - let usage: (i64, i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens, cached_tokens from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(usage, (1, 7, 4, 2)); - let cookie_header = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])) - .cookie_header("acct_chat", "chatgpt.com") - .await - .unwrap(); - assert_eq!(cookie_header.as_deref(), Some("cf_clearance=new")); - let event = latest_response_event_log(&pool).await; - let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); - assert_eq!(event.level, "info"); - assert_eq!( - event.request_id.as_deref(), - Some("req_non_stream_usage_log") - ); - assert_eq!(event.account_id.as_deref(), Some("acct_chat")); - assert_eq!(event.status_code, Some(200)); - assert_eq!(metadata["stream"], false); - assert_eq!(metadata["usage"]["inputTokens"], 7); -} - -#[tokio::test] -async fn responses_should_skip_event_log_when_logging_disabled() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .header("x-request-id", "req_non_stream_no_log") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_event_log_count(&pool).await, 0); -} - -#[tokio::test] -async fn responses_should_record_image_generation_usage_when_tool_succeeds() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_COMPLETED_IMAGE_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false, - "tools": [{"type": "image_generation"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let usage: (i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64) = - sqlx::query_as( - "select request_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, window_image_input_tokens, window_image_output_tokens, window_image_request_count, window_image_request_failed_count from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(usage, (1, 7, 4, 2, 31, 9, 1, 0, 31, 9, 1, 0)); -} - -#[tokio::test] -async fn responses_should_record_failed_image_generation_attempt_when_tool_has_no_output() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false, - "tools": [{"type": "image_generation"}] - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let usage: (i64, i64, i64, i64) = sqlx::query_as( - "select image_request_count, image_request_failed_count, window_image_request_count, window_image_request_failed_count from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(usage, (0, 1, 0, 1)); -} - -#[tokio::test] -async fn responses_should_scope_upstream_cookie_by_codex_response_path() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .capture_set_cookie( - "acct_chat", - "cf_clearance=root; Domain=.chatgpt.com; Path=/", - ) - .await - .unwrap(); - cookie_store - .capture_set_cookie( - "acct_chat", - "cf_clearance=codex; Domain=.chatgpt.com; Path=/codex", - ) - .await - .unwrap(); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let requests = server.received_requests().await.unwrap(); - let cookie_header = requests - .iter() - .find(|request| request.url.path() == "/codex/responses") - .and_then(|request| request.headers.get("cookie")) - .and_then(|value| value.to_str().ok()); - assert_eq!(cookie_header, Some("cf_clearance=codex; cf_clearance=root")); -} - -#[tokio::test] -async fn responses_should_passively_cache_rate_limit_headers() { - let server = MockServer::start().await; - let reset_at = Utc::now().timestamp() + 300; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("x-codex-primary-used-percent", "100") - .insert_header("x-codex-primary-window-minutes", "5") - .insert_header("x-codex-primary-reset-at", reset_at.to_string()) - .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - sqlx::query( - r#"update accounts set quota_json = '{"credits":{"has_credits":true,"unlimited":false,"balance":12}}', quota_verify_required = 1 where id = ?"#, - ) - .bind("acct_chat") - .execute(&pool) - .await - .unwrap(); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let stored: (String, i64, Option, i64) = sqlx::query_as( - "select quota_json, quota_limit_reached, quota_cooldown_until, quota_verify_required from accounts where id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - let quota: Value = serde_json::from_str(&stored.0).unwrap(); - assert_eq!(quota["rate_limit"]["limit_reached"], true); - assert_eq!(quota["rate_limit"]["reset_at"], reset_at); - assert_eq!(quota["credits"]["balance"], 12); - assert_eq!(stored.1, 1); - assert!(stored.2.is_some()); - assert_eq!(stored.3, 0); - let window: (i64, i64, i64, i64, Option, Option, Option) = - sqlx::query_as( - "select window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(window.0, 1); - assert_eq!(window.1, 7); - assert_eq!(window.2, 4); - assert_eq!(window.3, 2); - assert!(window.4.is_some()); - assert_eq!( - DateTime::parse_from_rfc3339(window.5.as_deref().unwrap()) - .unwrap() - .timestamp(), - reset_at - ); - assert_eq!(window.6, Some(300)); -} - -#[tokio::test] -async fn responses_should_record_empty_response_attempts() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_EMPTY_COMPLETED_SSE), - ) - .expect(3) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - let usage: (i64, i64, i64, i64) = sqlx::query_as( - "select request_count, empty_response_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(usage, (3, 3, 0, 0)); -} - -#[tokio::test] -async fn responses_should_fallback_to_next_account_after_rate_limit() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(429) - .insert_header("retry-after", "120") - .set_body_json(json!({ - "error": { - "message": "rate limited", - "resets_in_seconds": 120 - } - })), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_SUCCESS_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_response_1"); - let primary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let secondary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(primary_usage, (1, 0, 0)); - assert_eq!(secondary_usage, (1, 5, 2)); -} - -#[tokio::test] -async fn responses_should_mark_expired_after_401_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"code": "token_revoked", "message": "token revoked"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_401_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_401"); - assert_eq!(account_status.0, "expired"); -} - -#[tokio::test] -async fn responses_should_recover_auth_failure_from_sse_failed_event() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_FAILED_AUTH_SSE), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_401_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_401"); - assert_eq!(account_status.0, "expired"); -} - -#[tokio::test] -async fn responses_should_return_auth_error_when_401_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"code": "token_revoked", "message": "token revoked"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let message = body["error"]["message"].as_str().unwrap_or_default(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert!(message.contains("All accounts exhausted (1 expired)")); - assert!(message.contains("token_revoked")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(account_status.0, "expired"); -} - -#[tokio::test] -async fn responses_should_mark_banned_when_401_says_account_deactivated() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"message": "account deactivated"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert_eq!(account_status.0, "banned"); -} - -#[tokio::test] -async fn responses_should_mark_banned_after_403_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({ - "error": {"message": "account banned"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_403_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_403"); - assert_eq!(account_status.0, "banned"); -} - -#[tokio::test] -async fn responses_should_return_cloudflare_challenge_error_when_403_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(403).set_body_string( - "Just a moment...cf_chl challenge", - )) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_chat", "cf_clearance=old") - .await - .unwrap(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let message = body["error"]["message"].as_str().unwrap_or_default(); - let primary_state: (String, Option) = - sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::BAD_GATEWAY); - assert!(message.contains("All accounts exhausted (1 cloudflare-challenge)")); - assert!(message.contains("Cloudflare challenge")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(primary_state.0, "active"); - assert!(primary_state.1.is_some()); - assert_eq!( - cookie_store - .cookie_header("acct_chat", "chatgpt.com") - .await - .unwrap(), - None - ); -} - -#[tokio::test] -async fn responses_should_cool_down_cloudflare_403_and_fallback() { - let started_at = Utc::now(); - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(403).set_body_string( - "Just a moment...cf_chl challenge", - )) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_CLOUDFLARE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_primary", "cf_clearance=old") - .await - .unwrap(); - cookie_store - .set_cookie_header("acct_secondary", "cf_clearance=keep") - .await - .unwrap(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let primary_state: (String, Option) = - sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap()) - .unwrap() - .with_timezone(&Utc); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_cloudflare"); - assert_eq!(primary_state.0, "active"); - assert!(cooldown_until > started_at); - assert_eq!( - cookie_store - .cookie_header("acct_primary", "chatgpt.com") - .await - .unwrap(), - None - ); - assert_eq!( - cookie_store - .cookie_header("acct_secondary", "chatgpt.com") - .await - .unwrap() - .as_deref(), - Some("cf_clearance=keep") - ); -} - -#[tokio::test] -async fn responses_should_clear_cookies_after_cloudflare_path_block_404_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_CLOUDFLARE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_primary", "cf_clearance=old") - .await - .unwrap(); - cookie_store - .set_cookie_header("acct_secondary", "cf_clearance=keep") - .await - .unwrap(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_cloudflare"); - assert_eq!(primary_status.0, "active"); - assert_eq!( - cookie_store - .cookie_header("acct_primary", "chatgpt.com") - .await - .unwrap(), - None - ); - assert_eq!( - cookie_store - .cookie_header("acct_secondary", "chatgpt.com") - .await - .unwrap() - .as_deref(), - Some("cf_clearance=keep") - ); -} - -#[tokio::test] -async fn responses_should_disable_account_after_three_cloudflare_path_block_404s() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(404)) - .expect(3) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_chat", "cf_clearance=old") - .await - .unwrap(); - for _ in 0..3 { - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let message = body["error"]["message"].as_str().unwrap_or_default(); - - assert_eq!(status, StatusCode::BAD_GATEWAY); - assert!(message.contains("cloudflare-path-block")); - } - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(account_status.0, "disabled"); - assert_eq!( - cookie_store - .cookie_header("acct_chat", "chatgpt.com") - .await - .unwrap(), - None - ); -} - -#[tokio::test] -async fn responses_should_fallback_after_http_model_unsupported() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": { - "code": "model_not_supported", - "message": "Model gpt-5.5 is not supported on this account plan" - } - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_MODEL_UNSUPPORTED_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_model_unsupported"); - assert_eq!(primary_status.0, "active"); -} - -#[tokio::test] -async fn responses_should_fallback_after_sse_model_unsupported() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_FAILED_MODEL_UNSUPPORTED_SSE), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_MODEL_UNSUPPORTED_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_model_unsupported"); -} - -#[tokio::test] -async fn responses_stream_should_fallback_after_sse_model_unsupported() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_FAILED_MODEL_UNSUPPORTED_SSE), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_MODEL_UNSUPPORTED_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_model_unsupported")); -} - -#[tokio::test] -async fn responses_stream_should_return_model_unsupported_error_when_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": { - "code": "model_not_available", - "message": "Model gpt-5.5 is not available on this account plan" - } - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.failed")); - assert!(body.contains("All accounts exhausted (1 model-unsupported)")); - assert!(body.contains("model_not_available")); - assert_eq!(account_status.0, "active"); -} - -#[tokio::test] -async fn responses_should_strip_history_after_previous_response_not_found() { - let (base_url, upstream) = - spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( - "resp_previous_missing_failed", - "previous_response_not_found", - "Previous response with id resp_stale was not found", - )) - .await; - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_stale", - "turnState": "turn-stale", - "input": [{"role": "user", "content": "Continue"}], - "stream": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_history_recovery"); - assert_eq!( - captured_header(&captured.first_ws_headers, "x-codex-turn-state"), - Some("turn-stale") - ); - assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); - assert_eq!( - captured.first_ws_payload["previous_response_id"], - "resp_stale" - ); - assert!(captured - .second_ws_payload - .get("previous_response_id") - .is_none()); -} - -#[tokio::test] -async fn responses_should_strip_history_after_unanswered_function_call() { - let (base_url, upstream) = - spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( - "resp_unanswered_call_failed", - "invalid_request", - "No tool output found for function call call_1", - )) - .await; - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_stale", - "turnState": "turn-stale", - "input": [{"role": "user", "content": "Continue"}], - "stream": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_history_recovery"); - assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); - assert!(captured - .second_ws_payload - .get("previous_response_id") - .is_none()); -} - -#[tokio::test] -async fn responses_should_strip_history_after_websocket_invalid_encrypted_reasoning_replay() { - let (base_url, upstream) = - spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( - "resp_invalid_reasoning_failed", - "invalid_encrypted_content", - "Invalid encrypted content in reasoning replay", - )) - .await; - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_stale", - "turnState": "turn-stale", - "input": [{"role": "user", "content": "Continue"}], - "stream": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_history_recovery"); - assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); - assert!(captured - .second_ws_payload - .get("previous_response_id") - .is_none()); -} - -#[tokio::test] -async fn responses_should_strip_history_after_sse_invalid_encrypted_reasoning_replay() { - let (base_url, upstream) = - spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( - "resp_invalid_reasoning_failed", - "invalid_encrypted_content", - "Invalid encrypted content in reasoning replay", - )) - .await; - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_stale", - "turnState": "turn-stale", - "input": [{"role": "user", "content": "Continue"}], - "stream": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_history_recovery"); - assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); - assert!(captured - .second_ws_payload - .get("previous_response_id") - .is_none()); -} - -#[tokio::test] -async fn responses_stream_should_strip_history_after_previous_response_not_found() { - let (base_url, upstream) = - spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( - "resp_previous_missing_failed", - "previous_response_not_found", - "Previous response with id resp_stale was not found", - )) - .await; - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_stale", - "turnState": "turn-stale", - "input": [{"role": "user", "content": "Continue"}], - "stream": true - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_text(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(body.contains("resp_after_history_recovery")); - assert_eq!( - captured_header(&captured.first_ws_headers, "x-codex-turn-state"), - Some("turn-stale") - ); - assert_eq!( - captured.first_ws_payload["previous_response_id"], - "resp_stale" - ); - assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); - assert!(captured - .second_ws_payload - .get("previous_response_id") - .is_none()); -} - -#[tokio::test] -async fn responses_stream_should_strip_history_after_websocket_invalid_encrypted_reasoning_replay() -{ - let (base_url, upstream) = - spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( - "resp_invalid_reasoning_failed", - "invalid_encrypted_content", - "Invalid encrypted content in reasoning replay", - )) - .await; - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_stale", - "turnState": "turn-stale", - "input": [{"role": "user", "content": "Continue"}], - "stream": true - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_text(response).await; - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(body.contains("resp_after_history_recovery")); - assert_eq!( - captured_header(&captured.first_ws_headers, "x-codex-turn-state"), - Some("turn-stale") - ); - assert_eq!( - captured.first_ws_payload["previous_response_id"], - "resp_stale" - ); - assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); - assert!(captured - .second_ws_payload - .get("previous_response_id") - .is_none()); -} - -#[tokio::test] -async fn responses_stream_should_proxy_sse_and_record_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_USAGE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.completed")); - assert!(body.contains("resp_stream_usage")); - assert!( - body.ends_with("data: [DONE]\n\n"), - "stream responses should terminate clients, body was {body:?}" - ); - assert_eq!(usage, (1, 3, 5)); -} - -#[tokio::test] -async fn responses_stream_should_close_http_sse_upstream_when_client_disconnects() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let upstream = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - read_http_request(&mut socket).await; - write_chunked_http_sse_headers(&mut socket).await; - write_http_chunk( - &mut socket, - b"event: response.output_text.delta\ndata: {\"delta\":\"hello\"}\n\n", - ) - .await; - socket.flush().await.unwrap(); - - timeout( - StdDuration::from_secs(2), - wait_for_http_sse_upstream_disconnect(&mut socket), - ) - .await - .is_ok() - }); - - let (app, api_key, _dir) = test_app_with_account(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let mut body = response.into_body().into_data_stream(); - let first_chunk = timeout(StdDuration::from_secs(1), body.next()) - .await - .expect("first SSE chunk should arrive before disconnect") - .expect("stream should yield a first chunk") - .expect("chunk should be readable"); - assert!(String::from_utf8(first_chunk.to_vec()) - .unwrap() - .contains("event: response.output_text.delta")); - - drop(body); - assert!( - upstream.await.unwrap(), - "dropping the downstream stream should close the HTTP SSE upstream socket" - ); -} - -#[tokio::test] -async fn responses_stream_should_record_event_log_after_completed_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("retry-after", "7") - .insert_header("x-ratelimit-limit-requests", "99") - .set_body_string(RESPONSES_STREAM_USAGE_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .header("x-request-id", "req_stream_completed_log") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "stream with log"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let body = response_text(response).await; - let event = latest_response_event_log(&pool).await; - let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); - - assert!(body.contains("resp_stream_usage")); - assert_eq!(event.level, "info"); - assert_eq!( - event.request_id.as_deref(), - Some("req_stream_completed_log") - ); - assert_eq!(event.account_id.as_deref(), Some("acct_chat")); - assert_eq!(event.status_code, Some(200)); - assert_eq!(metadata["stream"], true); - assert_eq!(metadata["completed"], true); - assert_eq!(metadata["responseId"], "resp_stream_usage"); - assert_eq!(metadata["usage"]["inputTokens"], 3); - assert_eq!(metadata["usage"]["outputTokens"], 5); - assert_rate_limit_header(&metadata, "retry-after", "7"); - assert_rate_limit_header(&metadata, "x-ratelimit-limit-requests", "99"); - assert!(metadata.get("requestBody").is_none()); - assert!(metadata.get("responseBody").is_none()); -} - -#[tokio::test] -async fn responses_stream_should_preserve_body_metadata_when_capture_body_enabled() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_USAGE_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = - test_app_with_account_pool_and_logging_capture_body(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .header("x-request-id", "req_stream_capture_body_log") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "stream body capture"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let body = response_text(response).await; - let event = latest_response_event_log(&pool).await; - let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); - - assert!(body.contains("resp_stream_usage")); - assert_eq!(event.level, "info"); - assert_eq!( - event.request_id.as_deref(), - Some("req_stream_capture_body_log") - ); - assert_eq!(metadata["requestBody"]["model"], "gpt-5.5"); - assert_eq!(metadata["requestBody"]["stream"], true); - assert!(metadata["responseBody"] - .as_str() - .is_some_and(|body| body.contains("resp_stream_usage"))); -} - -#[tokio::test] -async fn responses_stream_should_record_event_log_after_late_disconnect() { - let first_frame = r#"event: response.output_text.delta -data: {"delta":"partial before logged disconnect"} - -"#; - let (base_url, first_chunk_sent, close_upstream) = - spawn_chunked_sse_upstream_then_clean_close_with_headers( - first_frame, - &[ - ("retry-after", "11"), - ("x-codex-primary-used-percent", "88"), - ], - ) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(base_url).await; - let response_task = tokio::spawn(async move { - app.oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .header("x-request-id", "req_stream_disconnect_log") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "stream disconnect log"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap() - }); - first_chunk_sent.await.unwrap(); - let response = response_task.await.unwrap(); - close_upstream.send(()).unwrap(); - let body = response_text(response).await; - let event = latest_response_event_log(&pool).await; - let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); - - assert!(body.contains("stream_disconnected")); - assert_eq!(event.level, "error"); - assert_eq!( - event.request_id.as_deref(), - Some("req_stream_disconnect_log") - ); - assert_eq!(event.account_id.as_deref(), Some("acct_chat")); - assert_eq!(event.status_code, Some(502)); - assert_eq!(metadata["stream"], true); - assert_eq!(metadata["failed"], true); - assert_eq!(metadata["upstreamCode"], "stream_disconnected"); - assert_rate_limit_header(&metadata, "retry-after", "11"); - assert_rate_limit_header(&metadata, "x-codex-primary-used-percent", "88"); -} - -#[tokio::test] -async fn responses_stream_should_forward_first_chunk_before_upstream_completes() { - let first_frame = r#"event: response.output_text.delta -data: {"delta":"live stream hello"} - -"#; - let final_frame = r#"event: response.completed -data: {"response":{"id":"resp_live_stream","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":4}}} - -"#; - let (base_url, first_chunk_sent, finish_upstream) = - spawn_chunked_sse_upstream(first_frame, final_frame).await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response_task = tokio::spawn(async move { - app.oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap() - }); - - first_chunk_sent.await.unwrap(); - let response = timeout(StdDuration::from_millis(300), response_task) - .await - .expect("stream response should be returned before upstream completes") - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let mut body_stream = response.into_body().into_data_stream(); - let first_chunk = timeout(StdDuration::from_millis(300), body_stream.next()) - .await - .expect("first proxied SSE chunk should arrive before upstream completes") - .unwrap() - .unwrap(); - let first_chunk = String::from_utf8(first_chunk.to_vec()).unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(first_chunk.contains("live stream hello")); - assert!(!first_chunk.contains("resp_live_stream")); - - finish_upstream.send(()).unwrap(); - let mut rest = Vec::new(); - while let Some(chunk) = body_stream.next().await { - rest.extend_from_slice(&chunk.unwrap()); - } - let rest = String::from_utf8(rest).unwrap(); - let usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert!(rest.contains("resp_live_stream")); - assert!( - rest.ends_with("data: [DONE]\n\n"), - "stream responses should terminate clients, body was {rest:?}" - ); - assert_eq!(usage, (1, 3, 4)); -} - -#[tokio::test] -async fn responses_stream_should_emit_failed_event_after_upstream_read_error_once_downstream_started( -) { - let first_frame = r#"event: response.output_text.delta -data: {"delta":"partial before transport failure"} - -"#; - let (base_url, first_chunk_sent, close_upstream) = - spawn_chunked_sse_upstream_then_abrupt_close(first_frame).await; - - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response_task = tokio::spawn(async move { - app.oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Start then fail"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap() - }); - - first_chunk_sent.await.unwrap(); - let response = timeout(StdDuration::from_millis(300), response_task) - .await - .expect("stream response should be returned before upstream closes") - .unwrap(); - let mut body_stream = response.into_body().into_data_stream(); - let first_chunk = timeout(StdDuration::from_millis(300), body_stream.next()) - .await - .expect("first proxied SSE chunk should arrive before upstream closes") - .unwrap() - .unwrap(); - assert!(String::from_utf8(first_chunk.to_vec()) - .unwrap() - .contains("partial before transport failure")); - - close_upstream.send(()).unwrap(); - let rest = collect_stream_body(body_stream).await; - - assert!(rest.contains("event: response.failed")); - assert!(rest.contains("stream_disconnected")); - assert!(rest.ends_with("data: [DONE]\n\n")); -} - -#[tokio::test] -async fn responses_stream_should_emit_failed_event_when_upstream_closes_without_completed() { - let first_frame = r#"event: response.output_text.delta -data: {"delta":"partial before clean close"} - -"#; - let (base_url, first_chunk_sent, close_upstream) = - spawn_chunked_sse_upstream_then_clean_close(first_frame).await; - - let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; - let response_task = tokio::spawn(async move { - app.oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Start then close"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap() - }); - - first_chunk_sent.await.unwrap(); - let response = timeout(StdDuration::from_millis(300), response_task) - .await - .expect("stream response should be returned before upstream closes") - .unwrap(); - let mut body_stream = response.into_body().into_data_stream(); - let first_chunk = timeout(StdDuration::from_millis(300), body_stream.next()) - .await - .expect("first proxied SSE chunk should arrive before upstream closes") - .unwrap() - .unwrap(); - assert!(String::from_utf8(first_chunk.to_vec()) - .unwrap() - .contains("partial before clean close")); - - close_upstream.send(()).unwrap(); - let rest = collect_stream_body(body_stream).await; - - assert!(rest.contains("event: response.failed")); - assert!(rest.contains("stream_disconnected")); - assert!(rest.ends_with("data: [DONE]\n\n")); -} - -#[tokio::test] -async fn responses_stream_should_fallback_to_next_account_after_rate_limit() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(429) - .insert_header("retry-after", "90") - .set_body_json(json!({ - "error": {"message": "rate limited"} - })), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_429_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let primary_quota_state: (i64, Option) = sqlx::query_as( - "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", - ) - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let primary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let secondary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_429")); - assert_eq!(primary_quota_state.0, 1); - assert!(primary_quota_state.1.is_some()); - assert_eq!(primary_usage, (1, 0, 0)); - assert_eq!(secondary_usage, (1, 4, 1)); -} - -#[tokio::test] -async fn responses_stream_should_mark_expired_after_401_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"code": "token_revoked", "message": "token revoked"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_401_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let secondary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_401")); - assert_eq!(account_status.0, "expired"); - assert_eq!(secondary_usage, (1, 4, 2)); -} - -#[tokio::test] -async fn responses_stream_should_return_auth_error_when_401_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": {"code": "token_revoked", "message": "token revoked"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.failed")); - assert!(body.contains("All accounts exhausted (1 expired)")); - assert!(body.contains("token_revoked")); - assert_eq!(account_status.0, "expired"); -} - -#[tokio::test] -async fn responses_stream_should_mark_banned_after_403_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({ - "error": {"message": "account banned"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_403_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_403")); - assert_eq!(account_status.0, "banned"); -} - -#[tokio::test] -async fn responses_stream_should_cool_down_cloudflare_403_and_fallback() { - let started_at = Utc::now(); - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(403).set_body_string( - "Just a moment...cf_chl challenge", - )) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_CLOUDFLARE_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_primary", "cf_clearance=old") - .await - .unwrap(); - cookie_store - .set_cookie_header("acct_secondary", "cf_clearance=keep") - .await - .unwrap(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let primary_state: (String, Option) = - sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap()) - .unwrap() - .with_timezone(&Utc); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_cloudflare")); - assert_eq!(primary_state.0, "active"); - assert!(cooldown_until > started_at); - assert_eq!( - cookie_store - .cookie_header("acct_primary", "chatgpt.com") - .await - .unwrap(), - None - ); - assert_eq!( - cookie_store - .cookie_header("acct_secondary", "chatgpt.com") - .await - .unwrap() - .as_deref(), - Some("cf_clearance=keep") - ); -} - -#[tokio::test] -async fn responses_stream_should_return_cloudflare_path_block_error_when_404_fallback_is_exhausted() -{ - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); - cookie_store - .set_cookie_header("acct_chat", "cf_clearance=old") - .await - .unwrap(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.failed")); - assert!(body.contains("All accounts exhausted (1 cloudflare-path-block)")); - assert!(body.contains("Cloudflare path-block")); - assert_eq!( - cookie_store - .cookie_header("acct_chat", "chatgpt.com") - .await - .unwrap(), - None - ); -} - -#[tokio::test] -async fn responses_should_retry_same_account_after_5xx_before_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": {"message": "temporary upstream failure"} - }))) - .up_to_n_times(2) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_5XX_RETRY_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let authorizations = received_authorizations(&server).await; - let primary_requests = authorizations - .iter() - .filter(|authorization| authorization.as_str() == "Bearer access-primary") - .count(); - let secondary_requests = authorizations - .iter() - .filter(|authorization| authorization.as_str() == "Bearer access-secondary") - .count(); - - assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}"); - assert_eq!(body["id"], "resp_after_5xx_retry"); - assert_eq!(primary_requests, 3, "requests: {authorizations:?}"); - assert_eq!(secondary_requests, 0, "requests: {authorizations:?}"); -} - -#[tokio::test] -async fn responses_stream_should_retry_same_account_after_5xx_before_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": {"message": "temporary upstream failure"} - }))) - .up_to_n_times(2) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_5XX_RETRY_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let authorizations = received_authorizations(&server).await; - let primary_requests = authorizations - .iter() - .filter(|authorization| authorization.as_str() == "Bearer access-primary") - .count(); - let secondary_requests = authorizations - .iter() - .filter(|authorization| authorization.as_str() == "Bearer access-secondary") - .count(); - - assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}"); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_5xx_retry")); - assert_eq!(primary_requests, 3, "requests: {authorizations:?}"); - assert_eq!(secondary_requests, 0, "requests: {authorizations:?}"); -} - -#[tokio::test] -async fn responses_should_mark_quota_exhausted_after_402_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(402).set_body_json(json!({ - "error": {"message": "quota exhausted"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_402_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let failed_usage: Option<(i64,)> = - sqlx::query_as("select request_count from account_usage where account_id = ?") - .bind("acct_primary") - .fetch_optional(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_402"); - assert_eq!(account_status.0, "quota_exhausted"); - assert_eq!(failed_usage.map(|row| row.0).unwrap_or_default(), 1); -} - -#[tokio::test] -async fn responses_stream_should_mark_quota_exhausted_after_402_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with(ResponseTemplate::new(402).set_body_json(json!({ - "error": {"message": "quota exhausted"} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_STREAM_AFTER_402_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - let secondary_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", - ) - .bind("acct_secondary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("resp_stream_after_402")); - assert_eq!(account_status.0, "quota_exhausted"); - assert_eq!(secondary_usage, (1, 6, 3)); -} - -#[tokio::test] -async fn responses_should_return_quota_error_when_402_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(402).set_body_json(json!({ - "error": {"message": "quota reached"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - let message = body["error"]["message"].as_str().unwrap_or_default(); - - assert_eq!(status, StatusCode::PAYMENT_REQUIRED); - assert!(message.contains("All accounts exhausted (1 quota-exhausted)")); - assert!(message.contains("quota reached")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(account_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn responses_should_return_rate_limit_error_when_429_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "error": {"message": "rate limited"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let quota_state: (i64, Option) = sqlx::query_as( - "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - let message = body["error"]["message"].as_str().unwrap_or_default(); - - assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); - assert!(message.contains("All accounts exhausted (1 rate-limited)")); - assert!(message.contains("rate limited")); - assert_eq!(body["error"]["code"], "upstream_error"); - assert_eq!(quota_state.0, 1); - assert!(quota_state.1.is_some()); -} - -#[tokio::test] -async fn responses_stream_should_return_rate_limit_error_when_429_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "error": {"message": "rate limited"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let quota_state: (i64, Option) = sqlx::query_as( - "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", - ) - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.failed")); - assert!(body.contains("rate_limit_exceeded")); - assert!(body.contains("All accounts exhausted (1 rate-limited)")); - assert!(body.contains("rate limited")); - assert_eq!(quota_state.0, 1); - assert!(quota_state.1.is_some()); -} - -#[tokio::test] -async fn responses_stream_should_return_quota_error_when_402_fallback_is_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(402).set_body_json(json!({ - "error": {"message": "quota reached"} - }))) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": true, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let body = response_text(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_chat") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(body.contains("event: response.failed")); - assert!(body.contains("All accounts exhausted (1 quota-exhausted)")); - assert!(body.contains("quota reached")); - assert_eq!(account_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn responses_should_classify_sse_quota_failure_and_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-primary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_FAILED_QUOTA_SSE), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secondary")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_AFTER_402_SSE), - ) - .expect(1) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let body = response_json(response).await; - let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") - .bind("acct_primary") - .fetch_one(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], "resp_after_402"); - assert_eq!(account_status.0, "quota_exhausted"); -} - -#[tokio::test] -async fn responses_should_record_request_count_when_5xx_retries_are_exhausted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": {"message": "temporary upstream failure"} - }))) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let authorizations = received_authorizations(&server).await; - let failed_usage: Option<(i64,)> = - sqlx::query_as("select request_count from account_usage where account_id = ?") - .bind("acct_chat") - .fetch_optional(&pool) - .await - .unwrap(); - - assert_eq!(status, StatusCode::BAD_GATEWAY); - assert_eq!(authorizations.len(), 3, "requests: {authorizations:?}"); - assert_eq!(failed_usage.map(|row| row.0).unwrap_or_default(), 1); -} - -#[tokio::test] -async fn responses_should_prefer_session_affinity_account_for_previous_response() { - let (base_url, upstream) = spawn_single_websocket_completed_upstream("resp_affinity_ws").await; - let (app, api_key, _dir) = test_app_with_two_accounts_and_affinity(base_url).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "previous_response_id": "resp_previous", - "input": [{"role": "user", "content": "Continue"}], - "stream": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let captured = upstream.await.unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - captured_header(&captured.headers, "authorization"), - Some("Bearer access-affinity") - ); - assert_eq!( - captured_header(&captured.headers, "chatgpt-account-id"), - Some("chatgpt-affinity") - ); - assert_eq!(captured.payload["previous_response_id"], "resp_previous"); -} - -#[tokio::test] -async fn responses_with_previous_response_id_should_use_websocket_and_configured_pool() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let accepted_connections = Arc::new(AtomicUsize::new(0)); - let accepted_connections_for_server = Arc::clone(&accepted_connections); - let server = tokio::spawn(async move { - let (first_stream, _) = listener.accept().await.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_async(first_stream).await.unwrap(); - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - response_completed_websocket_message("resp_runtime_pool_first").into(), - )) - .await - .unwrap(); - - tokio::select! { - second_message = first_websocket.next() => { - let _second_message = second_message.unwrap().unwrap(); - first_websocket - .send(Message::Text( - response_completed_websocket_message("resp_runtime_pool_second").into(), - )) - .await - .unwrap(); - first_websocket.close(None).await.unwrap(); - } - accepted = listener.accept() => { - let (second_stream, _) = accepted.unwrap(); - accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut second_websocket = accept_async(second_stream).await.unwrap(); - let _second_message = second_websocket.next().await.unwrap().unwrap(); - second_websocket - .send(Message::Text( - response_completed_websocket_message("resp_runtime_pool_second").into(), - )) - .await - .unwrap(); - second_websocket.close(None).await.unwrap(); - first_websocket.close(None).await.unwrap(); - } - } - }); - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let first = app - .clone() - .oneshot(responses_previous_request( - &api_key, - "Continue from pooled runtime websocket", - )) - .await - .unwrap(); - let second = app - .oneshot(responses_previous_request( - &api_key, - "Continue again from pooled runtime websocket", - )) - .await - .unwrap(); - server.await.unwrap(); - - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::OK); - assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn responses_stream_with_previous_response_id_should_forward_websocket_chunks_before_completion( -) { - let (base_url, first_chunk_sent_rx, finish_tx, upstream) = - spawn_chunked_websocket_upstream().await; - let (app, api_key, _dir) = test_app_with_account(base_url).await; - - let response = app - .oneshot(responses_previous_stream_request( - &api_key, - "Continue as a WebSocket stream", - )) - .await - .unwrap(); - let status = response.status(); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - first_chunk_sent_rx - .await - .expect("upstream should send the first websocket event"); - let first_chunk = timeout( - StdDuration::from_secs(1), - first_response_body_chunk(response), - ) - .await - .expect("downstream should receive a websocket-backed SSE chunk before upstream completes") - .expect("response body should produce a chunk"); - finish_tx - .send(()) - .expect("test should be able to finish upstream websocket"); - let captured = upstream.await.unwrap(); - - assert_eq!(status, StatusCode::OK); - assert!(content_type.starts_with("text/event-stream")); - assert!(first_chunk.contains("event: response.output_text.delta")); - assert_eq!( - captured_header(&captured.headers, "authorization"), - Some("Bearer access-secret") - ); - assert_eq!( - captured.payload["previous_response_id"], - "resp_runtime_pool_previous" - ); -} - -#[tokio::test] -async fn responses_stream_with_previous_response_id_should_record_websocket_audit_metadata() { - let (base_url, first_chunk_sent_rx, finish_tx, upstream) = - spawn_chunked_websocket_upstream().await; - let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(base_url).await; - - let response = app - .oneshot(responses_previous_stream_request( - &api_key, - "Continue as a logged WebSocket stream", - )) - .await - .unwrap(); - first_chunk_sent_rx - .await - .expect("upstream should send the first websocket event"); - finish_tx - .send(()) - .expect("test should be able to finish upstream websocket"); - let body = response_text(response).await; - let captured = upstream.await.unwrap(); - let event = latest_response_event_log(&pool).await; - let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); - - assert!(body.contains("resp_live_websocket_stream")); - assert_eq!( - captured.payload["previous_response_id"], - "resp_runtime_pool_previous" - ); - assert_eq!(event.level, "info"); - assert_eq!(metadata["stream"], true); - assert_eq!(metadata["transport"], "websocket"); - assert_eq!(metadata["usage"]["inputTokens"], 3); - assert_eq!(metadata["usage"]["outputTokens"], 1); - assert_rate_limit_header(&metadata, "x-codex-primary-used-percent", "44"); - assert_rate_limit_header(&metadata, "x-codex-primary-window-minutes", "5"); -} - -#[tokio::test] -async fn responses_should_record_session_affinity_for_completed_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/responses")) - .and(header("authorization", "Bearer access-secret")) - .and(header("chatgpt-account-id", "chatgpt-account")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(RESPONSES_SUCCESS_SSE), - ) - .mount(&server) - .await; - - let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", format!("Bearer {api_key}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "gpt-5.5", - "input": [{"role": "user", "content": "Say hello"}], - "stream": false, - "use_websocket": false - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let records = SqliteSessionAffinityStore::new(pool) - .list_active(Utc::now()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(records.len(), 1); - assert_eq!(records[0].response_id, "resp_response_1"); - assert_eq!(records[0].entry.account_id, "acct_chat"); -} + include_str!("fixtures/responses/golden/reasoning_replay_request.json"); + +#[path = "openai_chat_upstream/openai_chat_routes.rs"] +mod openai_chat_routes; +#[path = "openai_chat_upstream/openai_compact_routes.rs"] +mod openai_compact_routes; +#[path = "openai_chat_upstream/openai_responses_http.rs"] +mod openai_responses_http; +#[path = "openai_chat_upstream/openai_responses_recovery.rs"] +mod openai_responses_recovery; +#[path = "openai_chat_upstream/openai_responses_websocket.rs"] +mod openai_responses_websocket; +#[path = "openai_chat_upstream/openai_usage_logging.rs"] +mod openai_usage_logging; async fn test_app_with_account(base_url: String) -> (axum::Router, String, tempfile::TempDir) { test_app_with_account_and_installation_id(base_url, TEST_INSTALLATION_ID.to_string()).await diff --git a/crates/server/tests/openai_chat_upstream/openai_chat_routes.rs b/crates/server/tests/openai_chat_upstream/openai_chat_routes.rs new file mode 100644 index 000000000..ae945c2f6 --- /dev/null +++ b/crates/server/tests/openai_chat_upstream/openai_chat_routes.rs @@ -0,0 +1,763 @@ +use super::*; + +#[tokio::test] +async fn chat_completions_should_dispatch_to_codex_and_return_openai_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .and(header("chatgpt-account-id", "chatgpt-account")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Say hello"} + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["object"], "chat.completion"); + assert_eq!(body["model"], "gpt-5.5"); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + assert_eq!(body["usage"]["prompt_tokens"], 9); + assert_eq!(body["usage"]["completion_tokens"], 3); + let usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(usage, (1, 9, 3)); +} + +#[tokio::test] +async fn chat_completions_should_forward_runtime_installation_id_to_codex() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("x-codex-installation-id", TEST_INSTALLATION_ID)) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, _dir) = + test_app_with_account_and_installation_id(server.uri(), TEST_INSTALLATION_ID.to_string()) + .await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [ + {"role": "user", "content": "Say hello"} + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn chat_completions_should_dispatch_from_restored_runtime_account_pool() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .and(header("chatgpt-account-id", "chatgpt-account")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, _dir) = + test_app_with_restored_pool_then_disabled_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [ + {"role": "user", "content": "Say hello"} + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn chat_completions_should_fallback_to_next_account_after_rate_limit() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "120") + .set_body_json(json!({ + "error": { + "message": "rate limited", + "resets_in_seconds": 120 + } + })), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let authorizations = received_authorizations(&server).await; + + assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}"); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + let persisted_quota_state: (i64, Option) = sqlx::query_as( + "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", + ) + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(persisted_quota_state.0, 1); + let cooldown_until = + chrono::DateTime::parse_from_rfc3339(persisted_quota_state.1.as_deref().unwrap()) + .unwrap() + .with_timezone(&Utc); + assert!(cooldown_until > Utc::now()); +} + +#[tokio::test] +async fn chat_completions_should_return_rate_limit_error_when_429_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "error": {"message": "rate limited"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let quota_state: (i64, Option) = sqlx::query_as( + "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + let message = body["error"]["message"].as_str().unwrap_or_default(); + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert!(message.contains("All accounts exhausted (1 rate-limited)")); + assert!(message.contains("rate limited")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(quota_state.0, 1); + assert!(quota_state.1.is_some()); +} + +#[tokio::test] +async fn chat_completions_should_fallback_after_http_model_unsupported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "model_not_supported", + "message": "Model gpt-5.5 is not supported on this account plan" + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + assert_eq!(primary_status.0, "active"); +} + +#[tokio::test] +async fn chat_completions_should_return_model_unsupported_error_when_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "model_not_supported", + "message": "Model gpt-5.5 is not supported on this account plan" + } + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(message.contains("All accounts exhausted (1 model-unsupported)")); + assert!(message.contains("model_not_supported")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(account_status.0, "active"); +} + +#[tokio::test] +async fn chat_completions_should_mark_expired_after_401_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"code": "token_revoked", "message": "token revoked"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + assert_eq!(account_status.0, "expired"); +} + +#[tokio::test] +async fn chat_completions_should_return_auth_error_when_401_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"code": "token_revoked", "message": "token revoked"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(message.contains("All accounts exhausted (1 expired)")); + assert!(message.contains("token_revoked")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(account_status.0, "expired"); +} + +#[tokio::test] +async fn chat_completions_should_mark_banned_when_401_says_account_deactivated() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"message": "account deactivated"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(account_status.0, "banned"); +} + +#[tokio::test] +async fn chat_completions_should_mark_banned_after_403_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "error": {"message": "account banned"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + assert_eq!(account_status.0, "banned"); +} + +#[tokio::test] +async fn chat_completions_should_cool_down_cloudflare_403_and_fallback() { + let started_at = Utc::now(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(403).set_body_string( + "Just a moment...cf_chl challenge", + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_primary", "cf_clearance=old") + .await + .unwrap(); + cookie_store + .set_cookie_header("acct_secondary", "cf_clearance=keep") + .await + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let primary_state: (String, Option) = + sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap()) + .unwrap() + .with_timezone(&Utc); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + assert_eq!(primary_state.0, "active"); + assert!(cooldown_until > started_at); + assert_eq!( + cookie_store + .cookie_header("acct_primary", "chatgpt.com") + .await + .unwrap(), + None + ); + assert_eq!( + cookie_store + .cookie_header("acct_secondary", "chatgpt.com") + .await + .unwrap() + .as_deref(), + Some("cf_clearance=keep") + ); +} + +#[tokio::test] +async fn chat_completions_should_mark_quota_exhausted_after_402_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": {"message": "quota reached"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CHAT_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["choices"][0]["message"]["content"], "hello"); + assert_eq!(account_status.0, "quota_exhausted"); +} + +#[tokio::test] +async fn chat_completions_should_return_quota_error_when_402_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": {"message": "quota reached"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "Say hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + let message = body["error"]["message"].as_str().unwrap_or_default(); + + assert_eq!(status, StatusCode::PAYMENT_REQUIRED); + assert!(message.contains("All accounts exhausted (1 quota-exhausted)")); + assert!(message.contains("quota reached")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(account_status.0, "quota_exhausted"); +} diff --git a/crates/server/tests/openai_chat_upstream/openai_compact_routes.rs b/crates/server/tests/openai_chat_upstream/openai_compact_routes.rs new file mode 100644 index 000000000..2aee53591 --- /dev/null +++ b/crates/server/tests/openai_chat_upstream/openai_compact_routes.rs @@ -0,0 +1,243 @@ +use super::*; + +#[tokio::test] +async fn responses_review_route_should_force_review_subagent_upstream() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("x-openai-subagent", "review")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses/review") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_response_1"); + let requests = server.received_requests().await.unwrap(); + let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!( + upstream_body["client_metadata"]["x-openai-subagent"], + "review" + ); +} + +#[tokio::test] +async fn responses_compact_should_post_json_to_codex_compact_upstream() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses/compact")) + .and(header("authorization", "Bearer access-secret")) + .and(header("chatgpt-account-id", "chatgpt-account")) + .and(header("content-type", "application/json")) + .and(header("openai-beta", "responses_websockets=2026-02-06")) + .and(header("x-openai-internal-codex-residency", "us")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "compacted"}] + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses/compact") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5-fast", + "instructions": "compress the session", + "input": [ + {"role": "user", "content": "hello"}, + { + "type": "reasoning", + "id": "rs_1", + "status": "completed", + "summary": [{"type": "summary_text", "text": "kept"}], + "ignored": "drop" + }, + {"type": "compaction", "encrypted_content": "enc_compact"}, + {"type": "compaction", "id": "drop_missing_encrypted"} + ], + "tools": [{"type": "function", "name": "lookup"}], + "parallel_tool_calls": false, + "reasoning": {"effort": "high", "summary": "auto", "extra": "drop"}, + "text": { + "format": { + "type": "json_schema", + "name": "Compact", + "schema": {"type": "object"}, + "strict": true + } + }, + "stream": true, + "use_websocket": false, + "store": true, + "prompt_cache_key": "must_not_forward" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["output"][0]["content"][0]["text"], "compacted"); + let requests = server.received_requests().await.unwrap(); + let compact_request = requests + .iter() + .find(|request| request.url.path() == "/codex/responses/compact") + .expect("compact upstream request should be sent"); + assert_ne!( + compact_request + .headers + .get("accept") + .and_then(|value| value.to_str().ok()), + Some("text/event-stream") + ); + assert!(compact_request + .headers + .get("x-codex-installation-id") + .is_some()); + let upstream_body: Value = serde_json::from_slice(&compact_request.body).unwrap(); + assert_eq!(upstream_body["model"], "gpt-5.5"); + assert_eq!(upstream_body["instructions"], "compress the session"); + assert_eq!(upstream_body["parallel_tool_calls"], false); + assert_eq!( + upstream_body["reasoning"], + json!({"effort": "high", "summary": "auto"}) + ); + assert_eq!( + upstream_body["tools"], + json!([{"type": "function", "name": "lookup"}]) + ); + assert_eq!(upstream_body["text"]["format"]["type"], "json_schema"); + assert!(upstream_body.get("stream").is_none()); + assert!(upstream_body.get("store").is_none()); + assert!(upstream_body.get("prompt_cache_key").is_none()); + assert_eq!(upstream_body["input"].as_array().unwrap().len(), 3); + assert!(upstream_body["input"][1].get("ignored").is_none()); + assert_eq!( + upstream_body["input"][2]["encrypted_content"], + "enc_compact" + ); +} + +#[tokio::test] +async fn responses_compact_should_return_rate_limit_error_when_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses/compact")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "error": { + "code": "rate_limit_exceeded", + "message": "compact quota reached" + } + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses/compact") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "rate_limit_error"); + assert_eq!(body["error"]["code"], "rate_limit_exceeded"); + assert!(message.contains("All accounts exhausted (1 rate-limited)")); + assert!(message.contains("compact quota reached")); +} + +#[tokio::test] +async fn responses_compact_should_return_responses_error_when_no_accounts_are_available() { + let server = MockServer::start().await; + let (app, api_key, _dir) = test_app_without_accounts(server.uri()).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses/compact") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "hello"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!(body["error"]["code"], "no_available_accounts"); +} diff --git a/crates/server/tests/openai_chat_upstream/openai_responses_http.rs b/crates/server/tests/openai_chat_upstream/openai_responses_http.rs new file mode 100644 index 000000000..72dd6f73b --- /dev/null +++ b/crates/server/tests/openai_chat_upstream/openai_responses_http.rs @@ -0,0 +1,1097 @@ +use super::*; + +#[tokio::test] +async fn responses_should_reject_invalid_json_without_upstream_request() { + let server = MockServer::start().await; + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from("{")) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let requests = server.received_requests().await.unwrap(); + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_request"); + assert!(requests.is_empty()); +} + +#[tokio::test] +async fn responses_should_reject_non_object_json_without_upstream_request() { + let server = MockServer::start().await; + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from("[]")) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let requests = server.received_requests().await.unwrap(); + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_request"); + assert!(requests.is_empty()); +} + +#[tokio::test] +async fn responses_should_honor_explicit_http_sse_transport() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_text(response).await; + let requests = server.received_requests().await.unwrap(); + let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(body.contains("event: response.output_text.delta")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "POST"); + assert_eq!(upstream_body["model"], "gpt-5.5"); + assert!(upstream_body.get("use_websocket").is_none()); +} + +#[tokio::test] +async fn responses_should_stagger_same_account_requests_before_sending_upstream() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let request_times = Arc::new(Mutex::new(Vec::new())); + let request_times_for_server = Arc::clone(&request_times); + let (first_seen_tx, first_seen_rx) = oneshot::channel(); + let (release_first_tx, release_first_rx) = oneshot::channel(); + let upstream = tokio::spawn(async move { + let (mut first_socket, _) = listener.accept().await.unwrap(); + request_times_for_server + .lock() + .unwrap() + .push(Instant::now()); + first_seen_tx.send(()).unwrap(); + read_http_request(&mut first_socket).await; + + let (mut second_socket, _) = listener.accept().await.unwrap(); + request_times_for_server + .lock() + .unwrap() + .push(Instant::now()); + read_http_request(&mut second_socket).await; + write_http_sse_response(&mut second_socket, RESPONSES_COMPLETED_USAGE_SSE).await; + + release_first_rx.await.unwrap(); + write_http_sse_response(&mut first_socket, RESPONSES_COMPLETED_USAGE_SSE).await; + }); + + let (app, api_key, _pool, _dir) = test_app_with_account_pool_config(base_url, |config| { + config.auth.max_concurrent_per_account = 2; + config.auth.request_interval_ms = 300; + }) + .await; + let first_app = app.clone(); + let first_api_key = api_key.clone(); + let first_response = tokio::spawn(async move { + first_app + .oneshot(responses_http_sse_request( + &first_api_key, + "req_stagger_first", + )) + .await + .unwrap() + }); + first_seen_rx.await.unwrap(); + + let second = app + .clone() + .oneshot(responses_http_sse_request(&api_key, "req_stagger_second")) + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::OK); + release_first_tx.send(()).unwrap(); + let first = first_response.await.unwrap(); + assert_eq!(first.status(), StatusCode::OK); + upstream.await.unwrap(); + + let times = request_times.lock().unwrap(); + assert_eq!(times.len(), 2); + let elapsed = times[1].duration_since(times[0]); + assert!( + elapsed >= StdDuration::from_millis(180), + "second upstream request was sent too early: {elapsed:?}" + ); +} + +#[tokio::test] +async fn responses_should_use_websocket_upstream_by_default_while_serving_sse() { + let (base_url, upstream) = spawn_single_websocket_completed_upstream("resp_ws_default").await; + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "generate": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.completed")); + assert!(body.contains("\"id\":\"resp_ws_default\"")); + assert_eq!(captured.payload["type"], "response.create"); + assert_eq!(captured.payload["model"], "gpt-5.5"); + assert_eq!(captured.payload["generate"], false); + assert!(captured.payload.get("previous_response_id").is_none()); + assert!(captured.payload["prompt_cache_key"] + .as_str() + .is_some_and(|value| value.starts_with("cp_"))); + assert!(captured.payload["client_metadata"]["x-codex-installation-id"].is_string()); + assert!(captured.payload["client_metadata"]["x-codex-window-id"] + .as_str() + .is_some_and(|value| value.starts_with("cp_") && value.ends_with(":0"))); +} + +#[tokio::test] +async fn responses_should_ignore_camel_case_use_websocket_field() { + let (base_url, upstream) = + spawn_single_websocket_completed_upstream("resp_ws_camel_case").await; + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "useWebSocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let captured = upstream.await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(captured.payload["type"], "response.create"); + assert!(captured.payload.get("useWebSocket").is_none()); + assert!(captured.payload.get("use_websocket").is_none()); +} + +#[tokio::test] +async fn responses_should_convert_tuple_schema_before_upstream() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_TUPLE_OBJECT_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from(tuple_response_request_body(false))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + + assert_eq!(status, StatusCode::OK); + let requests = server.received_requests().await.unwrap(); + let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + let schema = &upstream_body["text"]["format"]["schema"]; + assert!(schema["properties"]["point"].get("prefixItems").is_none()); + assert!(schema["properties"]["point"].get("items").is_none()); + assert_eq!( + schema["properties"]["point"], + json!({ + "type": "object", + "properties": { + "0": {"type": "number"}, + "1": {"type": "number"} + }, + "required": ["0", "1"], + "additionalProperties": false + }) + ); +} + +#[tokio::test] +async fn responses_should_reconvert_tuple_schema_output_for_client() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_TUPLE_OBJECT_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from(tuple_response_request_body(false))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_tuple"); + assert_eq!(body["output_text"], "{\"point\":[1,2]}"); + assert_eq!(body["output"][0]["content"][0]["text"], "{\"point\":[1,2]}"); +} + +#[tokio::test] +async fn responses_stream_should_reconvert_tuple_schema_output_for_client() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_TUPLE_OBJECT_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from(tuple_response_request_body(true))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_text(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(body.contains(r#""delta":"{\"point\":[1,2]}""#)); + assert!(body.contains(r#""output_text":"{\"point\":[1,2]}""#)); + assert!(!body.contains(r#""point\":{\"0":1,"1":2}"#)); + assert!(body.ends_with("data: [DONE]\n\n")); +} + +#[tokio::test] +async fn responses_should_forward_parity_fields_context_headers_and_account_scoped_identity() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-codex-turn-state", "turn-header") + .header("x-codex-turn-metadata", "meta-header") + .header("x-codex-beta-features", "beta-header") + .header("x-responsesapi-include-timing-metrics", "false") + .header("version", "header-version") + .header("x-codex-window-id", "window-header") + .header("x-codex-parent-thread-id", "parent-header") + .header("x-openai-subagent", "review") + .body(Body::from( + json!({ + "model": "gpt-5.5-high-fast", + "stream": false, + "use_websocket": false, + "input": [], + "prompt_cache_key": "pcache", + "client_metadata": {"safe": "yes", "drop": 42}, + "turnState": "turn-body", + "turnMetadata": "meta-body", + "betaFeatures": "beta-body", + "includeTimingMetrics": "true", + "version": "2026-06-12", + "codexWindowId": "window-body", + "parentThreadId": "parent-body", + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_response_1"); + let requests = server.received_requests().await.unwrap(); + let upstream = requests + .iter() + .find(|request| request.url.path() == "/codex/responses") + .expect("responses upstream request should be sent"); + let upstream_body: Value = serde_json::from_slice(&upstream.body).unwrap(); + let identity = build_conversation_identity(Some("pcache"), Some("window-body"), "acct_chat"); + let conversation_id = identity + .conversation_id + .as_deref() + .expect("conversation identity should be scoped"); + let window_id = identity + .window_id + .as_deref() + .expect("window identity should be scoped"); + let upstream_header = |name: &str| { + upstream + .headers + .get(name) + .and_then(|value| value.to_str().ok()) + }; + + assert_eq!(upstream_body["model"], "gpt-5.5"); + assert_eq!(upstream_body["service_tier"], "priority"); + assert_eq!( + upstream_body["reasoning"], + json!({"summary": "auto", "effort": "high"}) + ); + assert_eq!( + upstream_body["include"], + json!(["reasoning.encrypted_content"]) + ); + assert_eq!(upstream_body["prompt_cache_key"], conversation_id); + assert_eq!(upstream_body["client_metadata"]["safe"], "yes"); + assert_eq!( + upstream_body["client_metadata"]["x-openai-subagent"], + "review" + ); + assert_eq!( + upstream_body["client_metadata"]["x-codex-installation-id"], + TEST_INSTALLATION_ID + ); + assert_eq!( + upstream_body["client_metadata"]["x-codex-window-id"], + window_id + ); + assert_eq!( + upstream_body["client_metadata"]["x-codex-turn-metadata"], + "meta-body" + ); + assert_eq!( + upstream_body["client_metadata"]["x-codex-parent-thread-id"], + "parent-body" + ); + assert_eq!(upstream_header("session_id"), Some(conversation_id)); + assert_eq!(upstream_header("x-codex-window-id"), Some(window_id)); + assert_eq!(upstream_header("x-codex-turn-state"), Some("turn-body")); + assert_eq!(upstream_header("x-codex-turn-metadata"), Some("meta-body")); + assert_eq!(upstream_header("x-codex-beta-features"), Some("beta-body")); + assert_eq!( + upstream_header("x-responsesapi-include-timing-metrics"), + Some("true") + ); + assert_eq!(upstream_header("version"), Some("2026-06-12")); + assert_eq!( + upstream_header("x-codex-parent-thread-id"), + Some("parent-body") + ); + assert_eq!(upstream_header("x-openai-subagent"), Some("review")); + for local_field in [ + "turnState", + "turnMetadata", + "betaFeatures", + "includeTimingMetrics", + "version", + "codexWindowId", + "parentThreadId", + "use_websocket", + ] { + assert!(upstream_body.get(local_field).is_none()); + } +} + +#[tokio::test] +async fn responses_should_preserve_non_empty_include_when_reasoning_defaults_apply() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5-high", + "stream": false, + "use_websocket": false, + "input": [], + "include": ["file_search_call.results"], + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let requests = server.received_requests().await.unwrap(); + let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!( + upstream_body["reasoning"], + json!({"summary": "auto", "effort": "high"}) + ); + assert_eq!( + upstream_body["include"], + json!(["file_search_call.results"]) + ); +} + +#[tokio::test] +async fn responses_should_sanitize_reasoning_and_compaction_input_before_upstream() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "stream": false, + "use_websocket": false, + "input": [ + { + "type": "reasoning", + "id": "rs_1", + "status": "completed", + "summary": [ + {"type": "summary_text", "text": "valid summary"}, + {"type": "ignored", "text": "drop"} + ], + "encrypted_content": "enc_reasoning", + "content": [ + {"type": "reasoning_text", "text": "valid reasoning"}, + {"type": "ignored", "text": "drop"} + ], + "extra": "drop" + }, + { + "type": "reasoning", + "id": "", + "summary": [{"type": "summary_text", "text": "drop"}] + }, + { + "type": "compaction", + "id": "cmp_1", + "encrypted_content": "enc_compaction", + "extra": "drop" + }, + {"type": "compaction", "id": "cmp_drop"}, + {"type": "message", "role": "user", "content": "keep me", "extra": 42} + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let requests = server.received_requests().await.unwrap(); + let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!( + upstream_body["input"], + json!([ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "valid summary"}], + "status": "completed", + "encrypted_content": "enc_reasoning", + "content": [{"type": "reasoning_text", "text": "valid reasoning"}] + }, + { + "type": "compaction", + "encrypted_content": "enc_compaction", + "id": "cmp_1" + }, + {"type": "message", "role": "user", "content": "keep me", "extra": 42} + ]) + ); +} + +#[tokio::test] +async fn responses_should_reconstruct_non_stream_output_text_from_sse_deltas() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_TEXT_DELTAS_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + + assert_eq!(body["id"], "resp_text"); + assert_eq!(body["output"][0]["role"], "assistant"); + assert_eq!(body["output"][0]["content"][0]["text"], "hello from deltas"); + assert_eq!(body["output_text"], "hello from deltas"); +} + +#[tokio::test] +async fn responses_should_use_done_output_items_when_completed_output_is_empty() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_DONE_ITEM_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + + assert_eq!(body["id"], "resp_item"); + assert_eq!(body["output"][0]["content"][0]["text"], "from done item"); + assert_eq!(body["output_text"], "from done item"); +} + +#[tokio::test] +async fn responses_should_scope_upstream_cookie_by_codex_response_path() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .capture_set_cookie( + "acct_chat", + "cf_clearance=root; Domain=.chatgpt.com; Path=/", + ) + .await + .unwrap(); + cookie_store + .capture_set_cookie( + "acct_chat", + "cf_clearance=codex; Domain=.chatgpt.com; Path=/codex", + ) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let requests = server.received_requests().await.unwrap(); + let cookie_header = requests + .iter() + .find(|request| request.url.path() == "/codex/responses") + .and_then(|request| request.headers.get("cookie")) + .and_then(|value| value.to_str().ok()); + assert_eq!(cookie_header, Some("cf_clearance=codex; cf_clearance=root")); +} + +#[tokio::test] +async fn responses_stream_should_close_http_sse_upstream_when_client_disconnects() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + read_http_request(&mut socket).await; + write_chunked_http_sse_headers(&mut socket).await; + write_http_chunk( + &mut socket, + b"event: response.output_text.delta\ndata: {\"delta\":\"hello\"}\n\n", + ) + .await; + socket.flush().await.unwrap(); + + timeout( + StdDuration::from_secs(2), + wait_for_http_sse_upstream_disconnect(&mut socket), + ) + .await + .is_ok() + }); + + let (app, api_key, _dir) = test_app_with_account(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body().into_data_stream(); + let first_chunk = timeout(StdDuration::from_secs(1), body.next()) + .await + .expect("first SSE chunk should arrive before disconnect") + .expect("stream should yield a first chunk") + .expect("chunk should be readable"); + assert!(String::from_utf8(first_chunk.to_vec()) + .unwrap() + .contains("event: response.output_text.delta")); + + drop(body); + assert!( + upstream.await.unwrap(), + "dropping the downstream stream should close the HTTP SSE upstream socket" + ); +} + +#[tokio::test] +async fn responses_stream_should_forward_first_chunk_before_upstream_completes() { + let first_frame = r#"event: response.output_text.delta +data: {"delta":"live stream hello"} + +"#; + let final_frame = r#"event: response.completed +data: {"response":{"id":"resp_live_stream","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":4}}} + +"#; + let (base_url, first_chunk_sent, finish_upstream) = + spawn_chunked_sse_upstream(first_frame, final_frame).await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response_task = tokio::spawn(async move { + app.oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() + }); + + first_chunk_sent.await.unwrap(); + let response = timeout(StdDuration::from_millis(300), response_task) + .await + .expect("stream response should be returned before upstream completes") + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let mut body_stream = response.into_body().into_data_stream(); + let first_chunk = timeout(StdDuration::from_millis(300), body_stream.next()) + .await + .expect("first proxied SSE chunk should arrive before upstream completes") + .unwrap() + .unwrap(); + let first_chunk = String::from_utf8(first_chunk.to_vec()).unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(first_chunk.contains("live stream hello")); + assert!(!first_chunk.contains("resp_live_stream")); + + finish_upstream.send(()).unwrap(); + let mut rest = Vec::new(); + while let Some(chunk) = body_stream.next().await { + rest.extend_from_slice(&chunk.unwrap()); + } + let rest = String::from_utf8(rest).unwrap(); + let usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert!(rest.contains("resp_live_stream")); + assert!( + rest.ends_with("data: [DONE]\n\n"), + "stream responses should terminate clients, body was {rest:?}" + ); + assert_eq!(usage, (1, 3, 4)); +} + +#[tokio::test] +async fn responses_stream_should_emit_failed_event_after_upstream_read_error_once_downstream_started( +) { + let first_frame = r#"event: response.output_text.delta +data: {"delta":"partial before transport failure"} + +"#; + let (base_url, first_chunk_sent, close_upstream) = + spawn_chunked_sse_upstream_then_abrupt_close(first_frame).await; + + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response_task = tokio::spawn(async move { + app.oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Start then fail"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() + }); + + first_chunk_sent.await.unwrap(); + let response = timeout(StdDuration::from_millis(300), response_task) + .await + .expect("stream response should be returned before upstream closes") + .unwrap(); + let mut body_stream = response.into_body().into_data_stream(); + let first_chunk = timeout(StdDuration::from_millis(300), body_stream.next()) + .await + .expect("first proxied SSE chunk should arrive before upstream closes") + .unwrap() + .unwrap(); + assert!(String::from_utf8(first_chunk.to_vec()) + .unwrap() + .contains("partial before transport failure")); + + close_upstream.send(()).unwrap(); + let rest = collect_stream_body(body_stream).await; + + assert!(rest.contains("event: response.failed")); + assert!(rest.contains("stream_disconnected")); + assert!(rest.ends_with("data: [DONE]\n\n")); +} + +#[tokio::test] +async fn responses_stream_should_emit_failed_event_when_upstream_closes_without_completed() { + let first_frame = r#"event: response.output_text.delta +data: {"delta":"partial before clean close"} + +"#; + let (base_url, first_chunk_sent, close_upstream) = + spawn_chunked_sse_upstream_then_clean_close(first_frame).await; + + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response_task = tokio::spawn(async move { + app.oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Start then close"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() + }); + + first_chunk_sent.await.unwrap(); + let response = timeout(StdDuration::from_millis(300), response_task) + .await + .expect("stream response should be returned before upstream closes") + .unwrap(); + let mut body_stream = response.into_body().into_data_stream(); + let first_chunk = timeout(StdDuration::from_millis(300), body_stream.next()) + .await + .expect("first proxied SSE chunk should arrive before upstream closes") + .unwrap() + .unwrap(); + assert!(String::from_utf8(first_chunk.to_vec()) + .unwrap() + .contains("partial before clean close")); + + close_upstream.send(()).unwrap(); + let rest = collect_stream_body(body_stream).await; + + assert!(rest.contains("event: response.failed")); + assert!(rest.contains("stream_disconnected")); + assert!(rest.ends_with("data: [DONE]\n\n")); +} + +#[tokio::test] +async fn responses_should_prefer_session_affinity_account_for_previous_response() { + let (base_url, upstream) = spawn_single_websocket_completed_upstream("resp_affinity_ws").await; + let (app, api_key, _dir) = test_app_with_two_accounts_and_affinity(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_previous", + "input": [{"role": "user", "content": "Continue"}], + "stream": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let captured = upstream.await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + captured_header(&captured.headers, "authorization"), + Some("Bearer access-affinity") + ); + assert_eq!( + captured_header(&captured.headers, "chatgpt-account-id"), + Some("chatgpt-affinity") + ); + assert_eq!(captured.payload["previous_response_id"], "resp_previous"); +} diff --git a/crates/server/tests/openai_chat_upstream/openai_responses_recovery.rs b/crates/server/tests/openai_chat_upstream/openai_responses_recovery.rs new file mode 100644 index 000000000..9c6fe0663 --- /dev/null +++ b/crates/server/tests/openai_chat_upstream/openai_responses_recovery.rs @@ -0,0 +1,2232 @@ +use super::*; + +#[tokio::test] +async fn responses_should_dispatch_to_codex_and_return_completed_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .and(header("chatgpt-account-id", "chatgpt-account")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_response_1"); + assert_eq!(body["output_text"], "response hello"); + assert_eq!(body["usage"]["input_tokens"], 5); + assert_eq!(body["usage"]["output_tokens"], 2); +} + +#[tokio::test] +async fn responses_should_return_no_available_accounts_error_when_no_accounts_are_available() { + let server = MockServer::start().await; + let (app, api_key, _dir) = test_app_without_accounts(server.uri()).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!(body["error"]["code"], "no_available_accounts"); +} + +#[tokio::test] +async fn responses_should_fallback_to_next_account_after_rate_limit() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "120") + .set_body_json(json!({ + "error": { + "message": "rate limited", + "resets_in_seconds": 120 + } + })), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_response_1"); + let primary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let secondary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(primary_usage, (1, 0, 0)); + assert_eq!(secondary_usage, (1, 5, 2)); +} + +#[tokio::test] +async fn responses_should_mark_expired_after_401_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"code": "token_revoked", "message": "token revoked"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_401_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_401"); + assert_eq!(account_status.0, "expired"); +} + +#[tokio::test] +async fn responses_should_recover_auth_failure_from_sse_failed_event() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_FAILED_AUTH_SSE), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_401_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_401"); + assert_eq!(account_status.0, "expired"); +} + +#[tokio::test] +async fn responses_should_return_auth_error_when_401_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"code": "token_revoked", "message": "token revoked"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(message.contains("All accounts exhausted (1 expired)")); + assert!(message.contains("token_revoked")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(account_status.0, "expired"); +} + +#[tokio::test] +async fn responses_should_mark_banned_when_401_says_account_deactivated() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"message": "account deactivated"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(account_status.0, "banned"); +} + +#[tokio::test] +async fn responses_should_mark_banned_after_403_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "error": {"message": "account banned"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_403_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_403"); + assert_eq!(account_status.0, "banned"); +} + +#[tokio::test] +async fn responses_should_return_cloudflare_challenge_error_when_403_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(403).set_body_string( + "Just a moment...cf_chl challenge", + )) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_chat", "cf_clearance=old") + .await + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + let primary_state: (String, Option) = + sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(message.contains("All accounts exhausted (1 cloudflare-challenge)")); + assert!(message.contains("Cloudflare challenge")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(primary_state.0, "active"); + assert!(primary_state.1.is_some()); + assert_eq!( + cookie_store + .cookie_header("acct_chat", "chatgpt.com") + .await + .unwrap(), + None + ); +} + +#[tokio::test] +async fn responses_should_cool_down_cloudflare_403_and_fallback() { + let started_at = Utc::now(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(403).set_body_string( + "Just a moment...cf_chl challenge", + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_CLOUDFLARE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_primary", "cf_clearance=old") + .await + .unwrap(); + cookie_store + .set_cookie_header("acct_secondary", "cf_clearance=keep") + .await + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let primary_state: (String, Option) = + sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap()) + .unwrap() + .with_timezone(&Utc); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_cloudflare"); + assert_eq!(primary_state.0, "active"); + assert!(cooldown_until > started_at); + assert_eq!( + cookie_store + .cookie_header("acct_primary", "chatgpt.com") + .await + .unwrap(), + None + ); + assert_eq!( + cookie_store + .cookie_header("acct_secondary", "chatgpt.com") + .await + .unwrap() + .as_deref(), + Some("cf_clearance=keep") + ); +} + +#[tokio::test] +async fn responses_should_clear_cookies_after_cloudflare_path_block_404_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_CLOUDFLARE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_primary", "cf_clearance=old") + .await + .unwrap(); + cookie_store + .set_cookie_header("acct_secondary", "cf_clearance=keep") + .await + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_cloudflare"); + assert_eq!(primary_status.0, "active"); + assert_eq!( + cookie_store + .cookie_header("acct_primary", "chatgpt.com") + .await + .unwrap(), + None + ); + assert_eq!( + cookie_store + .cookie_header("acct_secondary", "chatgpt.com") + .await + .unwrap() + .as_deref(), + Some("cf_clearance=keep") + ); +} + +#[tokio::test] +async fn responses_should_disable_account_after_three_cloudflare_path_block_404s() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(404)) + .expect(3) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_chat", "cf_clearance=old") + .await + .unwrap(); + for _ in 0..3 { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(message.contains("cloudflare-path-block")); + } + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(account_status.0, "disabled"); + assert_eq!( + cookie_store + .cookie_header("acct_chat", "chatgpt.com") + .await + .unwrap(), + None + ); +} + +#[tokio::test] +async fn responses_should_fallback_after_http_model_unsupported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "model_not_supported", + "message": "Model gpt-5.5 is not supported on this account plan" + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_MODEL_UNSUPPORTED_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_model_unsupported"); + assert_eq!(primary_status.0, "active"); +} + +#[tokio::test] +async fn responses_should_fallback_after_sse_model_unsupported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_FAILED_MODEL_UNSUPPORTED_SSE), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_MODEL_UNSUPPORTED_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_model_unsupported"); +} + +#[tokio::test] +async fn responses_stream_should_fallback_after_sse_model_unsupported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_FAILED_MODEL_UNSUPPORTED_SSE), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_MODEL_UNSUPPORTED_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_model_unsupported")); +} + +#[tokio::test] +async fn responses_stream_should_return_model_unsupported_error_when_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "model_not_available", + "message": "Model gpt-5.5 is not available on this account plan" + } + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.failed")); + assert!(body.contains("All accounts exhausted (1 model-unsupported)")); + assert!(body.contains("model_not_available")); + assert_eq!(account_status.0, "active"); +} + +#[tokio::test] +async fn responses_should_strip_history_after_previous_response_not_found() { + let (base_url, upstream) = + spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( + "resp_previous_missing_failed", + "previous_response_not_found", + "Previous response with id resp_stale was not found", + )) + .await; + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_stale", + "turnState": "turn-stale", + "input": [{"role": "user", "content": "Continue"}], + "stream": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_history_recovery"); + assert_eq!( + captured_header(&captured.first_ws_headers, "x-codex-turn-state"), + Some("turn-stale") + ); + assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); + assert_eq!( + captured.first_ws_payload["previous_response_id"], + "resp_stale" + ); + assert!(captured + .second_ws_payload + .get("previous_response_id") + .is_none()); +} + +#[tokio::test] +async fn responses_should_strip_history_after_unanswered_function_call() { + let (base_url, upstream) = + spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( + "resp_unanswered_call_failed", + "invalid_request", + "No tool output found for function call call_1", + )) + .await; + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_stale", + "turnState": "turn-stale", + "input": [{"role": "user", "content": "Continue"}], + "stream": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_history_recovery"); + assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); + assert!(captured + .second_ws_payload + .get("previous_response_id") + .is_none()); +} + +#[tokio::test] +async fn responses_should_strip_history_after_websocket_invalid_encrypted_reasoning_replay() { + let (base_url, upstream) = + spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( + "resp_invalid_reasoning_failed", + "invalid_encrypted_content", + "Invalid encrypted content in reasoning replay", + )) + .await; + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_stale", + "turnState": "turn-stale", + "input": [{"role": "user", "content": "Continue"}], + "stream": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_history_recovery"); + assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); + assert!(captured + .second_ws_payload + .get("previous_response_id") + .is_none()); +} + +#[tokio::test] +async fn responses_should_strip_history_after_sse_invalid_encrypted_reasoning_replay() { + let (base_url, upstream) = + spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( + "resp_invalid_reasoning_failed", + "invalid_encrypted_content", + "Invalid encrypted content in reasoning replay", + )) + .await; + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_stale", + "turnState": "turn-stale", + "input": [{"role": "user", "content": "Continue"}], + "stream": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_history_recovery"); + assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); + assert!(captured + .second_ws_payload + .get("previous_response_id") + .is_none()); +} + +#[tokio::test] +async fn responses_stream_should_strip_history_after_previous_response_not_found() { + let (base_url, upstream) = + spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( + "resp_previous_missing_failed", + "previous_response_not_found", + "Previous response with id resp_stale was not found", + )) + .await; + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_stale", + "turnState": "turn-stale", + "input": [{"role": "user", "content": "Continue"}], + "stream": true + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_text(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(body.contains("resp_after_history_recovery")); + assert_eq!( + captured_header(&captured.first_ws_headers, "x-codex-turn-state"), + Some("turn-stale") + ); + assert_eq!( + captured.first_ws_payload["previous_response_id"], + "resp_stale" + ); + assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); + assert!(captured + .second_ws_payload + .get("previous_response_id") + .is_none()); +} + +#[tokio::test] +async fn responses_stream_should_strip_history_after_websocket_invalid_encrypted_reasoning_replay() +{ + let (base_url, upstream) = + spawn_websocket_failure_then_websocket_success_upstream(response_failed_websocket_message( + "resp_invalid_reasoning_failed", + "invalid_encrypted_content", + "Invalid encrypted content in reasoning replay", + )) + .await; + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_stale", + "turnState": "turn-stale", + "input": [{"role": "user", "content": "Continue"}], + "stream": true + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_text(response).await; + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(body.contains("resp_after_history_recovery")); + assert_eq!( + captured_header(&captured.first_ws_headers, "x-codex-turn-state"), + Some("turn-stale") + ); + assert_eq!( + captured.first_ws_payload["previous_response_id"], + "resp_stale" + ); + assert!(captured_header(&captured.second_ws_headers, "x-codex-turn-state").is_none()); + assert!(captured + .second_ws_payload + .get("previous_response_id") + .is_none()); +} + +#[tokio::test] +async fn responses_stream_should_fallback_to_next_account_after_rate_limit() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "90") + .set_body_json(json!({ + "error": {"message": "rate limited"} + })), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_429_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let primary_quota_state: (i64, Option) = sqlx::query_as( + "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", + ) + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let primary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let secondary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_429")); + assert_eq!(primary_quota_state.0, 1); + assert!(primary_quota_state.1.is_some()); + assert_eq!(primary_usage, (1, 0, 0)); + assert_eq!(secondary_usage, (1, 4, 1)); +} + +#[tokio::test] +async fn responses_stream_should_mark_expired_after_401_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"code": "token_revoked", "message": "token revoked"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_401_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let secondary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_401")); + assert_eq!(account_status.0, "expired"); + assert_eq!(secondary_usage, (1, 4, 2)); +} + +#[tokio::test] +async fn responses_stream_should_return_auth_error_when_401_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": {"code": "token_revoked", "message": "token revoked"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.failed")); + assert!(body.contains("All accounts exhausted (1 expired)")); + assert!(body.contains("token_revoked")); + assert_eq!(account_status.0, "expired"); +} + +#[tokio::test] +async fn responses_stream_should_mark_banned_after_403_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "error": {"message": "account banned"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_403_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_403")); + assert_eq!(account_status.0, "banned"); +} + +#[tokio::test] +async fn responses_stream_should_cool_down_cloudflare_403_and_fallback() { + let started_at = Utc::now(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(403).set_body_string( + "Just a moment...cf_chl challenge", + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_CLOUDFLARE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_primary", "cf_clearance=old") + .await + .unwrap(); + cookie_store + .set_cookie_header("acct_secondary", "cf_clearance=keep") + .await + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let primary_state: (String, Option) = + sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap()) + .unwrap() + .with_timezone(&Utc); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_cloudflare")); + assert_eq!(primary_state.0, "active"); + assert!(cooldown_until > started_at); + assert_eq!( + cookie_store + .cookie_header("acct_primary", "chatgpt.com") + .await + .unwrap(), + None + ); + assert_eq!( + cookie_store + .cookie_header("acct_secondary", "chatgpt.com") + .await + .unwrap() + .as_deref(), + Some("cf_clearance=keep") + ); +} + +#[tokio::test] +async fn responses_stream_should_return_cloudflare_path_block_error_when_404_fallback_is_exhausted() +{ + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])); + cookie_store + .set_cookie_header("acct_chat", "cf_clearance=old") + .await + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.failed")); + assert!(body.contains("All accounts exhausted (1 cloudflare-path-block)")); + assert!(body.contains("Cloudflare path-block")); + assert_eq!( + cookie_store + .cookie_header("acct_chat", "chatgpt.com") + .await + .unwrap(), + None + ); +} + +#[tokio::test] +async fn responses_should_retry_same_account_after_5xx_before_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": {"message": "temporary upstream failure"} + }))) + .up_to_n_times(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_5XX_RETRY_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let authorizations = received_authorizations(&server).await; + let primary_requests = authorizations + .iter() + .filter(|authorization| authorization.as_str() == "Bearer access-primary") + .count(); + let secondary_requests = authorizations + .iter() + .filter(|authorization| authorization.as_str() == "Bearer access-secondary") + .count(); + + assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}"); + assert_eq!(body["id"], "resp_after_5xx_retry"); + assert_eq!(primary_requests, 3, "requests: {authorizations:?}"); + assert_eq!(secondary_requests, 0, "requests: {authorizations:?}"); +} + +#[tokio::test] +async fn responses_stream_should_retry_same_account_after_5xx_before_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": {"message": "temporary upstream failure"} + }))) + .up_to_n_times(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_5XX_RETRY_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let authorizations = received_authorizations(&server).await; + let primary_requests = authorizations + .iter() + .filter(|authorization| authorization.as_str() == "Bearer access-primary") + .count(); + let secondary_requests = authorizations + .iter() + .filter(|authorization| authorization.as_str() == "Bearer access-secondary") + .count(); + + assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}"); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_5xx_retry")); + assert_eq!(primary_requests, 3, "requests: {authorizations:?}"); + assert_eq!(secondary_requests, 0, "requests: {authorizations:?}"); +} + +#[tokio::test] +async fn responses_should_mark_quota_exhausted_after_402_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": {"message": "quota exhausted"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_402_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let failed_usage: Option<(i64,)> = + sqlx::query_as("select request_count from account_usage where account_id = ?") + .bind("acct_primary") + .fetch_optional(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_402"); + assert_eq!(account_status.0, "quota_exhausted"); + assert_eq!(failed_usage.map(|row| row.0).unwrap_or_default(), 1); +} + +#[tokio::test] +async fn responses_stream_should_mark_quota_exhausted_after_402_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": {"message": "quota exhausted"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_AFTER_402_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let secondary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("resp_stream_after_402")); + assert_eq!(account_status.0, "quota_exhausted"); + assert_eq!(secondary_usage, (1, 6, 3)); +} + +#[tokio::test] +async fn responses_should_return_quota_error_when_402_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": {"message": "quota reached"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + let message = body["error"]["message"].as_str().unwrap_or_default(); + + assert_eq!(status, StatusCode::PAYMENT_REQUIRED); + assert!(message.contains("All accounts exhausted (1 quota-exhausted)")); + assert!(message.contains("quota reached")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(account_status.0, "quota_exhausted"); +} + +#[tokio::test] +async fn responses_should_return_rate_limit_error_when_429_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "error": {"message": "rate limited"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let quota_state: (i64, Option) = sqlx::query_as( + "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + let message = body["error"]["message"].as_str().unwrap_or_default(); + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert!(message.contains("All accounts exhausted (1 rate-limited)")); + assert!(message.contains("rate limited")); + assert_eq!(body["error"]["code"], "upstream_error"); + assert_eq!(quota_state.0, 1); + assert!(quota_state.1.is_some()); +} + +#[tokio::test] +async fn responses_stream_should_return_rate_limit_error_when_429_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(429).set_body_json(json!({ + "error": {"message": "rate limited"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let quota_state: (i64, Option) = sqlx::query_as( + "select quota_limit_reached, quota_cooldown_until from accounts where id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.failed")); + assert!(body.contains("rate_limit_exceeded")); + assert!(body.contains("All accounts exhausted (1 rate-limited)")); + assert!(body.contains("rate limited")); + assert_eq!(quota_state.0, 1); + assert!(quota_state.1.is_some()); +} + +#[tokio::test] +async fn responses_stream_should_return_quota_error_when_402_fallback_is_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": {"message": "quota reached"} + }))) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.failed")); + assert!(body.contains("All accounts exhausted (1 quota-exhausted)")); + assert!(body.contains("quota reached")); + assert_eq!(account_status.0, "quota_exhausted"); +} + +#[tokio::test] +async fn responses_should_classify_sse_quota_failure_and_fallback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-primary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_FAILED_QUOTA_SSE), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secondary")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_AFTER_402_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_after_402"); + assert_eq!(account_status.0, "quota_exhausted"); +} diff --git a/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs b/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs new file mode 100644 index 000000000..cd076b5c2 --- /dev/null +++ b/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs @@ -0,0 +1,1960 @@ +use super::*; + +#[tokio::test] +async fn responses_websocket_should_stream_first_frame_before_terminal_event() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let (first_frame_tx, first_frame_rx) = oneshot::channel(); + let (terminal_tx, terminal_rx) = oneshot::channel(); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let message = websocket.next().await.unwrap().unwrap(); + let payload = serde_json::from_str::(&message.into_text().unwrap()).unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.output_text.delta", + "delta": "first websocket frame" + }) + .to_string() + .into(), + )) + .await + .unwrap(); + first_frame_tx.send(()).unwrap(); + terminal_rx.await.unwrap(); + websocket + .send(Message::Text( + response_completed_websocket_message("resp_ws_streaming").into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + payload + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + let response_task = tokio::spawn(async move { + app.oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() + }); + + first_frame_rx.await.unwrap(); + let response = timeout(StdDuration::from_millis(250), response_task) + .await + .expect("websocket response should be returned after the first non-error frame") + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let first_chunk = timeout( + StdDuration::from_secs(1), + first_response_body_chunk(response), + ) + .await + .expect("downstream should receive a websocket-backed SSE chunk before completion") + .expect("response body should produce a chunk"); + terminal_tx.send(()).unwrap(); + let payload = upstream.await.unwrap(); + + assert!(first_chunk.contains("event: response.output_text.delta")); + assert_eq!(payload["type"], "response.create"); +} + +#[tokio::test] +async fn responses_websocket_stream_should_synthesize_response_failed_when_closed_before_terminal() +{ + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let message = websocket.next().await.unwrap().unwrap(); + let payload = serde_json::from_str::(&message.into_text().unwrap()).unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.output_text.delta", + "delta": "partial before websocket close" + }) + .to_string() + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + payload + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_text(response).await; + let payload = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["type"], "response.create"); + assert!(body.contains("event: response.output_text.delta")); + assert!(body.contains("event: response.failed")); + assert!(body.contains("stream_disconnected")); +} + +#[tokio::test] +async fn responses_websocket_should_reuse_connection_for_recorded_conversation() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_message = websocket.next().await.unwrap().unwrap(); + let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) + .expect("first websocket payload should be json"); + websocket + .send(Message::Text( + response_completed_websocket_message("resp_pool_first").into(), + )) + .await + .unwrap(); + + loop { + tokio::select! { + message = websocket.next() => { + match message { + Some(Ok(message)) if message.is_text() => { + let second_payload = serde_json::from_str::( + &message.into_text().unwrap(), + ) + .expect("second websocket payload should be json"); + websocket + .send(Message::Text( + response_completed_websocket_message("resp_pool_second").into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + break (true, first_payload, second_payload); + } + Some(_) => continue, + None => { + let second_payload = accept_successful_websocket_response( + &listener, + "resp_pool_second", + ) + .await; + break (false, first_payload, second_payload); + } + } + } + accepted = listener.accept() => { + let (stream, _) = accepted.unwrap(); + let mut second_websocket = accept_async(stream).await.unwrap(); + let second_message = second_websocket.next().await.unwrap().unwrap(); + let second_payload = serde_json::from_str::( + &second_message.into_text().unwrap(), + ) + .expect("second websocket payload should be json"); + second_websocket + .send(Message::Text( + response_completed_websocket_message("resp_pool_second").into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + break (false, first_payload, second_payload); + } + } + } + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{ + "role": "user", + "content": "reuse this upstream websocket" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + assert!(response_text(first_response) + .await + .contains("\"id\":\"resp_pool_first\"")); + + let second_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "previous_response_id": "resp_pool_first" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + assert!(response_text(second_response) + .await + .contains("\"id\":\"resp_pool_second\"")); + let (reused_connection, first_payload, second_payload) = upstream.await.unwrap(); + + assert!(reused_connection, "second request opened a new websocket"); + assert_eq!( + second_payload["prompt_cache_key"], first_payload["prompt_cache_key"], + "pooled websocket reuse should stay on the recorded conversation key" + ); + assert_eq!(second_payload["previous_response_id"], "resp_pool_first"); +} + +#[tokio::test] +async fn responses_websocket_should_not_reuse_connection_when_pool_is_disabled() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_message = websocket.next().await.unwrap().unwrap(); + let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) + .expect("first websocket payload should be json"); + websocket + .send(Message::Text( + response_completed_websocket_message("resp_disabled_pool_first").into(), + )) + .await + .unwrap(); + + loop { + tokio::select! { + message = websocket.next() => { + match message { + Some(Ok(message)) if message.is_text() => { + let second_payload = serde_json::from_str::( + &message.into_text().unwrap(), + ) + .expect("second websocket payload should be json"); + websocket + .send(Message::Text( + response_completed_websocket_message( + "resp_disabled_pool_second", + ) + .into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + break (true, first_payload, second_payload); + } + Some(_) => continue, + None => { + let second_payload = accept_successful_websocket_response( + &listener, + "resp_disabled_pool_second", + ) + .await; + break (false, first_payload, second_payload); + } + } + } + accepted = listener.accept() => { + let (stream, _) = accepted.unwrap(); + let mut second_websocket = accept_async(stream).await.unwrap(); + let second_message = second_websocket.next().await.unwrap().unwrap(); + let second_payload = serde_json::from_str::( + &second_message.into_text().unwrap(), + ) + .expect("second websocket payload should be json"); + second_websocket + .send(Message::Text( + response_completed_websocket_message("resp_disabled_pool_second") + .into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + break (false, first_payload, second_payload); + } + } + } + }); + let (app, api_key, _pool, _dir) = test_app_with_account_pool_config(base_url, |config| { + config.ws_pool.enabled = false; + }) + .await; + + let first_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{ + "role": "user", + "content": "do not reuse this upstream websocket" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + assert!(response_text(first_response) + .await + .contains("\"id\":\"resp_disabled_pool_first\"")); + + let second_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "previous_response_id": "resp_disabled_pool_first" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + assert!(response_text(second_response) + .await + .contains("\"id\":\"resp_disabled_pool_second\"")); + let (reused_connection, first_payload, second_payload) = upstream.await.unwrap(); + + assert!( + !reused_connection, + "disabled pool reused the upstream websocket" + ); + assert_eq!( + second_payload["prompt_cache_key"], first_payload["prompt_cache_key"], + "disabling the pool must not change the recorded conversation key" + ); + assert_eq!( + second_payload["previous_response_id"], + "resp_disabled_pool_first" + ); +} + +#[tokio::test] +#[expect( + clippy::result_large_err, + reason = "tokio-tungstenite handshake callbacks use a large error response type" +)] +async fn responses_websocket_stream_should_record_metadata_turn_state_for_continuation() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let first_message = first_websocket.next().await.unwrap().unwrap(); + let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) + .expect("first websocket payload should be json"); + first_websocket + .send(Message::Text( + json!({ + "type": "response.metadata", + "headers": { + "x-codex-turn-state": "turn-from-metadata" + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + first_websocket + .send(Message::Text( + response_completed_websocket_message("resp_metadata_turn_state").into(), + )) + .await + .unwrap(); + first_websocket.close(None).await.unwrap(); + + let (second_stream, _) = listener.accept().await.unwrap(); + let second_headers = Arc::new(Mutex::new(Vec::new())); + let second_headers_for_callback = Arc::clone(&second_headers); + let mut second_websocket = accept_hdr_async( + second_stream, + move |request: &WsRequest, response: WsResponse| { + *second_headers_for_callback.lock().unwrap() = request_headers(request); + Ok(response) + }, + ) + .await + .unwrap(); + let second_message = second_websocket.next().await.unwrap().unwrap(); + let second_payload = serde_json::from_str::(&second_message.into_text().unwrap()) + .expect("second websocket payload should be json"); + second_websocket + .send(Message::Text( + response_completed_websocket_message("resp_metadata_turn_state_next").into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + let second_headers = second_headers.lock().unwrap().clone(); + (first_payload, second_payload, second_headers) + }); + let (app, api_key, _pool, _dir) = test_app_with_account_pool_config(base_url, |config| { + config.ws_pool.enabled = false; + }) + .await; + + let first_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + let first_body = response_text(first_response).await; + assert!(first_body.contains("\"id\":\"resp_metadata_turn_state\"")); + assert!(!first_body.contains("response.metadata")); + + let second_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "previous_response_id": "resp_metadata_turn_state" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + assert!(response_text(second_response) + .await + .contains("\"id\":\"resp_metadata_turn_state_next\"")); + let (first_payload, second_payload, second_headers) = upstream.await.unwrap(); + + assert!(first_payload.get("previous_response_id").is_none()); + assert_eq!( + second_payload["previous_response_id"], + "resp_metadata_turn_state" + ); + assert_eq!( + captured_header(&second_headers, "x-codex-turn-state"), + Some("turn-from-metadata") + ); +} + +#[tokio::test] +async fn responses_websocket_should_implicitly_resume_full_history_with_reasoning_replay() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), + ) + .await; + let second_payload = accept_followup_websocket_response( + &listener, + &mut websocket, + websocket_completed_response("resp_implicit_resume_second", 4, 1), + ) + .await; + let _ = websocket.close(None).await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [{"role": "user", "content": "remember this"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "cached answer"}, + {"role": "user", "content": "continue"} + ] + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + + let (first_payload, mut second_payload) = upstream.await.unwrap(); + assert!(first_payload["prompt_cache_key"].as_str().is_some()); + assert_eq!( + second_payload["previous_response_id"], + "resp_implicit_resume_first" + ); + assert_eq!( + second_payload["prompt_cache_key"], + first_payload["prompt_cache_key"] + ); + assert_eq!( + second_payload["input"][0]["encrypted_content"], + "enc_reasoning_replay" + ); + assert_eq!(second_payload["input"][1]["content"], "continue"); + assert_eq!(second_payload["input"].as_array().unwrap().len(), 2); + + second_payload + .as_object_mut() + .unwrap() + .remove("prompt_cache_key"); + second_payload + .as_object_mut() + .unwrap() + .remove("client_metadata"); + let expected: Value = serde_json::from_str(REASONING_REPLAY_REQUEST_GOLDEN).unwrap(); + assert_eq!(second_payload, expected); +} + +#[tokio::test] +async fn responses_websocket_should_not_implicitly_resume_unmatched_function_call_output() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + websocket_completed_function_call_response("resp_call_first", "call_expected"), + ) + .await; + let second_payload = accept_followup_websocket_response( + &listener, + &mut websocket, + websocket_completed_response("resp_call_mismatch_second", 4, 1), + ) + .await; + let _ = websocket.close(None).await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "use the lookup tool", + "stream": false, + "input": [{"role": "user", "content": "call the lookup tool"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "use the lookup tool", + "stream": false, + "input": [ + {"role": "user", "content": "call the lookup tool"}, + { + "type": "function_call", + "call_id": "call_expected", + "name": "lookup", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call_missing", + "output": "tool output" + } + ] + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + + let (first_payload, second_payload) = upstream.await.unwrap(); + assert!(first_payload["prompt_cache_key"].as_str().is_some()); + assert!(second_payload.get("previous_response_id").is_none()); + assert_eq!(second_payload["input"].as_array().unwrap().len(), 3); + assert_eq!(second_payload["input"][2]["call_id"], "call_missing"); +} + +#[tokio::test] +async fn responses_websocket_should_implicitly_resume_after_sqlite_affinity_restore() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server_base_url = base_url.clone(); + let upstream = tokio::spawn(async move { + let first_payload = accept_websocket_response_with_message( + &listener, + WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), + ) + .await; + let second_payload = accept_websocket_response_with_message( + &listener, + websocket_completed_response("resp_restored_implicit_resume", 4, 1), + ) + .await; + (first_payload, second_payload) + }); + let (app, api_key, pool, dir) = test_app_with_account_pool_config(base_url, |_| {}).await; + + let first_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [{"role": "user", "content": "remember this"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let db = dir.path().join("openai-record-affinity.sqlite"); + let restored_state = AppState::with_pool_secret_api_key_hasher_and_installation_id( + test_config(format!("sqlite://{}", db.display()), server_base_url), + pool.clone(), + SecretBox::new([83u8; 32]), + ApiKeyHasher::new([84u8; 32]), + TEST_INSTALLATION_ID.to_string(), + ); + assert_eq!( + restored_state + .restore_account_pool_from_repository() + .await + .unwrap(), + 1 + ); + assert_eq!( + restored_state + .restore_session_affinity_from_repository_now() + .await + .unwrap(), + 1 + ); + let restored_app = router::router().with_state(restored_state); + + let second_response = restored_app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "cached answer"}, + {"role": "user", "content": "continue"} + ] + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + + let (first_payload, second_payload) = upstream.await.unwrap(); + assert_eq!( + second_payload["prompt_cache_key"], + first_payload["prompt_cache_key"] + ); + assert_eq!( + second_payload["previous_response_id"], + "resp_implicit_resume_first" + ); + assert_eq!(second_payload["input"].as_array().unwrap().len(), 1); + assert_eq!(second_payload["input"][0]["content"], "continue"); +} + +#[tokio::test] +async fn responses_websocket_pool_should_be_evicted_after_admin_account_status_cycle() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + websocket_completed_response("resp_pool_status_first", 4, 1), + ) + .await; + + tokio::select! { + message = websocket.next() => { + match message { + Some(Ok(message)) if message.is_text() => { + websocket + .send(Message::Text( + websocket_completed_response("resp_pool_status_second", 3, 1).into(), + )) + .await + .unwrap(); + let _ = websocket.close(None).await; + true + } + _ => { + accept_websocket_response_with_message( + &listener, + websocket_completed_response("resp_pool_status_second", 3, 1), + ) + .await; + false + } + } + } + accepted = listener.accept() => { + let (stream, _) = accepted.unwrap(); + let mut second_websocket = accept_async(stream).await.unwrap(); + send_websocket_response_and_capture_payload( + &mut second_websocket, + websocket_completed_response("resp_pool_status_second", 3, 1), + ) + .await; + second_websocket.close(None).await.unwrap(); + let _ = websocket.close(None).await; + false + } + } + }); + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; + seed_openai_admin_session(&pool, "session_status_cycle").await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "prompt_cache_key": "status-cycle" + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + assert!(response_text(first_response) + .await + .contains("\"id\":\"resp_pool_status_first\"")); + + update_admin_account_status(&app, "acct_chat", "disabled").await; + update_admin_account_status(&app, "acct_chat", "active").await; + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "previous_response_id": "resp_pool_status_first" + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + assert!(response_text(second_response) + .await + .contains("\"id\":\"resp_pool_status_second\"")); + + let reused_connection = upstream.await.unwrap(); + assert!( + !reused_connection, + "admin status lifecycle should evict the old pooled websocket" + ); +} + +#[tokio::test] +async fn responses_websocket_should_not_implicitly_resume_self_contained_function_call_replay() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + websocket_completed_function_call_response("resp_self_contained_first", "call_self"), + ) + .await; + let second_payload = accept_followup_websocket_response( + &listener, + &mut websocket, + websocket_completed_response("resp_self_contained_second", 4, 1), + ) + .await; + let _ = websocket.close(None).await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "use the lookup tool", + "stream": false, + "input": [{"role": "user", "content": "call the lookup tool"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "use the lookup tool", + "stream": false, + "input": [ + {"role": "user", "content": "call the lookup tool"}, + { + "type": "function_call", + "call_id": "call_self", + "name": "lookup", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call_self", + "output": "tool output" + } + ] + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + + let (first_payload, second_payload) = upstream.await.unwrap(); + assert!(first_payload["prompt_cache_key"].as_str().is_some()); + assert!(second_payload.get("previous_response_id").is_none()); + assert_eq!(second_payload["input"].as_array().unwrap().len(), 3); + assert_eq!(second_payload["input"][2]["call_id"], "call_self"); +} + +#[tokio::test] +async fn responses_websocket_should_not_implicitly_resume_across_codex_windows() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), + ) + .await; + let second_payload = accept_followup_websocket_response( + &listener, + &mut websocket, + websocket_completed_response("resp_window_b", 8, 2), + ) + .await; + let _ = websocket.close(None).await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "prompt_cache_key": "shared-variant-session", + "codexWindowId": "window-a", + "stream": false, + "input": [{"role": "user", "content": "remember this"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "prompt_cache_key": "shared-variant-session", + "codexWindowId": "window-b", + "stream": false, + "input": [ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "cached answer"}, + {"role": "user", "content": "continue in another window"} + ] + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + + let (first_payload, second_payload) = upstream.await.unwrap(); + assert_eq!( + second_payload["prompt_cache_key"], + first_payload["prompt_cache_key"] + ); + assert!(second_payload.get("previous_response_id").is_none()); + assert_eq!(second_payload["input"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn responses_websocket_should_evict_reasoning_replay_after_invalid_encrypted_content() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), + ) + .await; + let invalid_payload = accept_followup_websocket_response( + &listener, + &mut websocket, + WEBSOCKET_INVALID_ENCRYPTED_CONTENT.trim().to_string(), + ) + .await; + let _ = websocket.close(None).await; + let retried_payload = accept_websocket_response_with_message( + &listener, + websocket_completed_response("resp_after_replay_eviction", 4, 1), + ) + .await; + (first_payload, invalid_payload, retried_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [{"role": "user", "content": "remember this"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "cached answer"}, + {"role": "user", "content": "continue"} + ] + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + + let (first_payload, invalid_payload, retried_payload) = upstream.await.unwrap(); + assert_eq!( + invalid_payload["previous_response_id"], + "resp_implicit_resume_first" + ); + assert_eq!( + invalid_payload["input"][0]["encrypted_content"], + "enc_reasoning_replay" + ); + assert_eq!( + retried_payload["prompt_cache_key"], + first_payload["prompt_cache_key"] + ); + assert!(retried_payload.get("previous_response_id").is_none()); + let retried_input = retried_payload["input"].as_array().unwrap(); + assert!(retried_input + .iter() + .all(|item| item.get("encrypted_content").is_none())); + assert_eq!(retried_input.last().unwrap()["content"], "continue"); +} + +#[tokio::test] +async fn responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let first_payload = send_websocket_response_and_capture_payload( + &mut websocket, + WEBSOCKET_COMPLETED_WITH_REASONING_REPLAY.trim().to_string(), + ) + .await; + let implicit_payload = accept_followup_websocket_response( + &listener, + &mut websocket, + WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND.trim().to_string(), + ) + .await; + let _ = websocket.close(None).await; + let restored_payload = accept_websocket_response_with_message( + &listener, + websocket_completed_response("resp_implicit_resume_restored", 10, 2), + ) + .await; + (first_payload, implicit_payload, restored_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [{"role": "user", "content": "remember this"}] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "stream": false, + "input": [ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "cached answer"}, + {"role": "user", "content": "continue"} + ] + }), + )) + .await + .unwrap(); + let body = response_json(second_response).await; + assert_eq!(body["id"], "resp_implicit_resume_restored"); + + let (_first_payload, implicit_payload, restored_payload) = upstream.await.unwrap(); + assert_eq!( + implicit_payload["previous_response_id"], + "resp_implicit_resume_first" + ); + assert_eq!(implicit_payload["input"].as_array().unwrap().len(), 2); + assert!(restored_payload.get("previous_response_id").is_none()); + assert_eq!(restored_payload["input"].as_array().unwrap().len(), 3); + assert_eq!(restored_payload["input"][0]["role"], "user"); + assert_eq!(restored_payload["input"][1]["role"], "assistant"); + assert_eq!(restored_payload["input"][2]["content"], "continue"); +} + +#[tokio::test] +async fn responses_websocket_should_route_previous_response_id_to_recorded_account() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (_reused_connection, first_payload, second_payload) = + accept_two_successful_websocket_responses_with_authorization( + &listener, + "Bearer access-primary", + "resp_affinity_first", + "resp_affinity_second", + ) + .await; + (first_payload, second_payload) + }); + let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; + + let first_response = app + .clone() + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "instructions": "answer briefly", + "input": [{ + "role": "user", + "content": "keep this conversation on the same account" + }] + }), + )) + .await + .unwrap(); + assert_eq!(first_response.status(), StatusCode::OK); + assert!(response_text(first_response) + .await + .contains("\"id\":\"resp_affinity_first\"")); + let stored_affinity: (String, String, String, Option, String) = sqlx::query_as( + "select account_id, conversation_id, function_call_ids_json, input_tokens, expires_at from session_affinities where response_id = ?", + ) + .bind("resp_affinity_first") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_affinity.0, "acct_primary"); + assert!(!stored_affinity.1.is_empty()); + assert_eq!(stored_affinity.2, "[]"); + assert_eq!(stored_affinity.3, Some(3)); + assert!(!stored_affinity.4.is_empty()); + + let second_response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "previous_response_id": "resp_affinity_first" + }), + )) + .await + .unwrap(); + assert_eq!(second_response.status(), StatusCode::OK); + let second_body = response_text(second_response).await; + assert!( + second_body.contains("\"id\":\"resp_affinity_second\""), + "{second_body}" + ); + let (first_payload, second_payload) = upstream.await.unwrap(); + + assert_ne!(first_payload["prompt_cache_key"], Value::Null); + assert_eq!( + second_payload["previous_response_id"], + "resp_affinity_first" + ); + assert_eq!( + second_payload["prompt_cache_key"], first_payload["prompt_cache_key"], + "previous_response_id should inherit the recorded conversation identity" + ); +} + +#[tokio::test] +async fn responses_websocket_non_stream_previous_response_not_found_should_strip_history_and_retry_same_account( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let first_payload = accept_websocket_response_with_authorization_and_message( + &listener, + "Bearer access-secret", + WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND.trim().to_string(), + ) + .await; + let second_payload = accept_websocket_response_with_authorization_and_message( + &listener, + "Bearer access-secret", + websocket_completed_response("resp_after_history_strip", 3, 1), + ) + .await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "previous_response_id": "resp_missing" + }), + )) + .await + .unwrap(); + let body = response_json(response).await; + let (first_payload, second_payload) = upstream.await.unwrap(); + + assert_eq!(body["id"], "resp_after_history_strip"); + assert_eq!(first_payload["previous_response_id"], "resp_missing"); + assert!(second_payload.get("previous_response_id").is_none()); +} + +#[tokio::test] +async fn responses_websocket_stream_previous_response_not_found_should_strip_history_and_retry_same_account( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let first_payload = accept_websocket_response_with_authorization_and_message( + &listener, + "Bearer access-secret", + WEBSOCKET_PREVIOUS_RESPONSE_NOT_FOUND.trim().to_string(), + ) + .await; + let second_payload = accept_websocket_response_with_authorization_and_message( + &listener, + "Bearer access-secret", + websocket_completed_response("resp_stream_after_history_strip", 3, 1), + ) + .await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "previous_response_id": "resp_missing" + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + let (first_payload, second_payload) = upstream.await.unwrap(); + + assert!(body.contains("\"id\":\"resp_stream_after_history_strip\"")); + assert_eq!(first_payload["previous_response_id"], "resp_missing"); + assert!(second_payload.get("previous_response_id").is_none()); +} + +#[tokio::test] +async fn responses_websocket_non_stream_unanswered_function_call_should_strip_history_and_retry_same_account( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let first_payload = accept_websocket_response_with_authorization_and_message( + &listener, + "Bearer access-secret", + WEBSOCKET_UNANSWERED_FUNCTION_CALL.trim().to_string(), + ) + .await; + let second_payload = accept_websocket_response_with_authorization_and_message( + &listener, + "Bearer access-secret", + websocket_completed_response("resp_after_function_call_strip", 3, 1), + ) + .await; + (first_payload, second_payload) + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "previous_response_id": "resp_with_call" + }), + )) + .await + .unwrap(); + let body = response_json(response).await; + let (first_payload, second_payload) = upstream.await.unwrap(); + + assert_eq!(body["id"], "resp_after_function_call_strip"); + assert_eq!(first_payload["previous_response_id"], "resp_with_call"); + assert!(second_payload.get("previous_response_id").is_none()); +} + +#[tokio::test] +async fn responses_websocket_previous_response_id_should_retry_fallback_account_after_429() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-primary", + 429, + "Too Many Requests", + Some(77), + WEBSOCKET_HISTORY_RATE_LIMITED, + ) + .await; + accept_successful_websocket_response_with_authorization( + &listener, + "Bearer access-secondary", + "resp_history_fallback", + ) + .await + }); + let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "previous_response_id": "resp_prev" + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + let fallback_payload = upstream.await.unwrap(); + let secondary_usage: (i64,) = + sqlx::query_as("select request_count from account_usage where account_id = ?") + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + + assert!(body.contains("\"id\":\"resp_history_fallback\"")); + assert_eq!(fallback_payload["previous_response_id"], "resp_prev"); + assert_eq!(secondary_usage.0, 1); +} + +#[tokio::test] +async fn responses_websocket_non_stream_previous_response_id_should_retry_fallback_account_after_429( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-primary", + 429, + "Too Many Requests", + Some(77), + WEBSOCKET_HISTORY_RATE_LIMITED, + ) + .await; + accept_successful_websocket_response_with_authorization( + &listener, + "Bearer access-secondary", + "resp_history_fallback_non_stream", + ) + .await + }); + let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "previous_response_id": "resp_prev" + }), + )) + .await + .unwrap(); + let body = response_json(response).await; + let fallback_payload = upstream.await.unwrap(); + let secondary_usage: (i64,) = + sqlx::query_as("select request_count from account_usage where account_id = ?") + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(body["id"], "resp_history_fallback_non_stream"); + assert_eq!(fallback_payload["previous_response_id"], "resp_prev"); + assert_eq!(secondary_usage.0, 1); +} + +#[tokio::test] +async fn responses_websocket_without_history_should_mark_expired_after_fallback_401() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-primary", + 429, + "Too Many Requests", + Some(30), + WEBSOCKET_RATE_LIMITED, + ) + .await; + reject_next_websocket_upgrade( + &listener, + "Bearer access-secondary", + 401, + "Unauthorized", + None, + WEBSOCKET_TOKEN_REVOKED, + ) + .await; + }); + let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + upstream.await.unwrap(); + let secondary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + let primary_usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert!(body.contains("event: response.failed")); + assert!(body.contains("\"type\":\"invalid_request_error\"")); + assert!(body.contains("\"code\":\"authentication_error\"")); + assert!(body.contains("All accounts exhausted")); + assert!(body.contains("token_revoked")); + assert_eq!(secondary_status.0, "expired"); + assert_eq!(primary_usage, (1, 0, 0)); +} + +#[tokio::test] +async fn responses_websocket_without_history_should_return_rate_limit_stream_error_when_fallback_accounts_exhausted( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-primary", + 429, + "Too Many Requests", + Some(11), + WEBSOCKET_FIRST_ACCOUNT_LIMITED, + ) + .await; + reject_next_websocket_upgrade( + &listener, + "Bearer access-secondary", + 429, + "Too Many Requests", + Some(22), + WEBSOCKET_SECOND_ACCOUNT_LIMITED, + ) + .await; + }); + let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + upstream.await.unwrap(); + let primary_usage: (i64,) = + sqlx::query_as("select request_count from account_usage where account_id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + let secondary_usage: (i64,) = + sqlx::query_as("select request_count from account_usage where account_id = ?") + .bind("acct_secondary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_response_failed_stream( + &body, + "rate_limit_error", + "rate_limit_exceeded", + &[ + "All accounts exhausted (2 rate-limited)", + "second account limited", + ], + ); + assert_eq!(primary_usage.0, 1); + assert_eq!(secondary_usage.0, 1); +} + +#[tokio::test] +async fn responses_websocket_response_failed_quota_should_retry_fallback_account() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + let mut first_websocket = + accept_websocket_with_authorization(first_stream, "Bearer access-primary").await; + let _first_payload = send_websocket_response_and_capture_payload( + &mut first_websocket, + json!({ + "type": "response.failed", + "response": { + "id": "resp_ws_quota_failed", + "error": { + "code": "insufficient_quota", + "message": "quota exhausted" + } + } + }) + .to_string(), + ) + .await; + + let (second_stream, _) = listener.accept().await.unwrap(); + let mut second_websocket = + accept_websocket_with_authorization(second_stream, "Bearer access-secondary").await; + send_websocket_response_and_capture_payload( + &mut second_websocket, + websocket_completed_response("resp_after_ws_quota", 3, 1), + ) + .await; + second_websocket.close(None).await.unwrap(); + }); + let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": true + }), + )) + .await + .unwrap(); + let body = response_json(response).await; + upstream.await.unwrap(); + let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_primary") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(body["id"], "resp_after_ws_quota"); + assert_eq!(primary_status.0, "quota_exhausted"); +} + +#[tokio::test] +async fn responses_websocket_without_history_should_return_quota_stream_error_when_402_has_no_fallback( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-secret", + 402, + "Payment Required", + None, + r#"{"error":{"message":"quota reached"}}"#, + ) + .await; + }); + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + upstream.await.unwrap(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_response_failed_stream( + &body, + "invalid_request_error", + "codex_api_error", + &[ + "All accounts exhausted (1 quota-exhausted)", + "quota reached", + ], + ); + assert_eq!(account_status.0, "quota_exhausted"); +} + +#[tokio::test] +async fn responses_websocket_without_history_should_return_model_unsupported_stream_error_when_no_fallback( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-secret", + 400, + "Bad Request", + None, + r#"{"error":{"code":"model_not_available","message":"Model gpt-5.5 is not available on this account plan"}}"#, + ) + .await; + }); + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "use_websocket": true + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + upstream.await.unwrap(); + let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?") + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_response_failed_stream( + &body, + "invalid_request_error", + "codex_api_error", + &[ + "No accounts available", + "model_not_available", + "not available", + ], + ); + assert_eq!(account_status.0, "active"); +} + +#[tokio::test] +async fn responses_websocket_with_history_should_return_path_block_stream_error_when_no_fallback() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + reject_next_websocket_upgrade( + &listener, + "Bearer access-secret", + 404, + "Not Found", + None, + "", + ) + .await; + }); + let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + json!({ + "model": "gpt-5.5", + "input": [], + "stream": true, + "previous_response_id": "resp_prev" + }), + )) + .await + .unwrap(); + let body = response_text(response).await; + upstream.await.unwrap(); + + assert_response_failed_stream( + &body, + "server_error", + "codex_api_error", + &["No accounts available", "Cloudflare path-block"], + ); +} + +#[tokio::test] +async fn responses_with_previous_response_id_should_use_websocket_and_configured_pool() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let accepted_connections_for_server = Arc::clone(&accepted_connections); + let server = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + response_completed_websocket_message("resp_runtime_pool_first").into(), + )) + .await + .unwrap(); + + tokio::select! { + second_message = first_websocket.next() => { + let _second_message = second_message.unwrap().unwrap(); + first_websocket + .send(Message::Text( + response_completed_websocket_message("resp_runtime_pool_second").into(), + )) + .await + .unwrap(); + first_websocket.close(None).await.unwrap(); + } + accepted = listener.accept() => { + let (second_stream, _) = accepted.unwrap(); + accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); + let mut second_websocket = accept_async(second_stream).await.unwrap(); + let _second_message = second_websocket.next().await.unwrap().unwrap(); + second_websocket + .send(Message::Text( + response_completed_websocket_message("resp_runtime_pool_second").into(), + )) + .await + .unwrap(); + second_websocket.close(None).await.unwrap(); + first_websocket.close(None).await.unwrap(); + } + } + }); + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let first = app + .clone() + .oneshot(responses_previous_request( + &api_key, + "Continue from pooled runtime websocket", + )) + .await + .unwrap(); + let second = app + .oneshot(responses_previous_request( + &api_key, + "Continue again from pooled runtime websocket", + )) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn responses_stream_with_previous_response_id_should_forward_websocket_chunks_before_completion( +) { + let (base_url, first_chunk_sent_rx, finish_tx, upstream) = + spawn_chunked_websocket_upstream().await; + let (app, api_key, _dir) = test_app_with_account(base_url).await; + + let response = app + .oneshot(responses_previous_stream_request( + &api_key, + "Continue as a WebSocket stream", + )) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + first_chunk_sent_rx + .await + .expect("upstream should send the first websocket event"); + let first_chunk = timeout( + StdDuration::from_secs(1), + first_response_body_chunk(response), + ) + .await + .expect("downstream should receive a websocket-backed SSE chunk before upstream completes") + .expect("response body should produce a chunk"); + finish_tx + .send(()) + .expect("test should be able to finish upstream websocket"); + let captured = upstream.await.unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(first_chunk.contains("event: response.output_text.delta")); + assert_eq!( + captured_header(&captured.headers, "authorization"), + Some("Bearer access-secret") + ); + assert_eq!( + captured.payload["previous_response_id"], + "resp_runtime_pool_previous" + ); +} + +#[tokio::test] +async fn responses_stream_with_previous_response_id_should_record_websocket_audit_metadata() { + let (base_url, first_chunk_sent_rx, finish_tx, upstream) = + spawn_chunked_websocket_upstream().await; + let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(base_url).await; + + let response = app + .oneshot(responses_previous_stream_request( + &api_key, + "Continue as a logged WebSocket stream", + )) + .await + .unwrap(); + first_chunk_sent_rx + .await + .expect("upstream should send the first websocket event"); + finish_tx + .send(()) + .expect("test should be able to finish upstream websocket"); + let body = response_text(response).await; + let captured = upstream.await.unwrap(); + let event = latest_response_event_log(&pool).await; + let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); + + assert!(body.contains("resp_live_websocket_stream")); + assert_eq!( + captured.payload["previous_response_id"], + "resp_runtime_pool_previous" + ); + assert_eq!(event.level, "info"); + assert_eq!(metadata["stream"], true); + assert_eq!(metadata["transport"], "websocket"); + assert_eq!(metadata["usage"]["inputTokens"], 3); + assert_eq!(metadata["usage"]["outputTokens"], 1); + assert_rate_limit_header(&metadata, "x-codex-primary-used-percent", "44"); + assert_rate_limit_header(&metadata, "x-codex-primary-window-minutes", "5"); +} diff --git a/crates/server/tests/openai_chat_upstream/openai_usage_logging.rs b/crates/server/tests/openai_chat_upstream/openai_usage_logging.rs new file mode 100644 index 000000000..8924e300e --- /dev/null +++ b/crates/server/tests/openai_chat_upstream/openai_usage_logging.rs @@ -0,0 +1,676 @@ +use super::*; + +#[tokio::test] +async fn responses_should_use_imported_account_record_usage_cookie_and_event_log() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .and(header("chatgpt-account-id", "chatgpt-account")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header( + "set-cookie", + "cf_clearance=new; Domain=.chatgpt.com; Path=/", + ) + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-request-id", "req_non_stream_usage_log") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_usage"); + assert_eq!(body["usage"]["input_tokens"], 7); + let usage: (i64, i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens, cached_tokens from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(usage, (1, 7, 4, 2)); + let cookie_header = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32])) + .cookie_header("acct_chat", "chatgpt.com") + .await + .unwrap(); + assert_eq!(cookie_header.as_deref(), Some("cf_clearance=new")); + let event = latest_response_event_log(&pool).await; + let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); + assert_eq!(event.level, "info"); + assert_eq!( + event.request_id.as_deref(), + Some("req_non_stream_usage_log") + ); + assert_eq!(event.account_id.as_deref(), Some("acct_chat")); + assert_eq!(event.status_code, Some(200)); + assert_eq!(metadata["stream"], false); + assert_eq!(metadata["usage"]["inputTokens"], 7); +} + +#[tokio::test] +async fn responses_should_skip_event_log_when_logging_disabled() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-request-id", "req_non_stream_no_log") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_event_log_count(&pool).await, 0); +} + +#[tokio::test] +async fn responses_should_record_image_generation_usage_when_tool_succeeds() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_IMAGE_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false, + "tools": [{"type": "image_generation"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let usage: (i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64) = + sqlx::query_as( + "select request_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, window_image_input_tokens, window_image_output_tokens, window_image_request_count, window_image_request_failed_count from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(usage, (1, 7, 4, 2, 31, 9, 1, 0, 31, 9, 1, 0)); +} + +#[tokio::test] +async fn responses_should_record_failed_image_generation_attempt_when_tool_has_no_output() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false, + "tools": [{"type": "image_generation"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let usage: (i64, i64, i64, i64) = sqlx::query_as( + "select image_request_count, image_request_failed_count, window_image_request_count, window_image_request_failed_count from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(usage, (0, 1, 0, 1)); +} + +#[tokio::test] +async fn responses_should_passively_cache_rate_limit_headers() { + let server = MockServer::start().await; + let reset_at = Utc::now().timestamp() + 300; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header("x-codex-primary-used-percent", "100") + .insert_header("x-codex-primary-window-minutes", "5") + .insert_header("x-codex-primary-reset-at", reset_at.to_string()) + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + sqlx::query( + r#"update accounts set quota_json = '{"credits":{"has_credits":true,"unlimited":false,"balance":12}}', quota_verify_required = 1 where id = ?"#, + ) + .bind("acct_chat") + .execute(&pool) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let stored: (String, i64, Option, i64) = sqlx::query_as( + "select quota_json, quota_limit_reached, quota_cooldown_until, quota_verify_required from accounts where id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + let quota: Value = serde_json::from_str(&stored.0).unwrap(); + assert_eq!(quota["rate_limit"]["limit_reached"], true); + assert_eq!(quota["rate_limit"]["reset_at"], reset_at); + assert_eq!(quota["credits"]["balance"], 12); + assert_eq!(stored.1, 1); + assert!(stored.2.is_some()); + assert_eq!(stored.3, 0); + let window: (i64, i64, i64, i64, Option, Option, Option) = + sqlx::query_as( + "select window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(window.0, 1); + assert_eq!(window.1, 7); + assert_eq!(window.2, 4); + assert_eq!(window.3, 2); + assert!(window.4.is_some()); + assert_eq!( + DateTime::parse_from_rfc3339(window.5.as_deref().unwrap()) + .unwrap() + .timestamp(), + reset_at + ); + assert_eq!(window.6, Some(300)); +} + +#[tokio::test] +async fn responses_should_record_empty_response_attempts() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_EMPTY_COMPLETED_SSE), + ) + .expect(3) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let usage: (i64, i64, i64, i64) = sqlx::query_as( + "select request_count, empty_response_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(usage, (3, 3, 0, 0)); +} + +#[tokio::test] +async fn responses_stream_should_proxy_sse_and_record_usage() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response_text(response).await; + let usage: (i64, i64, i64) = sqlx::query_as( + "select request_count, input_tokens, output_tokens from account_usage where account_id = ?", + ) + .bind("acct_chat") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert!(content_type.starts_with("text/event-stream")); + assert!(body.contains("event: response.completed")); + assert!(body.contains("resp_stream_usage")); + assert!( + body.ends_with("data: [DONE]\n\n"), + "stream responses should terminate clients, body was {body:?}" + ); + assert_eq!(usage, (1, 3, 5)); +} + +#[tokio::test] +async fn responses_stream_should_record_event_log_after_completed_stream() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header("retry-after", "7") + .insert_header("x-ratelimit-limit-requests", "99") + .set_body_string(RESPONSES_STREAM_USAGE_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-request-id", "req_stream_completed_log") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "stream with log"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_text(response).await; + let event = latest_response_event_log(&pool).await; + let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); + + assert!(body.contains("resp_stream_usage")); + assert_eq!(event.level, "info"); + assert_eq!( + event.request_id.as_deref(), + Some("req_stream_completed_log") + ); + assert_eq!(event.account_id.as_deref(), Some("acct_chat")); + assert_eq!(event.status_code, Some(200)); + assert_eq!(metadata["stream"], true); + assert_eq!(metadata["completed"], true); + assert_eq!(metadata["responseId"], "resp_stream_usage"); + assert_eq!(metadata["usage"]["inputTokens"], 3); + assert_eq!(metadata["usage"]["outputTokens"], 5); + assert_rate_limit_header(&metadata, "retry-after", "7"); + assert_rate_limit_header(&metadata, "x-ratelimit-limit-requests", "99"); + assert!(metadata.get("requestBody").is_none()); + assert!(metadata.get("responseBody").is_none()); +} + +#[tokio::test] +async fn responses_stream_should_preserve_body_metadata_when_capture_body_enabled() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_STREAM_USAGE_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = + test_app_with_account_pool_and_logging_capture_body(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-request-id", "req_stream_capture_body_log") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "stream body capture"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_text(response).await; + let event = latest_response_event_log(&pool).await; + let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); + + assert!(body.contains("resp_stream_usage")); + assert_eq!(event.level, "info"); + assert_eq!( + event.request_id.as_deref(), + Some("req_stream_capture_body_log") + ); + assert_eq!(metadata["requestBody"]["model"], "gpt-5.5"); + assert_eq!(metadata["requestBody"]["stream"], true); + assert!(metadata["responseBody"] + .as_str() + .is_some_and(|body| body.contains("resp_stream_usage"))); +} + +#[tokio::test] +async fn responses_stream_should_record_event_log_after_late_disconnect() { + let first_frame = r#"event: response.output_text.delta +data: {"delta":"partial before logged disconnect"} + +"#; + let (base_url, first_chunk_sent, close_upstream) = + spawn_chunked_sse_upstream_then_clean_close_with_headers( + first_frame, + &[ + ("retry-after", "11"), + ("x-codex-primary-used-percent", "88"), + ], + ) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(base_url).await; + let response_task = tokio::spawn(async move { + app.oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("x-request-id", "req_stream_disconnect_log") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "stream disconnect log"}], + "stream": true, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() + }); + first_chunk_sent.await.unwrap(); + let response = response_task.await.unwrap(); + close_upstream.send(()).unwrap(); + let body = response_text(response).await; + let event = latest_response_event_log(&pool).await; + let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); + + assert!(body.contains("stream_disconnected")); + assert_eq!(event.level, "error"); + assert_eq!( + event.request_id.as_deref(), + Some("req_stream_disconnect_log") + ); + assert_eq!(event.account_id.as_deref(), Some("acct_chat")); + assert_eq!(event.status_code, Some(502)); + assert_eq!(metadata["stream"], true); + assert_eq!(metadata["failed"], true); + assert_eq!(metadata["upstreamCode"], "stream_disconnected"); + assert_rate_limit_header(&metadata, "retry-after", "11"); + assert_rate_limit_header(&metadata, "x-codex-primary-used-percent", "88"); +} + +#[tokio::test] +async fn responses_should_record_request_count_when_5xx_retries_are_exhausted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": {"message": "temporary upstream failure"} + }))) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let authorizations = received_authorizations(&server).await; + let failed_usage: Option<(i64,)> = + sqlx::query_as("select request_count from account_usage where account_id = ?") + .bind("acct_chat") + .fetch_optional(&pool) + .await + .unwrap(); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!(authorizations.len(), 3, "requests: {authorizations:?}"); + assert_eq!(failed_usage.map(|row| row.0).unwrap_or_default(), 1); +} + +#[tokio::test] +async fn responses_should_record_session_affinity_for_completed_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .and(header("authorization", "Bearer access-secret")) + .and(header("chatgpt-account-id", "chatgpt-account")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .mount(&server) + .await; + + let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Say hello"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let records = SqliteSessionAffinityStore::new(pool) + .list_active(Utc::now()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(records.len(), 1); + assert_eq!(records[0].response_id, "resp_response_1"); + assert_eq!(records[0].entry.account_id, "acct_chat"); +} diff --git a/crates/xtask/src/check_architecture.rs b/crates/xtask/src/check_architecture.rs deleted file mode 100644 index aaed5d244..000000000 --- a/crates/xtask/src/check_architecture.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! 架构检查命令。 - -use crate::{repository_root, run_command, XtaskResult}; - -/// 运行架构测试。 -pub fn run() -> XtaskResult { - let root = repository_root()?; - run_command("cargo", &["test", "--test", "architecture"], &root) -} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 96f8fdf16..d1f9c3799 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -1,5 +1,4 @@ mod build_web; -mod check_architecture; mod release; use std::{ @@ -24,7 +23,6 @@ fn main() -> ExitCode { fn run() -> XtaskResult { match std::env::args().nth(1).as_deref() { Some("build-web") => build_web::run(), - Some("check-architecture") => check_architecture::run(), Some("release") => release::run(), _ => { print_usage(); @@ -34,7 +32,7 @@ fn run() -> XtaskResult { } fn print_usage() { - eprintln!("usage: cargo xtask "); + eprintln!("usage: cargo xtask "); } pub(crate) fn repository_root() -> Result { diff --git a/docs/ADMIN_INITIALIZATION_FIX.md b/docs/ADMIN_INITIALIZATION_FIX.md deleted file mode 100644 index 58456d154..000000000 --- a/docs/ADMIN_INITIALIZATION_FIX.md +++ /dev/null @@ -1,360 +0,0 @@ -# 管理员初始化功能修复 - 2026-06-13 - -## 问题描述 - -**原问题:** 无法导入测试账号进行端到端验证 - -**症状:** -- 数据库中没有管理员账号 -- config.yaml 缺少默认管理员配置 -- 无法登录管理接口导入账号 - -**根本原因:** -系统启动时未创建默认管理员账号,且配置文件未提供初始化机制。 - ---- - -## 修复方案 - -### 1. 配置层:添加默认管理员配置 - -**文件:** `config.yaml` - -```yaml -admin: - session_ttl_minutes: 1440 - default_username: admin # 新增:默认管理员用户名 - default_password: admin # 新增:默认管理员密码 -``` - -**文件:** `src/config/types.rs` - -```rust -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct AdminConfig { - pub session_ttl_minutes: u64, - #[serde(default = "default_session_cleanup_interval")] - pub session_cleanup_interval_secs: u64, - /// 默认管理员用户名(首次启动时创建) - #[serde(default = "default_admin_username")] - pub default_username: String, - /// 默认管理员密码(首次启动时创建) - #[serde(default = "default_admin_password")] - pub default_password: String, -} - -fn default_admin_username() -> String { - "admin".to_string() -} - -fn default_admin_password() -> String { - "admin".to_string() -} -``` - ---- - -### 2. 启动层:自动创建管理员 - -**文件:** `src/runtime/bootstrap.rs` - -```rust -use crate::{ - platform::{ - crypto::{CryptoError, SecretBox}, - identity::{ - admin_session::hash_admin_password, // 新增导入 - api_key::ApiKeyHasher, - error::AuthError - }, - storage::db::connect_sqlite, - }, -}; - -pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> { - // ... 其他初始化 - let pool = connect_sqlite(&config.database.url).await?; - - // 初始化默认管理员账号(如果不存在) - ensure_default_admin_exists(&pool, &config).await?; - - // ... 继续初始化 -} - -/// 确保默认管理员账号存在(首次启动时创建) -async fn ensure_default_admin_exists( - pool: &SqlitePool, - config: &AppConfig, -) -> Result<(), sqlx::Error> { - // 检查是否已存在管理员 - let count: (i64,) = sqlx::query_as("select count(*) from admin_users") - .fetch_one(pool) - .await?; - - if count.0 == 0 { - // 创建默认管理员 - let admin_id = format!("admin_{}", uuid::Uuid::new_v4().simple()); - let password_hash = hash_admin_password(&config.admin.default_password) - .map_err(|e| sqlx::Error::Protocol(format!("failed to hash password: {}", e)))?; - let now = chrono::Utc::now().to_rfc3339(); - - sqlx::query( - "insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)", - ) - .bind(&admin_id) - .bind(&password_hash) - .bind(&now) - .bind(&now) - .execute(pool) - .await?; - - tracing::info!( - username = %config.admin.default_username, - "created default admin user" - ); - } - - Ok(()) -} -``` - ---- - -## 验证流程 - -### 1. 管理员登录 - -```bash -curl -X POST http://127.0.0.1:8080/api/admin/login \ - -H "Content-Type: application/json" \ - -d '{"password":"admin"}' -``` - -**响应:** -```json -{ - "code": 200, - "message": "OK", - "data": { - "expiresAt": "2026-06-14T14:21:39.337284602+00:00" - } -} -``` - -**Set-Cookie 头:** -``` -cpr_admin_session=sess_a27875537483420ab7ba6ce4d1f91b5e; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400 -``` - ---- - -### 2. 导入测试账号 - -```bash -curl -X POST http://127.0.0.1:8080/api/admin/accounts/import \ - -H "Content-Type: application/json" \ - -H "Cookie: cpr_admin_session=sess_a27875537483420ab7ba6ce4d1f91b5e" \ - -d @accounts-export.json -``` - -**响应:** -```json -{ - "code": 200, - "message": "OK", - "data": { - "imported": 1, - "skipped": 0, - "sourceFormat": "native" - } -} -``` - ---- - -### 3. 端到端真实请求验证 - -#### 3.1 创建 API 密钥 - -```bash -curl -X POST http://127.0.0.1:8080/api/admin/api-keys \ - -H "Content-Type: application/json" \ - -H "Cookie: cpr_admin_session=sess_..." \ - -d '{"name":"test-key","label":"测试密钥"}' -``` - -**响应:** -```json -{ - "code": 200, - "data": { - "id": "key_14eea266d1a24118bae4c46c8a48e135", - "plaintext": "cpr_5kmialybsZE5aWAaXMZvjV1j8eSuK3FkxItJd740ZRc" - } -} -``` - -#### 3.2 刷新模型列表 - -```bash -curl -X POST http://127.0.0.1:8080/api/admin/refresh-models \ - -H "Cookie: cpr_admin_session=sess_..." -``` - -**响应:** -```json -{ - "code": 200, - "data": { - "refreshedPlans": 1, - "modelCount": 3, - "failedPlans": 0 - } -} -``` - -#### 3.3 发送真实请求到 ChatGPT API - -```bash -curl -X POST http://127.0.0.1:8080/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer cpr_5kmialybsZE5aWAaXMZvjV1j8eSuK3FkxItJd740ZRc" \ - -d '{ - "model": "gpt-5.5", - "messages": [{"role": "user", "content": "Say hi in 3 words"}], - "stream": false - }' -``` - -**响应:** -```json -{ - "choices": [{ - "finish_reason": "stop", - "index": 0, - "message": { - "content": "Hi there, friend!", - "role": "assistant" - } - }], - "created": 1781360607, - "id": "chatcmpl-6333924d51a741bcbe885c00b0c54910", - "model": "gpt-5.5", - "object": "chat.completion", - "usage": { - "completion_tokens": 9, - "prompt_tokens": 22, - "total_tokens": 31 - } -} -``` - -✅ **验证成功!** ChatGPT API 接受了请求头,返回正常响应,说明: -- 指纹实现正确 (26.609.41114) -- 请求头顺序正确 -- User-Agent 格式正确 -- 所有浏览器特征头部正确 - ---- - -## 测试修复 - -由于 `AdminConfig` 添加了新字段,需要修复所有测试文件中的初始化代码。 - -**影响的测试文件:** -- `tests/api/admin/*.rs` (6 个文件) -- `tests/codex_serving/*.rs` (2 个文件) -- `tests/runtime/startup.rs` -- `tests/support/*.rs` (2 个文件) - -**修复方式:** -```rust -admin: AdminConfig { - session_ttl_minutes: 1440, - session_cleanup_interval_secs: 3600, - default_username: "admin".to_string(), // 新增 - default_password: "admin".to_string(), // 新增 -}, -``` - ---- - -## 测试结果 - -### 单元测试 -```bash -$ cargo test --lib -test result: ok. 20 passed; 0 failed -``` - -### 集成测试 -```bash -$ cargo test --test admin -test result: ok. 75 passed; 0 failed - -$ cargo test --test codex_serving -test result: ok. 49 passed; 0 failed -``` - -**总计:** 144+ 测试全部通过 ✅ - ---- - -## 端到端验证总结 - -| 验证项 | 状态 | 说明 | -|--------|------|------| -| **管理员初始化** | ✅ | 首次启动自动创建,使用 config.yaml 配置 | -| **管理员登录** | ✅ | 密码正确,返回会话 cookie | -| **账号导入** | ✅ | 成功导入 2 个测试账号 | -| **API 密钥创建** | ✅ | 生成客户端密钥用于请求 | -| **模型刷新** | ✅ | 从 ChatGPT 后端获取 3 个模型 | -| **真实请求** | ✅ | 使用最新指纹成功发送请求 | -| **ChatGPT 响应** | ✅ | API 接受请求头,返回正常响应 | -| **指纹版本** | ✅ | 26.609.41114 (build 3888) | -| **账号轮换** | ✅ | 两个账号都处于 active 状态 | - ---- - -## 影响范围 - -### 修改的模块 -- `config.yaml` - 添加默认管理员配置 -- `src/config/types.rs` - AdminConfig 添加字段 -- `src/runtime/bootstrap.rs` - 启动时初始化管理员 -- `tests/**/*.rs` - 修复测试中的 AdminConfig 初始化 - -### 不变的模块 -- `src/api/admin/auth/service.rs` - 认证逻辑无需变更 -- `src/platform/identity/admin_session.rs` - 密码哈希逻辑无需变更 -- `src/platform/storage/schema.sql` - 数据库表结构无需变更 - ---- - -## 安全考虑 - -1. **密码哈希:** 使用 bcrypt 算法,默认 cost=12 -2. **会话管理:** HttpOnly + SameSite=Lax + 24小时过期 -3. **生产环境建议:** - - 通过环境变量覆盖默认密码 - - 首次登录后立即修改密码(TODO: 未实现) - - 考虑添加密码强度验证 - ---- - -## 后续改进 - -### 可选功能: -- [ ] 首次登录强制修改密码 -- [ ] 密码复杂度验证 -- [ ] 管理员账号管理(增删改查) -- [ ] 审计日志记录管理员操作 -- [ ] 支持环境变量 `ADMIN_PASSWORD` 覆盖配置 - ---- - -## 相关文档 - -- [FINGERPRINT_PROPAGATION_FIX.md](FINGERPRINT_PROPAGATION_FIX.md) - 指纹传播修复 -- [FINGERPRINT_AUTO_UPDATE.md](FINGERPRINT_AUTO_UPDATE.md) - 自动更新机制 -- [IMPLEMENTATION_COMPARISON.md](IMPLEMENTATION_COMPARISON.md) - 与 Node.js 对比 diff --git a/docs/FINGERPRINT_AUTO_UPDATE.md b/docs/FINGERPRINT_AUTO_UPDATE.md deleted file mode 100644 index bf2520f08..000000000 --- a/docs/FINGERPRINT_AUTO_UPDATE.md +++ /dev/null @@ -1,445 +0,0 @@ -# Codex Desktop 指纹自动更新实现 - -## 实现日期 -2026-06-13 - -## 背景 - -Codex Desktop 会定期发布新版本,版本号变化会影响 HTTP 指纹识别。为保持与真实客户端一致,需要实现自动同步机制。 - ---- - -## 实现方案 - -### 核心机制 - -参考 Node.js 版本实现,采用相同的更新策略: - -1. **轮询 Sparkle Appcast** - 定期检查官方更新源 -2. **自动解析版本** - 提取 `app_version`、`build_number` -3. **持久化状态** - 写入 `data/version-state.json` 和数据库 -4. **运行时生效** - 后台任务自动应用新版本 - -### 关键参数 - -| 参数 | 值 | 说明 | -|------|-----|------| -| Appcast URL | `https://persistent.oaistatic.com/codex-app-prod/appcast.xml` | Codex Desktop 官方更新源 | -| 轮询间隔 | 3 天 | 与 Node.js 版本一致 | -| 当前版本 | `26.519.81530` | app_version | -| 当前构建号 | `3178` | build_number | -| Chromium 版本 | `146` | 浏览器内核版本 | - ---- - -## 实现细节 - -### 1. UpdateChecker 核心结构 - -```rust -pub struct UpdateChecker { - db: Option, - state: Arc>, -} - -impl UpdateChecker { - pub fn new(db: Option, current_version: String, current_build: String) -> Self - pub async fn check_for_update(&self) -> Result - pub fn start_background_checker(self) -> JoinHandle<()> -} -``` - -**特性:** -- 使用 `Arc>` 实现内部状态共享 -- 返回 `JoinHandle<()>` 支持优雅关闭 -- 异步设计,不阻塞主线程 - -### 2. Appcast XML 解析 - -支持两种 Sparkle 格式: - -**属性语法:** -```xml - - - - - -``` - -**元素语法:** -```xml - - 26.519.81530 - 3178 - - -``` - -### 3. 版本状态持久化 - -**文件结构:** -``` -data/ -├── update-state.json # 最近检查状态 -├── version-state.json # 当前应用版本 -└── extracted-fingerprint.json # 可选:提取的完整指纹 -``` - -**update-state.json 格式:** -```json -{ - "last_check": "2026-06-13T10:30:00Z", - "latest_version": "26.519.81530", - "latest_build": "3178", - "download_url": "https://...", - "update_available": false, - "current_version": "26.519.81530", - "current_build": "3178" -} -``` - -**version-state.json 格式:** -```json -{ - "app_version": "26.519.81530", - "build_number": "3178", - "chromium_version": "146" -} -``` - -### 4. 数据库持久化 - -自动更新通过 `FingerprintRepository::upsert_auto_update()` 写入 `fingerprints` 表的 `auto_updated` 当前记录: - -```sql -insert into fingerprints ( - id, app_version, build_number, platform, arch, - chromium_version, user_agent_template, source, created_at -) values ('auto_updated', ?, ?, 'darwin', 'arm64', ?, ?, 'auto_update', ?) -on conflict(id) do update set - app_version = excluded.app_version, - build_number = excluded.build_number, - chromium_version = excluded.chromium_version, - created_at = excluded.created_at -``` - -**好处:** -- 启动时可以稳定加载最新 `auto_update` fingerprint -- 自动更新逻辑不再直接持有业务表 SQL -- 历史记录写入仍由 `FingerprintRepository::insert_update()` 支持 - -### 5. Chromium 版本匹配 - -如果存在 `extracted-fingerprint.json`(手动提取的完整指纹): - -```rust -fn load_matching_chromium_version(version: &str, build: &str) -> Option { - // 读取 extracted-fingerprint.json - // 匹配 app_version 和 build_number - // 返回对应的 chromium_version -} -``` - -**场景:** -- 自动更新时,先检查本地是否有匹配的提取指纹 -- 如果有,使用提取的 `chromium_version`(更准确) -- 否则,保持默认值 `"146"` - ---- - -## 集成到后台任务 - -### coordinator.rs 修改 - -```rust -pub async fn start_background_tasks( - state: &AppState, - db_pool: SqlitePool, - config: &AppConfig, -) -> BackgroundTaskCoordinator { - let mut coordinator = BackgroundTaskCoordinator::default(); - - // 指纹自动更新(3 天轮询一次) - let fingerprint = Fingerprint::default_codex_desktop(); - let update_checker = UpdateChecker::new( - Some(db_pool.clone()), - fingerprint.app_version.clone(), - fingerprint.build_number.clone(), - ); - let update_handle = update_checker.start_background_checker(); - coordinator.push("fingerprint_update", SchedulerHandle::from_join_handle(update_handle)); - tracing::info!("fingerprint update checker started"); - - // ... 其他后台任务 -} -``` - -**启动流程:** -1. 应用启动时自动创建 `UpdateChecker` -2. 立即执行首次检查(非阻塞) -3. 后台循环每 3 天检查一次 -4. 发现新版本自动应用 - ---- - -## 工作流程 - -``` -启动应用 - ↓ -创建 UpdateChecker - ↓ -立即首次检查 (async) - ↓ -[每 3 天循环] - ↓ -fetch appcast.xml - ↓ -解析版本信息 - ↓ -对比当前版本 - ↓ - 是否有更新? - ├─ 否 → 继续等待 - └─ 是 → 应用更新 - ├─ 写入 version-state.json - ├─ 更新数据库 fingerprints 表 - ├─ 更新内部状态 - └─ 记录日志 -``` - ---- - -## 日志输出 - -**启动时:** -``` -[UpdateChecker] 启动后台指纹版本检查器,间隔:3d -[UpdateChecker] 首次检查失败: HTTP 请求失败: ... (可选) -``` - -**发现更新:** -``` -[UpdateChecker] *** 发现新版本: v26.600.12345 (build 3200) — 当前: v26.519.81530 (build 3178) -[UpdateChecker] 已自动应用: v26.600.12345 (build 3200) -``` - -**定期检查:** -``` -[UpdateChecker] 定期检查失败: 获取 appcast 失败,状态码: 503 (可选) -``` - ---- - -## 错误处理 - -### UpdateError 类型 - -```rust -pub enum UpdateError { - Http(reqwest::Error), // 网络请求失败 - AppcastFetch(u16), // HTTP 非 200 响应 - AppcastParse, // XML 解析失败 - Json(serde_json::Error), // JSON 序列化失败 - Io(std::io::Error), // 文件操作失败 - Database(sqlx::Error), // 数据库操作失败 -} -``` - -### 容错策略 - -1. **网络失败** - 记录警告日志,等待下次轮询 -2. **解析失败** - 记录警告日志,不影响现有版本 -3. **文件写入失败** - 尽力而为(best-effort),不阻塞应用 -4. **数据库失败** - 记录错误,但版本状态文件仍保留 - -**设计原则:** -- 更新失败不影响核心服务运行 -- 所有错误仅记录日志,不抛出异常 -- 下次轮询会自动重试 - ---- - -## 相关文件 - -1. **src/codex/gateway/fingerprint/update_checker.rs** - - UpdateChecker 结构体 - - Appcast 解析逻辑 - - 状态持久化 - - 通过 `FingerprintRepository` 写入数据库 - -2. **src/codex/gateway/fingerprint/repository.rs** - - fingerprint 历史记录写入 - - `auto_updated` 当前记录 upsert - - 启动时加载 `auto_update` fingerprint - -3. **src/platform/storage/paths.rs** - - 数据目录路径管理 - - `data_dir()` 和 `ensure_data_dir()` - -4. **修改文件:** - - `src/codex/gateway/fingerprint/mod.rs` - 添加模块导出 - - `src/platform/storage/mod.rs` - 添加 paths 模块 - - `src/runtime/tasks/coordinator.rs` - 启动 update checker - - `src/runtime/tasks/types.rs` - 支持 JoinHandle - ---- - -## 与 Node.js 对比 - -| 特性 | Node.js | Rust | 状态 | -|------|---------|------|------| -| **Appcast URL** | `https://persistent.oaistatic.com/codex-app-prod/appcast.xml` | 同左 | ✅ 一致 | -| **轮询间隔** | 3 天 | 3 天 | ✅ 一致 | -| **XML 解析** | 支持属性/元素两种语法 | 支持属性/元素两种语法 | ✅ 一致 | -| **版本状态文件** | `data/version-state.json` | 同左 | ✅ 一致 | -| **检查状态文件** | `data/update-state.json` | 同左 | ✅ 一致 | -| **立即首次检查** | ✅ 非阻塞 | ✅ 非阻塞 | ✅ 一致 | -| **自动应用更新** | ✅ | ✅ | ✅ 一致 | -| **数据库持久化** | ❌ | ✅ | 🟢 Rust 额外功能 | -| **完整指纹提取** | ✅ (full-update.ts) | 🟡 仅匹配逻辑 | 🟡 部分实现 | - -**差异说明:** -- Node.js 有 `full-update.ts` 脚本下载 Codex.app 并提取完整指纹 -- Rust 版本暂不实现下载功能(复杂度高,收益低) -- Rust 版本保留匹配逻辑,支持手动提取的指纹 - ---- - -## 测试验证 - -### ✅ 编译验证 -```bash -cargo build -# Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.29s -``` - -### ✅ 测试验证 -``` -总计:217 tests passed -``` - -### 手动验证步骤 - -1. **检查启动日志:** - ```bash - cargo run - # 观察是否有 "[UpdateChecker] 启动后台指纹版本检查器" 日志 - ``` - -2. **模拟版本更新:** - - 修改 `Fingerprint::default_codex_desktop()` 的版本号为旧版本 - - 重启应用 - - 观察是否自动检测到新版本 - -3. **验证持久化:** - ```bash - cat ~/.local/share/codex-proxy-rs/data/update-state.json - cat ~/.local/share/codex-proxy-rs/data/version-state.json - ``` - -4. **验证数据库:** - ```sql - select * from fingerprints where id = 'auto_updated'; - ``` - ---- - -## 安全考虑 - -### 1. 网络安全 -- ✅ 使用 HTTPS(`https://persistent.oaistatic.com`) -- ✅ 30 秒请求超时 -- ✅ reqwest 默认验证 TLS 证书 - -### 2. XML 解析安全 -- ✅ 手动解析,不使用 XML 库(避免 XXE 攻击) -- ✅ 仅提取必要字段(version, build, url) -- ✅ 严格验证提取结果 - -### 3. 文件系统安全 -- ✅ 使用 `dirs::data_local_dir()` 标准目录 -- ✅ 自动创建父目录(`ensure_data_dir`) -- ✅ 权限由操作系统管理 - -### 4. 并发安全 -- ✅ 使用 `Arc>` 保护内部状态 -- ✅ 单线程后台任务,无竞态条件 -- ✅ 文件写入使用原子操作 - ---- - -## 维护指南 - -### 更新轮询间隔 - -修改 `POLL_INTERVAL` 常量: - -```rust -const POLL_INTERVAL: Duration = Duration::from_secs(1 * 24 * 60 * 60); // 1 天 -``` - -### 禁用自动更新 - -在 `coordinator.rs` 中注释相关代码: - -```rust -// let update_checker = UpdateChecker::new(...); -// coordinator.push("fingerprint_update", ...); -``` - -### 手动触发检查 - -```rust -let checker = UpdateChecker::new(Some(db), version, build); -let state = checker.check_for_update().await?; -if state.update_available { - println!("发现新版本: {}", state.latest_version.unwrap()); -} -``` - ---- - -## 已知限制 - -1. **不下载 Codex.app** - - Node.js 版本会下载完整应用并提取指纹 - - Rust 版本仅从 appcast 获取版本号 - - 影响:无法自动获取 `chromium_version` - -2. **依赖外部提取** - - 如需完整指纹,需手动提取并放置到 `data/extracted-fingerprint.json` - - 或保持默认 `chromium_version = "146"` - -3. **无 UI 通知** - - 版本更新静默进行 - - 仅通过日志通知 - -这些限制是有意的设计选择,优先保证核心功能的简洁性和可靠性。 - ---- - -## 总结 - -✅ **已实现 Codex Desktop 指纹自动更新功能** - -核心特性: -- ✅ 每 3 天自动检查官方更新源 -- ✅ 自动解析和应用新版本 -- ✅ 持久化到文件和数据库 -- ✅ 非阻塞后台任务 -- ✅ 完善的错误处理和日志 - -安全保证: -- ✅ HTTPS 通信 -- ✅ 安全的 XML 解析 -- ✅ 并发安全设计 -- ✅ 失败不影响核心服务 - -与 Node.js 参考实现对齐: -- ✅ 相同的更新源和轮询间隔 -- ✅ 相同的文件格式和目录结构 -- ✅ 相同的版本应用逻辑 - -**指纹安全基石已构建完成,可投入生产使用。** 🚀 diff --git a/docs/FINGERPRINT_FIX.md b/docs/FINGERPRINT_FIX.md deleted file mode 100644 index 7959e25f9..000000000 --- a/docs/FINGERPRINT_FIX.md +++ /dev/null @@ -1,340 +0,0 @@ -# Codex Desktop 指纹修复报告 - -## 修复日期 -2026-06-13 - -## 问题背景 - -原 Rust 实现与 Node.js 参考版本在 HTTP 指纹方面存在多个致命差异,可能导致极高封号风险。 - ---- - -## 修复的问题 - -### 🔴 P0 - 关键修复 - -#### 1. 请求头顺序错误(检测概率:99%) - -**问题:** -- Rust 使用 `BTreeMap` 导致请求头按字母排序 -- Node.js 严格按 `fingerprint.yaml` 的 `header_order` 数组排序 -- 请求头顺序是指纹识别的核心特征 - -**修复:** -```rust -// 添加依赖 -indexmap = { version = "2.0", features = ["serde"] } - -// 实现严格排序 -fn order_headers(headers: IndexMap, order: &[String]) -> IndexMap { - let mut ordered = IndexMap::new(); - for key in order { - if let Some(value) = headers.get(key) { - ordered.insert(key.clone(), value.clone()); - } - } - // 未在 order 中的头追加到末尾 - for (key, value) in headers { - if !ordered.contains_key(&key) { - ordered.insert(key, value); - } - } - ordered -} -``` - -**标准顺序(与 Node.js 一致):** -1. `authorization` -2. `chatgpt-account-id` -3. `originator` -4. `x-openai-internal-codex-residency` -5. `x-client-request-id` -6. `x-codex-installation-id` -7. `session_id` -8. `x-codex-window-id` -9. `x-codex-turn-state` -10. `x-codex-turn-metadata` -11. `x-codex-beta-features` -12. `x-responsesapi-include-timing-metrics` -13. `x-codex-parent-thread-id` -14. `version` -15. `openai-beta` -16. `user-agent` -17. `sec-ch-ua` -18. `sec-ch-ua-mobile` -19. `sec-ch-ua-platform` -20. `accept-encoding` -21. `accept-language` -22. `sec-fetch-site` -23. `sec-fetch-mode` -24. `sec-fetch-dest` -25. `content-type` -26. `accept` -27. `cookie` - -#### 2. 缺失 9 个浏览器特征头(检测概率:95%) - -**问题:** -Chromium 浏览器的标准指纹头完全缺失,直接暴露非浏览器客户端身份。 - -**修复:** -```rust -pub struct Fingerprint { - // ... 现有字段 - pub default_headers: IndexMap, - pub header_order: Vec, -} - -impl Fingerprint { - pub fn default_codex_desktop() -> Self { - let mut default_headers = IndexMap::new(); - default_headers.insert("Accept-Encoding".to_string(), "gzip, deflate, br, zstd".to_string()); - default_headers.insert("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()); - default_headers.insert("sec-ch-ua-mobile".to_string(), "?0".to_string()); - default_headers.insert("sec-ch-ua-platform".to_string(), "\"macOS\"".to_string()); - default_headers.insert("sec-fetch-site".to_string(), "same-origin".to_string()); - default_headers.insert("sec-fetch-mode".to_string(), "cors".to_string()); - default_headers.insert("sec-fetch-dest".to_string(), "empty".to_string()); - - // ... - } - - pub fn sec_ch_ua(&self) -> String { - format!("\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"", self.chromium_version) - } -} -``` - -**新增的头:** -| 请求头 | 值 | -|--------|-----| -| `Accept-Encoding` | `gzip, deflate, br, zstd` | -| `Accept-Language` | `en-US,en;q=0.9` | -| `sec-ch-ua` | `"Chromium";v="146", "Not:A-Brand";v="24"` | -| `sec-ch-ua-mobile` | `?0` | -| `sec-ch-ua-platform` | `"macOS"` | -| `sec-fetch-site` | `same-origin` | -| `sec-fetch-mode` | `cors` | -| `sec-fetch-dest` | `empty` | - -#### 3. User-Agent 格式错误(检测概率:90%) - -**修复前:** -``` -Codex/26.519.81530 (darwin; arm64) Chromium/146 -``` - -**修复后:** -``` -Codex Desktop/26.519.81530 (darwin; arm64) -``` - -**修改:** -```rust -user_agent_template: "Codex Desktop/{app_version} ({platform}; {arch})".to_string(), - -pub fn user_agent(&self) -> String { - self.user_agent_template - .replace("{app_version}", &self.app_version) - .replace("{platform}", &self.platform) - .replace("{arch}", &self.arch) - // 移除 chromium_version 替换 -} -``` - ---- - -### 🟠 P1 - 高风险修复 - -#### 4. x-openai-internal-codex-residency 不一致 - -**问题:** -- `client.rs` 使用 `"us"`(正确) -- `headers.rs` 使用 `"global"`(错误) - -**修复:** -```rust -// headers.rs 统一为 "us" -headers.insert("x-openai-internal-codex-residency".to_string(), "us".to_string()); -``` - -#### 5. Cookie 捕获策略过于宽松 - -**问题:** -- 原实现捕获所有 `Set-Cookie` 头 -- `__cf_bm` 绑定到 (IP + UA + TLS + timing),重放会导致 404 -- Node.js 仅白名单 `cf_clearance` - -**修复:** -```rust -const CAPTURABLE_COOKIES: &[&str] = &["cf_clearance"]; - -pub fn capture_set_cookie(&mut self, account_id: &str, raw: &str) { - let Some((name, _)) = name_value.split_once('=') else { - return; - }; - - // 只捕获白名单 Cookie - if !CAPTURABLE_COOKIES.contains(&name) { - return; - } - - // ... 现有逻辑 -} -``` - ---- - -## 修改的文件 - -1. **Cargo.toml** - - 添加 `indexmap = { version = "2.0", features = ["serde"] }` - -2. **src/codex/gateway/fingerprint/model.rs** - - 添加 `default_headers: IndexMap` - - 添加 `header_order: Vec` - - 实现 `sec_ch_ua()` 方法 - - 修正 `user_agent_template` 格式 - -3. **src/codex/gateway/transport/headers.rs** - - 替换 `BTreeMap` 为 `IndexMap` - - 实现 `order_headers()` 函数 - - 新增 `build_ordered_codex_headers()` 公开 API - - 添加所有浏览器特征头 - - 修正 `x-openai-internal-codex-residency` 为 `"us"` - -4. **src/codex/gateway/transport/http_client.rs** - - 更新导入:`build_codex_headers` → `build_ordered_codex_headers` - - 重构 `request_headers()` 使用排序后的头 - -5. **src/codex/accounts/cookies/jar.rs** - - 添加 `CAPTURABLE_COOKIES` 白名单常量 - - 实现白名单过滤逻辑 - -6. **tests/codex_serving/diagnostics_route.rs** - - 更新测试断言:User-Agent 格式 - ---- - -## 验证结果 - -### ✅ 编译验证 -```bash -cargo build -# Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.72s -``` - -### ✅ 测试验证 -``` -running 20 tests (unit) -test result: ok. 20 passed - -running 75 tests (admin) -test result: ok. 75 passed - -running 49 tests (codex_serving) -test result: ok. 49 passed - -总计:217 tests passed -``` - -### ✅ 与 Node.js 对比 - -| 特征 | Node.js | Rust (修复后) | 状态 | -|------|---------|---------------|------| -| TLS 指纹 | reqwest 0.12.28 + rustls 0.23.36 | reqwest 0.12.28 + rustls 0.23.36 | ✅ 一致 | -| 请求头顺序 | 严格按 `header_order` 排序 | 严格按 `header_order` 排序 | ✅ 一致 | -| User-Agent | `Codex Desktop/26.519.81530 (darwin; arm64)` | `Codex Desktop/26.519.81530 (darwin; arm64)` | ✅ 一致 | -| sec-ch-ua | `"Chromium";v="146", "Not:A-Brand";v="24"` | `"Chromium";v="146", "Not:A-Brand";v="24"` | ✅ 一致 | -| sec-* 头 | 7 个标准头 | 7 个标准头 | ✅ 一致 | -| Accept-Encoding | `gzip, deflate, br, zstd` | `gzip, deflate, br, zstd` | ✅ 一致 | -| Accept-Language | `en-US,en;q=0.9` | `en-US,en;q=0.9` | ✅ 一致 | -| x-openai-internal-codex-residency | `us` | `us` | ✅ 一致 | -| Cookie 捕获 | 仅 `cf_clearance` | 仅 `cf_clearance` | ✅ 一致 | -| x-codex-* 头 | 完整支持 | 完整支持 | ✅ 一致 | - ---- - -## 风险评估 - -### 修复前风险等级:🔴 极高 - -- 请求头顺序错误:**99% 检测概率** -- 缺失浏览器特征头:**95% 检测概率** -- User-Agent 格式错误:**90% 检测概率** - -**总体封号概率:接近 100%** - -### 修复后风险等级:🟢 低 - -- 所有关键指纹特征与 Node.js 版本完全一致 -- TLS 层使用相同版本的 reqwest + rustls -- 请求头顺序、内容、格式严格对齐 -- Cookie 策略符合 Cloudflare 最佳实践 - -**预期封号率:与 Node.js 版本相当** - ---- - -## 测试建议 - -### 1. 功能测试 -- 使用抓包工具(Wireshark/Charles)对比 Rust 和 Node.js 版本的请求头 -- 验证请求头顺序与 Node.js 完全一致 -- 验证所有 `sec-*` 头都存在且值正确 - -### 2. 集成测试 -- 使用真实账号测试 10-20 个请求 -- 观察是否触发 Cloudflare 验证 -- 检查 `cf_clearance` Cookie 是否被正确捕获和重放 - -### 3. 压力测试 -- 并发 50+ 请求测试连接池稳定性 -- 长时间运行(24 小时)观察封号率 -- 对比 Node.js 版本的封号率 - ---- - -## 已知限制 - -### 🟡 P2 级别(低优先级) - -1. **缺少代理支持** - - Node.js 支持 SOCKS/HTTP 代理 - - Rust 使用 `no_proxy()` - - 影响:无法通过代理分散流量 - -2. **连接池未显式配置** - - Node.js 显式设置 `pool_max_idle_per_host(4)` 和 `tcp_keepalive(30s)` - - Rust 使用 reqwest 默认配置 - - 影响:连接行为可能略有不同 - -这些限制对指纹识别的影响较小,可在后续迭代中优化。 - ---- - -## 总结 - -✅ **所有 P0 和 P1 级别的指纹问题已修复** - -核心修复: -1. ✅ 实现严格的请求头排序(IndexMap) -2. ✅ 添加 9 个浏览器特征头 -3. ✅ 修正 User-Agent 格式 -4. ✅ 统一 x-openai-internal-codex-residency 值 -5. ✅ 实现 Cookie 白名单捕获 - -代码质量: -- ✅ 所有 217 个测试通过 -- ✅ 编译无警告 -- ✅ 符合 Rust 最佳实践 - -现在 Rust 版本的 HTTP 指纹已达到 Node.js 参考实现的水平,可以投入生产环境测试。 - ---- - -## 参考资料 - -- Node.js 参考实现:`/home/zyy/桌面/Codes/codex-proxy` -- 指纹对比分析:由 AI 子代理生成(2026-06-13) -- TLS 指纹说明:reqwest 0.12.28 使用 rustls 0.23.36,与真实 Codex Desktop 一致 diff --git a/docs/FINGERPRINT_PROPAGATION_FIX.md b/docs/FINGERPRINT_PROPAGATION_FIX.md deleted file mode 100644 index e32445274..000000000 --- a/docs/FINGERPRINT_PROPAGATION_FIX.md +++ /dev/null @@ -1,412 +0,0 @@ -# 指纹传播修复 - 2026-06-13 - -## 问题描述 - -自动更新机制成功从 appcast 获取并写入数据库,但实际请求仍使用硬编码的默认指纹。 - -**症状:** -- 数据库有新版本 `26.609.41114` (build `3888`) -- 日志显示 "loaded fingerprint from database" -- 但诊断接口返回旧版本 `26.519.81530` (build `3178`) -- 实际 HTTP 请求使用旧指纹 - -**根本原因:** - -架构分离 - 两条独立的指纹加载路径: -1. `coordinator.rs` 加载指纹 → 仅用于 UpdateChecker -2. `AppState` 服务 → 硬编码默认指纹 → 用于实际请求 - -``` -coordinator.rs: - load_latest_auto_updated() → UpdateChecker ✅ - -bootstrap.rs: - build_state() → AppState - → CodexUpstreamService::new() - → send_codex_request() - → Fingerprint::default_codex_desktop() ❌ 硬编码 -``` - ---- - -## 修复方案 - -### 架构变更 - -将指纹加载提前到 `bootstrap.rs`,并通过依赖注入传递到服务层。 - -``` -bootstrap.rs: - load_latest_auto_updated() → Fingerprint - → AppState::new(fingerprint) - → CodexUpstreamService::new(fingerprint) - → send_codex_request() - → deps.fingerprint ✅ 使用注入的指纹 -``` - ---- - -## 修改的文件 - -### 1. `src/runtime/bootstrap.rs` - -**变更:** 启动时加载指纹并传递 - -```rust -pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> { - // ... 其他初始化 - let pool = connect_sqlite(&config.database.url).await?; - - // 加载指纹:优先数据库 auto_update,否则使用默认 - let fingerprint_repo = FingerprintRepository::new(pool.clone()); - let fingerprint = match fingerprint_repo.load_latest_auto_updated().await { - Ok(Some(fp)) => { - tracing::info!( - version = %fp.app_version, - build = %fp.build_number, - source = "database", - "loaded fingerprint for requests" - ); - fp - } - Ok(None) => { - let fp = Fingerprint::default_codex_desktop(); - tracing::info!( - version = %fp.app_version, - build = %fp.build_number, - source = "default", - "using default fingerprint for requests" - ); - fp - } - Err(e) => { - tracing::warn!(error = %e, "failed to load fingerprint from database, using default"); - Fingerprint::default_codex_desktop() - } - }; - - // 传递指纹到 AppState - let state = AppState::with_pool_secret_api_key_hasher_oauth_client_and_fingerprint( - config, - pool.clone(), - secret_box, - api_key_hasher, - oauth_client, - fingerprint, // ← 新参数 - ); - - let restored_accounts = state.reload_account_pool_from_repository().await?; - Ok((state, pool, restored_accounts)) -} -``` - -### 2. `src/runtime/state.rs` - -**变更:** 添加 `fingerprint` 字段和新的构造函数 - -```rust -#[derive(Default)] -struct AppStateDependencies { - pool: Option, - secret_box: Option, - api_key_hasher: Option, - token_refresher: Option>, - oauth_client: Option>, - local_config_path: Option, - fingerprint: Option, // ← 新增 -} - -// 新增构造函数 -pub fn with_pool_secret_api_key_hasher_oauth_client_and_fingerprint( - config: AppConfig, - pool: SqlitePool, - secret_box: SecretBox, - api_key_hasher: ApiKeyHasher, - oauth_client: C, - fingerprint: Fingerprint, // ← 新参数 -) -> Self -where - C: OAuthClient + TokenRefresher, -{ - let oauth_client = Arc::new(oauth_client); - let token_refresher: Arc = oauth_client.clone(); - let oauth_client: Arc = oauth_client; - Self::from_dependencies( - config, - AppStateDependencies { - pool: Some(pool), - secret_box: Some(secret_box), - api_key_hasher: Some(api_key_hasher), - token_refresher: Some(token_refresher), - oauth_client: Some(oauth_client), - fingerprint: Some(fingerprint), // ← 传递 - ..AppStateDependencies::default() - }, - ) -} - -// 修改 v1_services 签名 -fn v1_services( - config: Arc, - pool: Option<&SqlitePool>, - secret_box: Option<&SecretBox>, - token_refresher: Option>, - account_pool: Arc>, - fingerprint: Fingerprint, // ← 新参数 -) -> V1Services { - let upstream = codex_upstream_service( - config.clone(), - pool, - secret_box, - token_refresher, - account_pool.clone(), - fingerprint, // ← 传递 - ); - // ... -} - -fn codex_upstream_service( - config: Arc, - pool: Option<&SqlitePool>, - secret_box: Option<&SecretBox>, - token_refresher: Option>, - account_pool: Arc>, - fingerprint: Fingerprint, // ← 新参数 -) -> CodexUpstreamService { - CodexUpstreamService::new( - config, - account_pool, - account_repository(pool, secret_box), - cookie_repository(pool, secret_box), - pool.cloned().map(EventLogRepository::new), - token_refresher, - fingerprint, // ← 传递 - ) -} -``` - -### 3. `src/codex/serving/dispatch/mod.rs` - -**变更:** 存储并使用指纹 - -```rust -#[derive(Clone)] -struct CodexUpstreamDependencies { - config: Arc, - account_pool: Arc>, - account_repository: Option, - cookie_repository: Option, - event_logs: Option, - token_refresher: Option>, - fingerprint: Fingerprint, // ← 新增:用于实际请求的指纹 -} - -impl CodexUpstreamService { - pub(crate) fn new( - config: Arc, - account_pool: Arc>, - account_repository: Option, - cookie_repository: Option, - event_logs: Option, - token_refresher: Option>, - fingerprint: Fingerprint, // ← 新参数 - ) -> Self { - Self { - deps: CodexUpstreamDependencies { - config, - account_pool, - account_repository, - cookie_repository, - event_logs, - token_refresher, - fingerprint, // ← 存储 - }, - } - } - - /// 获取当前使用的指纹(用于诊断) - pub(crate) fn fingerprint(&self) -> &Fingerprint { - &self.deps.fingerprint - } -} - -// 实际请求构造 - send_codex_request -async fn send_codex_request(...) -> Result<...> { - // ... - let client = CodexBackendClient::new( - build_reqwest_client(deps.config.tls.force_http11)?, - deps.config.api.base_url.clone(), - deps.fingerprint.clone(), // ← 使用存储的指纹 - ); - client.create_response(...).await -} - -// 实际请求构造 - send_codex_stream_request -async fn send_codex_stream_request(...) -> Result<...> { - // ... - let client = CodexBackendClient::new( - build_reqwest_client(deps.config.tls.force_http11)?, - deps.config.api.base_url.clone(), - deps.fingerprint.clone(), // ← 使用存储的指纹 - ); - client.stream_response(...).await -} -``` - -### 4. `src/codex/serving/responses.rs` - -**变更:** 暴露指纹给诊断接口 - -```rust -impl ResponsesService { - // ... - - /// 获取上游使用的指纹(用于诊断) - pub fn upstream_fingerprint(&self) -> &Fingerprint { - self.upstream.fingerprint() - } -} -``` - -### 5. `src/codex/serving/http/diagnostics.rs` - -**变更:** 从实际服务获取指纹 - -```rust -pub async fn debug_fingerprint( - State(state): State, // ← 添加 State 参数 - headers: HeaderMap, -) -> impl IntoResponse { - if !is_local_debug_request(&headers) { - return ( - StatusCode::FORBIDDEN, - Json(json!({ "error": "debug endpoint is local-only" })), - ) - .into_response(); - } - - // 从实际服务中获取指纹 - let fingerprint = state.services.responses.upstream_fingerprint(); - - ( - StatusCode::OK, - Json(fingerprint_diagnostics(fingerprint.clone())), - ) - .into_response() -} -``` - ---- - -## 验证结果 - -### 1. 诊断接口测试 - -```bash -$ curl -s http://127.0.0.1:8080/debug/fingerprint -{ - "source": "staticDefault", - "originator": "Codex Desktop", - "appVersion": "26.609.41114", # ✅ 新版本 - "buildNumber": "3888", - "platform": "darwin", - "arch": "arm64", - "chromiumVersion": "146", - "userAgent": "Codex Desktop/26.609.41114 (darwin; arm64)" -} -``` - -### 2. 测试覆盖 - -```bash -$ cargo test --lib -test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out - -$ cargo test --test '*' -test result: ok. 82 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out -``` - -**总计:** 217 个测试全部通过 ✅ - -### 3. 实际请求验证 - -实际 HTTP 请求现在使用的 User-Agent: -``` -Codex Desktop/26.609.41114 (darwin; arm64) -``` - -而不是之前的: -``` -Codex Desktop/26.519.81530 (darwin; arm64) -``` - ---- - -## 影响范围 - -### 修改的模块 -- `runtime/bootstrap.rs` - 启动流程 -- `runtime/state.rs` - 状态管理 -- `codex/serving/dispatch/mod.rs` - 上游服务 -- `codex/serving/responses.rs` - 响应服务 -- `codex/serving/http/diagnostics.rs` - 诊断接口 - -### 不变的模块 -- `codex/gateway/fingerprint/update_checker.rs` - 自动更新逻辑 -- `codex/gateway/fingerprint/repository.rs` - 数据库操作 -- `codex/gateway/transport/client.rs` - HTTP 客户端 -- `codex/gateway/transport/headers.rs` - 请求头构造 - -### 测试影响 -- ✅ 所有现有测试继续通过 -- ✅ 无需修改测试代码 -- ✅ 测试中使用默认指纹(符合预期) - ---- - -## 设计原则 - -1. **单一数据源** - 数据库是指纹的唯一真实来源,启动时一次性加载 - -2. **依赖注入** - 指纹通过构造函数传递,避免全局状态和异步阻塞 - -3. **最小变更** - 仅修改必要的接口,保持向后兼容 - -4. **清晰的所有权** - 指纹由 `CodexUpstreamDependencies` 持有,生命周期明确 - -5. **可测试性** - 测试代码可以显式传入指纹,无需模拟数据库 - ---- - -## 后续工作 - -### 需要真实账号验证的功能: - -1. **请求头对齐验证** - 使用抓包工具验证实际发送到 ChatGPT API 的请求头是否正确 - -2. **安全检测测试** - 确认更新后的指纹不会触发 Cloudflare 或 OpenAI 的安全检测 - -3. **账号状态监控** - 观察使用新指纹后账号是否出现异常(封禁、限流等) - -### 可选增强: - -- 指纹变更通知(webhook/日志) -- 手动触发更新检查(管理接口) -- 指纹历史记录查询 -- 版本回滚支持 - ---- - -## 相关文档 - -- [FINGERPRINT_FIX.md](FINGERPRINT_FIX.md) - P0 指纹修复总览 -- [FINGERPRINT_AUTO_UPDATE.md](FINGERPRINT_AUTO_UPDATE.md) - 自动更新机制 -- [FINGERPRINT_UPDATE_TEST_REPORT.md](FINGERPRINT_UPDATE_TEST_REPORT.md) - 完整测试报告 diff --git a/docs/FINGERPRINT_UPDATE_TEST_REPORT.md b/docs/FINGERPRINT_UPDATE_TEST_REPORT.md deleted file mode 100644 index ac62fa9e9..000000000 --- a/docs/FINGERPRINT_UPDATE_TEST_REPORT.md +++ /dev/null @@ -1,367 +0,0 @@ -# 指纹自动更新测试报告 - -## 测试时间 -2026-06-13 - -## 测试环境 -- 数据库:全新初始化(旧数据库已备份) -- 服务端口:8080 -- 日志级别:INFO - ---- - -## ✅ 已验证的功能 - -### 1. 指纹自动更新机制 - -**日志证据:** -```json -{"timestamp":"2026-06-13T13:28:58.095403Z","level":"INFO", - "message":"[UpdateChecker] *** 发现新版本: v26.609.41114 (build 3888) — 当前: v26.519.81530 (build 3178)"} - -{"timestamp":"2026-06-13T13:28:58.096579Z","level":"INFO", - "message":"[UpdateChecker] 已自动应用: v26.609.41114 (build 3888)"} -``` - -**结果:** ✅ **自动更新成功!** -- 轮询官方 appcast:成功 -- 检测到新版本:`26.609.41114` (build `3888`) -- 写入数据库:成功 -- 更新内存状态:成功 - -### 2. 数据库持久化 - -**日志证据:** -```json -{"timestamp":"2026-06-13T13:30:57.962438Z","level":"INFO", - "message":"loaded fingerprint from database (auto_update)","version":"26.609.41114","build":"3888"} -``` - -**结果:** ✅ **重启后从数据库加载成功!** -- 优先级:数据库 > 默认配置 ✅ -- 数据一致性:版本号正确 ✅ -- 启动日志清晰:显示来源和版本 ✅ - -### 3. 后台任务调度 - -**启动日志:** -``` -loaded fingerprint from database (auto_update) -fingerprint update checker started -[UpdateChecker] 启动后台指纹版本检查器,间隔:259200s -refresh scheduler started -session cleanup scheduler started -quota refresher started -model refresher started -``` - -**结果:** ✅ **所有后台任务正常启动!** - ---- - -## ✅ 问题已修复(2026-06-13) - -### 问题 1:诊断接口显示旧版本 → **已修复** - -**之前现象:** -```bash -$ curl http://127.0.0.1:8080/debug/fingerprint -{ - "appVersion": "26.519.81530", # 旧版本 - "buildNumber": "3178" -} -``` - -**修复后:** -```bash -$ curl http://127.0.0.1:8080/debug/fingerprint -{ - "appVersion": "26.609.41114", # ✅ 新版本 - "buildNumber": "3888" -} -``` - -**根本原因:** - -架构分离问题: -1. `coordinator.rs` 中加载指纹 → 用于 UpdateChecker -2. `AppState` 中的服务 → 使用硬编码默认指纹 -3. 实际请求构造时 → 直接调用 `Fingerprint::default_codex_desktop()` - -**修复方案:** - -采用方案 1 - 在 bootstrap 时加载并传递指纹: - -1. **`bootstrap.rs`** - 启动时加载指纹 -```rust -pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> { - // ... - let pool = connect_sqlite(&config.database.url).await?; - - // 加载指纹:优先数据库 auto_update,否则使用默认 - let fingerprint_repo = FingerprintRepository::new(pool.clone()); - let fingerprint = match fingerprint_repo.load_latest_auto_updated().await { - Ok(Some(fp)) => { - tracing::info!(version = %fp.app_version, build = %fp.build_number, - source = "database", "loaded fingerprint for requests"); - fp - } - Ok(None) => { - let fp = Fingerprint::default_codex_desktop(); - tracing::info!(version = %fp.app_version, build = %fp.build_number, - source = "default", "using default fingerprint for requests"); - fp - } - Err(e) => { - tracing::warn!(error = %e, "failed to load fingerprint from database, using default"); - Fingerprint::default_codex_desktop() - } - }; - - let state = AppState::with_pool_secret_api_key_hasher_oauth_client_and_fingerprint( - config, pool.clone(), secret_box, api_key_hasher, oauth_client, fingerprint, - ); - // ... -} -``` - -2. **`state.rs`** - 传递指纹到服务 -```rust -struct AppStateDependencies { - // ... - fingerprint: Option, // ← 新增 -} - -fn v1_services( - config: Arc, - pool: Option<&SqlitePool>, - secret_box: Option<&SecretBox>, - token_refresher: Option>, - account_pool: Arc>, - fingerprint: Fingerprint, // ← 新增参数 -) -> V1Services { - let upstream = codex_upstream_service( - config.clone(), pool, secret_box, token_refresher, - account_pool.clone(), fingerprint, // ← 传递 - ); - // ... -} -``` - -3. **`dispatch/mod.rs`** - 存储并使用指纹 -```rust -struct CodexUpstreamDependencies { - // ... - fingerprint: Fingerprint, // ← 新增:用于实际请求的指纹 -} - -impl CodexUpstreamService { - pub(crate) fn new( - config: Arc, - // ... - fingerprint: Fingerprint, // ← 新增参数 - ) -> Self { - Self { - deps: CodexUpstreamDependencies { - config, account_pool, // ... - fingerprint, // ← 存储 - }, - } - } - - pub(crate) fn fingerprint(&self) -> &Fingerprint { - &self.deps.fingerprint - } -} - -// 实际请求构造时使用 -async fn send_codex_stream_request(...) -> Result<...> { - let client = CodexBackendClient::new( - build_reqwest_client(deps.config.tls.force_http11)?, - deps.config.api.base_url.clone(), - deps.fingerprint.clone(), // ← 使用存储的指纹,而非硬编码默认值 - ); - // ... -} -``` - -4. **`responses.rs` + `http/diagnostics.rs`** - 诊断接口返回实际指纹 -```rust -impl ResponsesService { - pub fn upstream_fingerprint(&self) -> &Fingerprint { - self.upstream.fingerprint() - } -} - -pub async fn debug_fingerprint( - State(state): State, // ← 添加 State 参数 - headers: HeaderMap, -) -> impl IntoResponse { - // ... - let fingerprint = state.services.responses.upstream_fingerprint(); - (StatusCode::OK, Json(fingerprint_diagnostics(fingerprint.clone()))) - .into_response() -} -``` - -**修复范围:** -- ✅ 实际 HTTP 请求使用数据库指纹 -- ✅ 诊断接口显示正确版本 -- ✅ 数据库更新正常 -- ✅ UpdateChecker 状态正常 -- ✅ 所有 217 个测试通过 - ---- - -## 测试覆盖度 - -| 功能 | 状态 | 说明 | -|------|------|------| -| **指纹自动更新** | ✅ 100% | appcast 轮询、解析、更新全流程 | -| **数据库持久化** | ✅ 100% | 写入、读取、优先级 | -| **后台任务调度** | ✅ 100% | 启动、日志、间隔 | -| **实际请求使用** | ✅ 100% | CodexClient 使用数据库指纹 | -| **诊断接口正确性** | ✅ 100% | 显示实际使用的指纹 | - ---- - -## 无法测试的功能(缺少账号) - -由于数据库中没有可用账号,以下功能无法测试: - -1. **实际请求头对齐** - - 需要发送真实请求到 ChatGPT API - - 验证 User-Agent、sec-* 头等 - -2. **指纹更新后的请求影响** - - 新旧版本请求头对比 - - 上游响应变化 - -3. **配额和用量统计** - - 请求计数 - - Token 统计 - -4. **Cookie 捕获和重放** - - cf_clearance 处理 - - Cloudflare 验证流程 - -5. **账号轮换和并发控制** - - 负载均衡 - - 限流策略 - ---- - -## 总结 - -### ✅ 成功部分(100%) - -1. **核心机制完整** - - 3 天轮询 appcast ✅ - - 自动解析版本号 ✅ - - 数据库持久化 ✅ - - 重启后加载 ✅ - - 实际请求使用数据库指纹 ✅ - -2. **与 Node.js 对齐** - - 相同的更新源 ✅ - - 相同的轮询间隔 ✅ - - 相同的文件格式 ✅ - - 相同的架构模式 ✅ - -3. **日志和监控** - - 清晰的日志输出 ✅ - - 版本变更可追踪 ✅ - - 错误处理完善 ✅ - - 诊断接口显示实际版本 ✅ - -4. **测试覆盖** - - 217 个测试全部通过 ✅ - - 单元测试覆盖核心逻辑 ✅ - - 集成测试验证完整流程 ✅ - -### 🎉 已修复的问题 - -1. **实际请求使用数据库指纹** ✅ - - 修改 `AppState` 初始化流程 - - 将数据库指纹传递给 `CodexUpstreamService` - - 实际请求构造时使用传递的指纹 - -2. **诊断接口显示正确版本** ✅ - - 从实际服务中获取指纹 - - 返回真实使用的版本号 - -### 后续优化(可选) - -**P2(未来增强):** -- 添加指纹变更通知(例如 webhook) -- 支持手动触发检查(管理接口) -- 指纹历史记录查询 -- 版本回滚支持 - ---- - -## 已完成的修复步骤 - -1. **修复 AppState 指纹传递** ✅ - - 修改 `bootstrap.rs` - 启动时加载指纹 - - 修改 `state.rs` - 添加 `fingerprint` 字段和构造函数 - - 修改 `CodexUpstreamService::new()` - 接收并存储指纹 - - 修改 `send_codex_request/send_codex_stream_request` - 使用存储的指纹 - -2. **修复诊断接口** ✅ - - 修改 `responses.rs` - 添加 `upstream_fingerprint()` 方法 - - 修改 `http/diagnostics.rs` - 从 AppState 获取实际指纹 - - 路由签名添加 `State(state)` 参数 - -3. **验证修复** ✅ - - 重启服务 ✅ - - 检查诊断接口 ✅ - 显示 `26.609.41114` - - 所有测试通过 ✅ - 217 个测试 - -## 下一步行动(需要账号) - -1. **完整测试(需要账号)** - - 添加测试账号到数据库 - - 发送真实请求到 ChatGPT API - - 抓包验证请求头(User-Agent, sec-* 等) - - 观察 ChatGPT 响应和账号状态 - - 确认不会触发安全检测/封号 - ---- - -## 附录:测试日志片段 - -### 成功的自动更新 -``` -2026-06-13T13:28:56.846692Z WARN [UpdateChecker] 首次检查失败: HTTP 请求失败 -2026-06-13T13:28:58.095403Z INFO [UpdateChecker] *** 发现新版本: v26.609.41114 (build 3888) -2026-06-13T13:28:58.096579Z INFO [UpdateChecker] 已自动应用: v26.609.41114 (build 3888) -``` - -### 重启后加载数据库指纹 -``` -2026-06-13T13:30:57.962438Z INFO loaded fingerprint from database (auto_update) version="26.609.41114" build="3888" -2026-06-13T13:30:57.962485Z INFO fingerprint update checker started -``` - -### 当前 API 响应(错误) -```json -{ - "fingerprint": { - "source": "staticDefault", - "appVersion": "26.519.81530", - "buildNumber": "3178" - } -} -``` - -**期望响应:** -```json -{ - "fingerprint": { - "source": "database", - "appVersion": "26.609.41114", - "buildNumber": "3888" - } -} -``` diff --git a/docs/IMPLEMENTATION_COMPARISON.md b/docs/IMPLEMENTATION_COMPARISON.md deleted file mode 100644 index 76e6d7d60..000000000 --- a/docs/IMPLEMENTATION_COMPARISON.md +++ /dev/null @@ -1,328 +0,0 @@ -# Rust vs Node.js 实现对比 - -## 指纹头部构造对比 - -### Node.js 实现 (`src/fingerprint/manager.ts`) - -```typescript -function buildUserAgent(config: AppConfig, fp: FingerprintConfig): string { - return fp.user_agent_template - .replace("{version}", config.client.app_version) - .replace("{platform}", config.client.platform) - .replace("{arch}", config.client.arch); -} - -function buildSecChUa(config: AppConfig): string { - const cv = config.client.chromium_version; - return `"Chromium";v="${cv}", "Not:A-Brand";v="24"`; -} - -function buildRawDefaultHeaders(config: AppConfig, fp: FingerprintConfig): Record { - const raw: Record = {}; - - raw["User-Agent"] = buildUserAgent(config, fp); - raw["sec-ch-ua"] = buildSecChUa(config); - - // Add static default headers - if (fp.default_headers) { - for (const [key, value] of Object.entries(fp.default_headers)) { - raw[key] = value; - } - } - - return raw; -} - -function orderHeaders( - headers: Record, - order: string[], -): Record { - const ordered: Record = {}; - for (const key of order) { - if (key in headers) { - ordered[key] = headers[key]; - } - } - for (const key of Object.keys(headers)) { - if (!(key in ordered)) { - ordered[key] = headers[key]; - } - } - return ordered; -} - -export function buildHeadersWithContentType( - token: string, - accountId?: string | null, - ctx?: AppContext, -): Record { - const { config, fp } = resolve(ctx); - const raw: Record = {}; - - raw["Authorization"] = `Bearer ${token}`; - - const acctId = accountId ?? extractChatGptAccountId(token); - if (acctId) raw["ChatGPT-Account-Id"] = acctId; - - raw["originator"] = config.client.originator; - - // Merge default headers - const defaults = buildRawDefaultHeaders(config, fp); - for (const [key, value] of Object.entries(defaults)) { - raw[key] = value; - } - - raw["Content-Type"] = "application/json"; - - return orderHeaders(raw, fp.header_order); -} -``` - -### Rust 实现 (`src/codex/gateway/fingerprint/model.rs` + `transport/headers.rs`) - -```rust -impl Fingerprint { - pub fn user_agent(&self) -> String { - self.user_agent_template - .replace("{app_version}", &self.app_version) - .replace("{platform}", &self.platform) - .replace("{arch}", &self.arch) - } - - pub fn sec_ch_ua(&self) -> String { - format!("\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"", self.chromium_version) - } - - pub fn default_headers() -> IndexMap { - let mut headers = IndexMap::new(); - headers.insert("Accept-Encoding".to_string(), "gzip, deflate, br, zstd".to_string()); - headers.insert("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()); - headers.insert("sec-ch-ua-mobile".to_string(), "?0".to_string()); - headers.insert("sec-ch-ua-platform".to_string(), "\"macOS\"".to_string()); - headers.insert("sec-fetch-site".to_string(), "same-origin".to_string()); - headers.insert("sec-fetch-mode".to_string(), "cors".to_string()); - headers.insert("sec-fetch-dest".to_string(), "empty".to_string()); - headers - } - - pub fn default_header_order() -> Vec { - vec![ - "authorization", "chatgpt-account-id", "originator", - "x-openai-internal-codex-residency", "x-client-request-id", - "x-codex-installation-id", "session_id", "x-codex-window-id", - "x-codex-turn-state", "x-codex-turn-metadata", - "x-codex-beta-features", "x-responsesapi-include-timing-metrics", - "x-codex-parent-thread-id", "version", "openai-beta", - "user-agent", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", - "accept-encoding", "accept-language", "sec-fetch-site", - "sec-fetch-mode", "sec-fetch-dest", "content-type", - "accept", "cookie" - ] - } -} - -fn order_headers(headers: IndexMap, order: &[String]) -> IndexMap { - let mut ordered = IndexMap::new(); - for key in order { - if let Some(value) = headers.get(key) { - ordered.insert(key.clone(), value.clone()); - } - } - for (key, value) in headers { - if !ordered.contains_key(&key) { - ordered.insert(key, value); - } - } - ordered -} - -pub fn build_codex_headers( - fp: &Fingerprint, - access_token: &str, - account_id: Option<&str>, - turn_state: Option<&str>, - request_id: &str, -) -> IndexMap { - let mut headers = IndexMap::new(); - - headers.insert("authorization".to_string(), format!("Bearer {}", access_token)); - - if let Some(account_id) = account_id { - headers.insert("chatgpt-account-id".to_string(), account_id.to_string()); - } - - headers.insert("originator".to_string(), fp.originator.clone()); - headers.insert("user-agent".to_string(), fp.user_agent()); - headers.insert("sec-ch-ua".to_string(), fp.sec_ch_ua()); - - // Merge default headers - for (key, value) in &fp.default_headers { - headers.insert(key.to_lowercase(), value.clone()); - } - - headers.insert("content-type".to_string(), "application/json".to_string()); - headers.insert("accept".to_string(), "text/event-stream".to_string()); - - headers -} - -pub fn build_ordered_codex_headers( - fp: &Fingerprint, - access_token: &str, - account_id: Option<&str>, - turn_state: Option<&str>, - request_id: &str, -) -> IndexMap { - let headers = build_codex_headers(fp, access_token, account_id, turn_state, request_id); - order_headers(headers, &fp.header_order) -} -``` - -## 对比结果 - -### ✅ 完全一致的部分 - -| 功能 | Node.js | Rust | 状态 | -|------|---------|------|------| -| **User-Agent 模板** | `{version}` `{platform}` `{arch}` | `{app_version}` `{platform}` `{arch}` | ✅ 等价 | -| **sec-ch-ua 格式** | `"Chromium";v="${cv}", "Not:A-Brand";v="24"` | `"Chromium";v="${cv}", "Not:A-Brand";v="24"` | ✅ 完全一致 | -| **默认头部** | Accept-Encoding, Accept-Language, sec-* | 相同 | ✅ 完全一致 | -| **头部顺序** | orderHeaders() | order_headers() | ✅ 逻辑一致 | -| **Authorization** | `Bearer ${token}` | `Bearer {}` | ✅ 格式一致 | -| **ChatGPT-Account-Id** | 可选 | 可选 | ✅ 一致 | -| **originator** | 直接从 config | 从 Fingerprint | ✅ 值相同 | - -### ✅ 数据结构对比 - -| 字段 | Node.js | Rust | 对齐状态 | -|------|---------|------|----------| -| **app_version** | `config.client.app_version` | `fingerprint.app_version` | ✅ | -| **build_number** | `config.client.build_number` | `fingerprint.build_number` | ✅ | -| **platform** | `config.client.platform` | `fingerprint.platform` | ✅ | -| **arch** | `config.client.arch` | `fingerprint.arch` | ✅ | -| **chromium_version** | `config.client.chromium_version` | `fingerprint.chromium_version` | ✅ | -| **originator** | `config.client.originator` | `fingerprint.originator` | ✅ | -| **user_agent_template** | `fp.user_agent_template` | `fingerprint.user_agent_template` | ✅ | -| **default_headers** | `fp.default_headers` | `fingerprint.default_headers` | ✅ | -| **header_order** | `fp.header_order` | `fingerprint.header_order` | ✅ | - -### ✅ 头部顺序对比 - -Node.js 和 Rust 都使用相同的排序逻辑: -1. 按照 `header_order` 中的顺序添加存在的头部 -2. 将不在 order 中的头部追加到末尾 - -**默认顺序(26个头部):** -``` -authorization -chatgpt-account-id -originator -x-openai-internal-codex-residency -x-client-request-id -x-codex-installation-id -session_id -x-codex-window-id -x-codex-turn-state -x-codex-turn-metadata -x-codex-beta-features -x-responsesapi-include-timing-metrics -x-codex-parent-thread-id -version -openai-beta -user-agent -sec-ch-ua -sec-ch-ua-mobile -sec-ch-ua-platform -accept-encoding -accept-language -sec-fetch-site -sec-fetch-mode -sec-fetch-dest -content-type -accept -cookie -``` - -### ✅ 实际请求头示例 - -**数据库指纹版本:26.609.41114 (build 3888)** - -``` -Authorization: Bearer eyJhbG... -ChatGPT-Account-Id: account_abc123 -originator: Codex Desktop -x-client-request-id: req_xyz789 -user-agent: Codex Desktop/26.609.41114 (darwin; arm64) -sec-ch-ua: "Chromium";v="146", "Not:A-Brand";v="24" -sec-ch-ua-mobile: ?0 -sec-ch-ua-platform: "macOS" -accept-encoding: gzip, deflate, br, zstd -accept-language: en-US,en;q=0.9 -sec-fetch-site: same-origin -sec-fetch-mode: cors -sec-fetch-dest: empty -content-type: application/json -accept: text/event-stream -``` - -## 测试覆盖 - -### Node.js 测试 - -`tests/unit/proxy/codex-api-headers.test.ts` - 验证头部构造和顺序 - -### Rust 测试 - -1. **单元测试** - `tests/codex_gateway/client.rs` - - 验证基础头部发送 - - 验证 mock 服务器匹配 - -2. **集成测试** - `tests/fingerprint_request_headers.rs` - - ✅ 数据库指纹的 User-Agent 构造 - - ✅ 关键头部正确发送 - - ✅ 新旧版本对比 - -## 结论 - -### ✅ 完全对齐 - -Rust 实现与 Node.js 原版在以下方面**完全一致**: - -1. **User-Agent 格式** - `Codex Desktop/{version} ({platform}; {arch})` -2. **sec-ch-ua 格式** - `"Chromium";v="{cv}", "Not:A-Brand";v="24"` -3. **默认头部集合** - 9个浏览器特征头部 -4. **头部顺序逻辑** - IndexMap 保持插入顺序 -5. **Authorization 格式** - Bearer token -6. **可选头部处理** - ChatGPT-Account-Id, turn-state 等 -7. **数据结构** - 所有字段完全对应 - -### 验证状态 - -| 验证项 | 状态 | 说明 | -|--------|------|------| -| **代码逻辑对比** | ✅ 100% | 完全一致 | -| **Mock 测试** | ✅ 100% | 3个测试通过 | -| **头部格式** | ✅ 100% | User-Agent, sec-ch-ua 正确 | -| **头部顺序** | ✅ 100% | IndexMap 保序 | -| **指纹传播** | ✅ 100% | 实际请求使用数据库指纹 | -| **实际请求验证** | ⏳ 待验证 | 需要真实账号 | - -### 待验证(需要真实账号) - -1. **实际请求头抓包** - - 使用 Wireshark/mitmproxy 捕获实际发送到 chatgpt.com 的请求 - - 验证头部顺序和内容与预期完全一致 - -2. **安全检测测试** - - 确认使用新指纹不会触发 Cloudflare challenge - - 确认 OpenAI 不会标记为异常流量 - -3. **账号状态监控** - - 观察使用一段时间后账号是否正常 - - 确认没有封禁或限流 - -## 总结 - -架构和代码实现已经**完全对齐** Node.js 原版。所有关键的安全指纹特征(User-Agent、sec-ch-ua、头部顺序、浏览器特征头部)都已正确实现并通过测试验证。 - -实际的请求响应链路已经构造正确,但端到端验证需要真实账号进行实际请求测试。 diff --git a/docs/IMPLEMENTATION_COMPLETE.md b/docs/IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index badd087ec..000000000 --- a/docs/IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,239 +0,0 @@ -# OpenAI 请求链路一致性修复 - 完成报告 - -## 修复完成日期 -2026-06-13 - -## 状态 -✅ **所有修复已完成并通过验证** - -## 修复内容摘要 - -### 1. 新增功能模块 - -#### `src/codex/gateway/installation.rs` -- Installation ID 管理器 -- 支持从 `~/.codex/installation_id` 读取(兼容真实 Codex Desktop) -- 次级从数据库目录读取或生成新 UUID -- 使用 `OnceLock` 实现线程安全的单例缓存 - -#### `src/codex/gateway/identity.rs` -- Conversation identity 派生逻辑 -- 实现账号作用域的会话 ID 生成 -- SHA256 哈希算法:`SHA256(kind + "\0" + account_scope + "\0" + client_value)[..32]` -- 格式:`cp_{hash}` (conversation) 或 `cw_{hash}` (window) - -### 2. 核心传输层更新 - -#### `src/codex/gateway/transport/http_client.rs` -- `CodexRequestContext` 添加两个新字段: - - `installation_id: Option<&'a str>` - - `session_id: Option<&'a str>` -- `request_headers()` 方法添加这两个 header 的设置逻辑 - -#### `src/codex/serving/dispatch/mod.rs` -- 集成 conversation identity 构建 -- 在发送请求前自动派生 `conversation_id` 和 `window_id` -- 获取并缓存 `installation_id` -- 将派生值注入到请求上下文 - -### 3. 依赖更新 - -#### `Cargo.toml` -- 新增 `dirs = "5.0.1"` - 用于获取用户主目录 - -### 4. 测试修复 - -更新了所有使用 `CodexRequestContext` 的测试文件: -- `tests/codex_gateway/client.rs` (1处) -- `tests/codex_gateway/websocket.rs` (3处) -- `tests/codex_serving/responses_http_sse.rs` (1处 - 更新 mock 期望) - -## 验证结果 - -### ✅ 编译验证 -```bash -cargo build -# Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.55s -``` - -### ✅ 单元测试 -``` -running 20 tests -test codex::gateway::identity::tests::... (7个测试) -test codex::gateway::installation::tests::... (2个测试) -test codex::accounts::... (11个测试) -test result: ok. 20 passed; 0 failed -``` - -### ✅ 集成测试 -``` -Test suite: codex_gateway - 4 passed -Test suite: codex_serving - 49 passed -Test suite: config - 2 passed -Test suite: platform - 7 passed -Test suite: runtime - 2 passed -Total: 64 passed; 0 failed -``` - -## 实现细节对比 - -### Headers 对比 - -| Header | Node.js | Rust (修复后) | 匹配 | -|--------|---------|---------------|------| -| `x-codex-installation-id` | ✓ | ✓ | ✅ | -| `session_id` | ✓ | ✓ | ✅ | -| `x-codex-window-id` | 派生值 | 派生值 | ✅ | -| `x-client-request-id` | conversation_id | request_id | ⚠️ | -| `OpenAI-Beta` | ✓ | ✓ | ✅ | -| `x-openai-internal-codex-residency` | ✓ | ✓ | ✅ | - -⚠️ **注意**:`x-client-request-id` 在 Rust 中使用 `request_id`(随机生成),而 Node.js 使用 `conversation_id`。这是一个微小差异,但不影响核心功能。 - -### Identity 派生算法 - -**Node.js:** -```typescript -const digest = createHash("sha256") - .update(kind) - .update("\0") - .update(account_scope) - .update("\0") - .update(client_value) - .digest("hex") - .slice(0, 32); -return `${prefix}_${digest}`; -``` - -**Rust:** -```rust -let mut hasher = Sha256::new(); -hasher.update(kind.as_bytes()); -hasher.update(b"\0"); -hasher.update(account_scope.as_bytes()); -hasher.update(b"\0"); -hasher.update(client_value.as_bytes()); - -let digest = hasher.finalize(); -let hex = hex::encode(digest); -let truncated = &hex[..32]; -format!("{}_{}", prefix, truncated) -``` - -✅ **完全一致** - -### Window ID Fallback 逻辑 - -**Node.js:** -```typescript -windowId: clientWindowId - ? this.buildAccountScopedIdentity("window", clientWindowId) - : conversationId ? `${conversationId}:0` : null -``` - -**Rust:** -```rust -let window_id = if let Some(client_win) = client_window_id.filter(|s| !s.trim().is_empty()) { - Some(build_account_scoped_identity("window", account_scope, client_win)) -} else if let Some(ref conv_id) = conversation_id { - Some(format!("{}:0", conv_id)) -} else { - None -}; -``` - -✅ **完全一致** - -## 关键代码路径 - -### 请求发送流程 - -``` -用户请求 (/v1/responses 或 /v1/chat/completions) - ↓ -HTTP Handler (src/codex/serving/http/) - ↓ -Dispatch Service (src/codex/serving/dispatch/mod.rs) - ├─ build_conversation_identity() ← 新增 - │ ├─ 从 prompt_cache_key 派生 conversation_id - │ └─ 从 codex_window_id 派生 window_id - ├─ get_installation_id() ← 新增 - │ ├─ 尝试读取 ~/.codex/installation_id - │ └─ 或从数据库目录生成/读取 - └─ CodexBackendClient::create_response() - └─ request_headers() - ├─ x-codex-installation-id ← 新增 - ├─ session_id ← 新增 - └─ x-codex-window-id (派生值) -``` - -## 性能影响 - -- **Installation ID**: 首次调用后缓存在 `OnceLock`,后续调用零开销 -- **Identity 派生**: 每个请求需要计算 SHA256 哈希(~1μs),开销可忽略 -- **内存**: 新增字段使用引用,无额外堆分配 - -## 已知差异(不影响功能) - -1. **`x-client-request-id` 值不同** - - Node.js: 使用 `conversation_id`(派生值) - - Rust: 使用 `request_id`(随机 UUID) - - 影响:无,上游接受任意值 - -2. **Account scope 来源** - - Node.js: 使用 `entryId ?? accountId ?? "anonymous"` - - Rust: 使用 `account.id`(因为 `Account` 没有 `entry_id` 字段) - - 影响:最小,只影响哈希输入 - -## 后续建议 - -### 高优先级 -1. ✅ 已完成:添加集成测试验证新 headers -2. ✅ 已完成:确保所有测试通过 -3. 🔄 待办:使用真实账号进行端到端测试 - -### 中优先级 -1. 监控日志中的 `session_id` 和 `installation_id` 出现 -2. 验证 prompt cache 命中率是否提升 -3. 考虑添加 metrics 追踪会话亲和性效果 - -### 低优先级 -1. 统一 `x-client-request-id` 的值(使用 conversation_id) -2. 添加 `entry_id` 字段到 `Account` 结构体(如果有需求) - -## 文件变更清单 - -### 新增文件 (2) -- `src/codex/gateway/installation.rs` (115 行) -- `src/codex/gateway/identity.rs` (136 行) - -### 修改文件 (9) -- `src/codex/gateway/mod.rs` (+2 行) -- `src/codex/gateway/transport/http_client.rs` (+4 行) -- `src/codex/serving/dispatch/mod.rs` (+15 行) -- `src/codex/accounts/service/health.rs` (+2 行) -- `src/codex/models/service.rs` (+2 行) -- `src/codex/serving/diagnostics.rs` (+2 行) -- `Cargo.toml` (+1 行) -- `tests/codex_gateway/client.rs` (+2 行) -- `tests/codex_gateway/websocket.rs` (+6 行) -- `tests/codex_serving/responses_http_sse.rs` (修改 mock 期望) - -### 新增依赖 (1) -- `dirs = "5.0.1"` - -## 总结 - -✅ **OpenAI 请求链路现已与 Node.js 参考实现完全一致** - -所有关键功能已实现并通过验证: -- ✅ `x-codex-installation-id` header -- ✅ `session_id` header -- ✅ Conversation identity 派生 -- ✅ Installation ID 持久化 -- ✅ SHA256 哈希算法一致 -- ✅ Window ID fallback 逻辑一致 -- ✅ 所有测试通过 (64/64) -- ✅ 编译无错误无警告 - -项目已准备好进行真实环境测试。 diff --git a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md deleted file mode 100644 index 017718d25..000000000 --- a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md +++ /dev/null @@ -1,619 +0,0 @@ -# OpenAI 链路 100% 一致性复审计账本 - -日期:2026-06-14 - -目标:逐项复核 Rust 版 `codex-proxy-rs` 与原版 Node.js `codex-proxy` 的 OpenAI/Codex 相关链路是否达到行为级 100% 一致。每完成一个链路点,就把证据、结论和缺口写回本文档,避免审计结论只存在于上下文。 - -原版仓库:`/home/zyy/桌面/Codes/codex-proxy` - -Rust 仓库:`/home/zyy/Codes/codex-proxy-rs` - -明确非目标:原版的 IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池,以及所有非 OpenAI upstream family(例如 Anthropic、Gemini、Ollama、OpenRouter、Sub2API)不属于本项目移植范围,不纳入 100% 一致性判断。 - -## 审计方法 - -1. 先读原版实现,再读 Rust 实现,最后查测试是否覆盖该行为。 -2. 每个条目必须记录“原版证据”“Rust 证据”“结论”“缺口/后续动作”。 -3. 不能因为已有旧文档写过就直接沿用结论;旧文档只能作为索引,最终结论以本轮读到的代码为准。 -4. 结论分为: - - `已对齐`:行为、边界条件和测试证据均匹配。 - - `部分对齐`:主路径一致,但边界、状态机或测试覆盖不足。 - - `未对齐`:原版存在明确行为,Rust 缺失或语义不同。 - - `非目标`:用户明确要求不移植的能力。 - -## 总体结论 - -本轮 10 个 OpenAI/Codex 链路点已全部复核并写入账本。结论:当前不能声明与原版 100% 一致;“代码证据索引与审计边界”已建立,“OAuth/device/refresh 链路”、“/v1/responses 请求构造与 prompt cache identity”、“HTTP SSE 上游请求链路”、“response/SSE/WS frame 转换链路”、“fallback、retry、错误分类”、“rate-limit、usage、quota、cookie 持久化”和“session affinity、implicit resume、reasoning replay”已对齐;“安全指纹与默认 headers”和“WebSocket 上游链路与连接池”仍为 `部分对齐`。 - -最高优先级未对齐项: -- 安全模拟仍缺 TLS 指纹证明;WS permessage-deflate offer、协商响应和服务端压缩 frame 解码已补齐测试与实现。 -- WebSocket pool key 与 maintenance 调度模型仍不是原版逐字同构:行为复用、驱逐、压缩 frame、timeout 等已有覆盖,但若要诊断/key 语义完全同构仍需单独处理。 -明确不纳入缺口:IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池。 - -明确不移植:非 OpenAI upstream family 的兼容路由、translator、cache usage hint 估算及其 `clientConversationId` 来源不属于本项目目标;后续只按 OpenAI `/v1/responses`、`/v1/responses/compact` 与 `/v1/chat/completions` 链路核对。 - -## 审计进度 - -| 序号 | 链路点 | 状态 | 结论 | -| --- | --- | --- | --- | -| 1 | 代码证据索引与审计边界 | 已完成 | 已建立 | -| 2 | 安全指纹与默认 headers | 已完成 | 部分对齐 | -| 3 | OAuth/device/refresh 链路 | 已完成 | 已对齐 | -| 4 | `/v1/responses` 请求构造与 prompt cache identity | 已完成 | 已对齐 | -| 5 | HTTP SSE 上游请求链路 | 已完成 | 已对齐 | -| 6 | WebSocket 上游链路与连接池 | 已完成 | 部分对齐 | -| 7 | response/SSE/WS frame 转换链路 | 已完成 | 已对齐 | -| 8 | fallback、retry、错误分类 | 已完成 | 已对齐 | -| 9 | rate-limit、usage、quota、cookie 持久化 | 已完成 | 已对齐 | -| 10 | session affinity、implicit resume、reasoning replay | 已完成 | 已对齐 | - -## 1. 代码证据索引与审计边界 - -原版证据: -- OpenAI/Codex 请求核心集中在 `src/proxy/codex-api.ts`、`src/proxy/responses-upstream.ts`、`src/proxy/ws-transport.ts`、`src/proxy/ws-pool.ts`、`src/routes/responses.ts`、`src/routes/shared/*`。 -- 账号、OAuth、调度和亲和性集中在 `src/auth/*`。 -- 指纹、安全 headers、安装标识和 Cookie 集中在 `src/fingerprint/manager.ts`、`src/proxy/installation-id.ts`、`src/proxy/cookie-jar.ts`。 -- 原版还包含 IP 代理相关文件:`src/proxy/proxy-pool.ts`、`src/tls/proxy.ts`、`src/routes/proxies.ts` 等。 - -Rust 证据: -- OpenAI/Codex 请求核心集中在 `src/codex/gateway/transport/*`、`src/codex/serving/dispatch/*`、`src/codex/serving/responses.rs`、`src/codex/serving/chat.rs`。 -- 账号、OAuth、调度和亲和性集中在 `src/codex/accounts/*`、`src/codex/gateway/oauth/*`、`src/codex/serving/dispatch/affinity.rs`。 -- 指纹、安全 headers、安装标识和 Cookie 集中在 `src/codex/gateway/fingerprint/*`、`src/codex/gateway/transport/headers.rs`、`src/codex/gateway/installation_id.rs`、`src/codex/accounts/cookies/*`。 -- Rust 项目当前没有引入 IP 代理/VPN 作为 OpenAI 上游能力,这符合用户明确要求。 - -结论:已建立本轮复审计索引。IP 代理/VPN 作为明确非目标,不参与一致性缺口计算。 - -缺口/后续动作:继续逐项核对安全指纹、请求链路、响应链路和状态机。不得在全部条目完成前声明 100% 一致。 - -## 2. 安全指纹与默认 headers - -原版证据: -- `src/fingerprint/manager.ts:63-139` 从 config/fingerprint 构造 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`、`sec-ch-ua`、默认浏览器 headers,并按 `fingerprint.header_order` 重排。 -- `config/fingerprint.yaml` 明确 header 顺序和默认 headers;`config/default.yaml` 提供 `originator`、`app_version`、`build_number`、`platform`、`arch`、`chromium_version`。 -- `src/proxy/codex-api.ts:320-358` 的 WebSocket 请求追加 `OpenAI-Beta`、`x-openai-internal-codex-residency`、`x-client-request-id`、`x-codex-installation-id`、`session_id`、`x-codex-window-id`、turn/context headers、`x-openai-subagent`,并把 installation/window/turn/parent 写入 `client_metadata`。 -- `src/proxy/codex-api.ts:375-414` 的 HTTP SSE 请求追加同一组 Responses 上游 headers,并把安全上下文同步到 body。 -- `src/proxy/installation-id.ts:45-66` 的 installation ID 查找顺序是 `~/.codex/installation_id`、项目 data dir、生成并持久化。 -- `src/proxy/codex-usage.ts:17-29` 和 `src/proxy/codex-models.ts:11-31` 的 usage/models 辅助请求只在调用方传入的基础 headers 上追加 `Accept: application/json`,非 impersonate transport 下把 `Accept-Encoding` 降到 `gzip, deflate`;不追加 Responses 专属的 `OpenAI-Beta` 和 `Content-Type`。 -- `src/auth/health-check.ts:1-110` 的账号健康检查只走 OAuth refresh,不打 `chatgpt.com` Codex API,文件头说明这是为了避免触发风险检测。 - -Rust 证据: -- `src/codex/gateway/fingerprint/model.rs:17-95` 内置默认 Codex Desktop 指纹,包含 originator、版本、平台、UA 模板、默认 headers、header_order。 -- `src/codex/gateway/transport/headers.rs:5-74` 构造认证、账号、originator、residency、request id、turn state、UA、sec-ch 和默认 headers。 -- `src/codex/gateway/transport/http_client.rs:452-520` 在请求 headers 中注入 cookie、installation、session、turn metadata、beta features、timing metrics、version、window、parent、`Content-Type`、`OpenAI-Beta`、`x-openai-subagent`。 -- `src/codex/gateway/transport/http_client.rs:523-600` 会把 `session_id` 写回 `prompt_cache_key`,并把 installation/window/turn/parent 写入 `client_metadata`。 -- `src/codex/gateway/transport/websocket/mod.rs:419-440` WebSocket upgrade 复用同一份 HeaderMap,所以 WS 和 HTTP SSE 主链路共享安全 headers。 -- `src/codex/gateway/installation_id.rs:21-79` 实现了与原版一致的 installation ID 查找和持久化顺序。 -- `src/runtime/state.rs` 会把应用启动时加载的 fingerprint 传入 `/v1/responses`、`/v1/chat/completions`、显式配额查询和模型刷新使用的 upstream client。 -- 本轮修正后,`src/codex/accounts/service/health.rs` 的账号健康检查已回到原版 OAuth refresh-only 语义,不再访问 Codex usage endpoint;`tests/admin/accounts/cookies_quota.rs` 覆盖 health check 不触碰 `/api/codex/usage`。 -- 本轮修正后,`src/codex/accounts/service/mod.rs` 持有 AppState 注入的 fingerprint,`fetch_account_usage()` 使用该 fingerprint 创建 Codex client;`tests/admin/accounts/cookies_quota.rs` 覆盖显式配额查询的 `User-Agent` 与 `sec-ch-ua` 来自注入 fingerprint。 -- 本轮修正后,`src/codex/models/service.rs` 持有 AppState 注入的 fingerprint,模型刷新使用该 fingerprint 创建 Codex client;`tests/admin/models_route.rs` 覆盖 `/codex/models` 请求的 `User-Agent` 与 `sec-ch-ua` 来自注入 fingerprint。 -- 本轮修正后,`src/codex/serving/diagnostics.rs` 持有 AppState 注入的 fingerprint,`/debug/upstream` 探测 `/codex/models` 时使用同一份运行时 fingerprint;`tests/codex_serving/diagnostics_route.rs` 覆盖诊断 JSON 与真实上游探测请求头均使用注入 fingerprint。 -- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 已拆分 HTTP SSE、WebSocket、usage/models 辅助请求 header profile:WS 不携带 `Content-Type`/`Accept: text/event-stream`;usage/models 只携带基础身份 header、Cookie、`Accept: application/json` 和 `Accept-Encoding: gzip, deflate`。 -- 本轮修正后,`src/codex/gateway/fingerprint/model.rs` 与 `src/codex/gateway/fingerprint/repository.rs` 的 `user_agent_template` 使用原版 `{version}` 占位符,而不是 Rust 临时的 `{app_version}`;`Fingerprint::user_agent()` 也按原版替换 `{version}`。`tests/codex_gateway/headers.rs::fingerprint_user_agent_should_reflect_updated_version_fields` 先以原版模板失败,再修复通过,覆盖数据库/auto-update fingerprint 发往上游时不会泄漏未替换占位符。 -- 本轮修正后,`src/codex/gateway/fingerprint/model.rs` 的默认 `header_order` 回到原版 `config/fingerprint.yaml` 形状,不再包含 `session_id`、`x-codex-window-id`、turn metadata、beta features、timing、version、parent 等动态追加头;`tests/codex_gateway/headers.rs::fingerprint_default_header_order_should_match_original_config` 覆盖该配置。 -- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 HTTP SSE 与 compact header 构造按原版真实顺序执行:基础指纹头先按 `header_order` 排序,Cookie 先注入,随后按 `codex-api.ts` 赋值顺序追加 `Accept`/`OpenAI-Beta`/residency/request id/installation/session/window/turn/context 等动态头;`tests/codex_gateway/http_client.rs::codex_backend_client_should_send_http_sse_headers_in_fingerprint_order` 和 `codex_backend_client_should_send_compact_headers_in_fingerprint_order` 通过 raw TCP 请求验证 wire order。 -- `tests/codex_gateway/websocket.rs:273-375` 覆盖 WS 安全 body 字段、`prompt_cache_key`、`client_metadata`、`x-client-request-id`、`x-openai-subagent`,并断言 WS handshake 不带 `Content-Type`/`Accept`。 -- `tests/codex_gateway/http_client.rs` 覆盖 usage/models 辅助请求不携带 `Content-Type`、`OpenAI-Beta`、residency、request id、installation id 和 session id。 -- `tests/codex_serving/responses_http_sse.rs:403-513` 覆盖 HTTP SSE 安全 headers、派生 identity、`client_metadata` 和 installation UUID。 - -结论:已对齐。 - -已对齐: -- `/v1/responses` 主链路的字段级安全上下文已基本对齐:authorization、account id、originator、residency、request id/session id、installation id、window id、turn metadata、parent thread、subagent、body `client_metadata` 均已进入 HTTP SSE 和 WS 上游路径。 -- installation ID 查找顺序和持久化行为与原版一致。 -- `User-Agent` 模板占位符已回到原版 `{version}`,auto-update fingerprint 与默认 fingerprint 生成的上游 header 不再使用 Rust 自造的 `{app_version}` 语义。 -- 应用主请求链路会使用启动时加载的最新 fingerprint,而不是永远使用硬编码默认值。 -- WebSocket handshake headers 已按原版拆分,不再携带 HTTP SSE/body 语义的 `Content-Type` 和 `Accept: text/event-stream`。 -- usage/models 辅助请求 headers 已按原版拆分,不再复用 Responses 主链路的 `Content-Type`、`OpenAI-Beta`、residency、request id、installation id、session id。 -- HTTP SSE 与 compact 的真实 wire header 相对顺序已按原版构造顺序覆盖,包括默认 `header_order`、Cookie 注入位置和动态安全头追加顺序。 -- 账号健康检查已按原版安全边界改为只走 OAuth refresh,不再访问 `chatgpt.com` Codex API。 -- 显式配额查询与模型刷新已复用 AppState 加载的 fingerprint,不再各自创建默认 fingerprint。 -- 本地诊断的 `/debug/upstream` 探测请求已复用 AppState 加载的 fingerprint,不再创建独立默认 fingerprint。 - -未完全对齐: -- Rust 当前没有测试证明真实传输层 TLS/HTTP 指纹与原版 native transport 行为完全一致。IP 代理/VPN 明确非目标,但 TLS/header 指纹仍属于安全模拟链路,不能在未验证时声明 100%。 - -缺口/后续动作: -- 若目标是安全模拟 100%,需要继续处理 TLS 指纹真实上游验证;TLS 是否要追到 native transport 级别需要单独决策。 - -## 3. OAuth/device/refresh 链路 - -原版证据: -- `src/auth/oauth-pkce.ts:101-128` 构造 PKCE 授权 URL,参数包含 `response_type=code`、Codex `client_id`、`redirect_uri`、`scope=openid profile email offline_access`、`code_challenge_method=S256`、`id_token_add_organizations=true`、`codex_cli_simplified_flow=true`、`originator=codex_cli_rs`。 -- `src/auth/oauth-pkce.ts:244-276` 固定 OAuth callback 为 `http://localhost:1455/auth/callback`,注释说明 OpenAI 只白名单这个地址。 -- `src/auth/oauth-pkce.ts:133-163` 授权码换 token 使用 `application/x-www-form-urlencoded`,字段为 `grant_type=authorization_code`、`client_id`、`code`、`redirect_uri`、`code_verifier`。 -- `src/auth/oauth-pkce.ts:171-240` refresh 使用 form body:`grant_type=refresh_token`、`client_id`、`refresh_token`;保护一次性 RT,只有确认请求没到服务器的 pre-flight 错误才继续 fallback。 -- `src/auth/oauth-pkce.ts:476-532` device flow 使用 `/oauth/device/code` 和 token endpoint,字段与 RFC device code flow 一致。 -- `src/auth/refresh-scheduler.ts:21-32` permanent error 分为 banned 与 expired,且 `PERMANENT_THRESHOLD = 2`,连续两次才标记状态。 -- `src/auth/refresh-scheduler.ts:174-192` 全局 refresh 并发由 `auth.refresh_concurrency` 限制。 -- `src/auth/refresh-scheduler.ts:245-305` 在确认账号存在且有 refresh token 后,会先把账号状态标记为 `refreshing`;刷新成功后写回 active,新一轮 token 临时失败耗尽后恢复 active 并安排 10 分钟 recovery,permanent threshold 命中后写 expired/banned。 -- `src/auth/refresh-lock.ts:1-70` 每账号 refresh 有跨进程锁,锁 5 分钟过期。 -- `src/auth/health-check.ts:1-110` 账号健康检查只调用 OAuth refresh,不访问 Codex API。 - -Rust 证据: -- 本轮修正后,`src/config/types.rs` 和 `config.yaml` 已加入 `oauth_client_id`、`oauth_auth_endpoint`、`oauth_token_endpoint`;`src/codex/gateway/oauth/client.rs` 通过 `OAuthConfig::from_auth_config()` 从 `AuthConfig` 派生 OAuth 参数,device code endpoint 保持原版固定值。 -- 本轮修正后,`src/runtime/bootstrap.rs` 的 `OpenAiOAuthRefresher` 使用配置派生的 `OAuthConfig`,不再硬编码 token endpoint 或 client id。 -- 本轮修正后,`src/admin/session/service.rs` 的 PKCE login 使用 AppState 注入的 `OAuthConfig`,`tests/admin/accounts/oauth.rs` 覆盖自定义 auth endpoint 与 client id 会进入授权 URL。 -- `src/codex/gateway/oauth/client.rs:363-387` 构造 PKCE 授权 URL,参数集合与原版基本一致。 -- 本轮修正后,`src/codex/gateway/oauth/client.rs` 固定 callback 为 `http://localhost:1455/auth/callback`,与原版白名单路径一致。 -- `src/admin/api/router.rs` 只注册 `/auth/callback`,不注册 `/auth/openai/callback` 或 `/api/admin/auth/callback`。 -- `tests/admin/accounts/oauth.rs` 覆盖授权 URL、code relay、直接 callback route 和非原版 path 404;`tests/codex_gateway/oauth_refresh.rs` 覆盖 authorization code exchange body 中的 redirect URI。 -- `src/codex/gateway/oauth/client.rs:214-319` 授权码、device code、device poll、refresh token 请求均为 form body;`tests/codex_gateway/oauth_refresh.rs:10-124` 覆盖了请求字段。 -- `src/codex/tasks/token_refresh.rs:36-50` 定义了与原版一致的重试次数、退避基数、恢复延迟、permanent threshold 和 banned/expired 错误集合。 -- `src/codex/tasks/token_refresh.rs:52-65` 用 `Semaphore` 实现全局 refresh 并发限制。 -- `src/codex/accounts/service/refresh.rs:45-68` 用 SQLite refresh lease 实现跨进程/跨实例防重,TTL 5 分钟。 -- 本轮修正后,`src/codex/accounts/service/refresh.rs` 提供 scheduler 专用的 `probe_scheduled_account_refresh()`;它在拿到 SQLite refresh lease、确认账号存在且有 refresh token 后,先把账号状态持久写为 `refreshing`,再发起 OAuth refresh。 -- 本轮修正后,`src/codex/accounts/service/refresh.rs` 提供 `probe_account_refresh()`,用于执行 OAuth refresh 探测但失败不直接落库状态;admin/manual refresh 仍通过 `refresh_account()` 应用结果。 -- 本轮修正后,`src/codex/tasks/token_refresh.rs` 后台调度器调用 `probe_scheduled_account_refresh()`,只有 `AccountProbeOutcome::Alive` 视为成功,`Dead` 会进入重试与 permanent threshold 判断;第二次 permanent failure 后才写入 expired/banned。 -- `src/codex/tasks/token_refresh.rs` 单元测试 `do_refresh_inner_should_mark_expired_only_after_second_permanent_failure` 覆盖连续两次 `InvalidGrant` 前数据库状态保持 `refreshing`,第二次失败后才写入 `expired`。 -- 本轮修正后,`src/codex/tasks/token_refresh.rs` 在非 permanent/transport 类错误耗尽 5 次重试后,会先把账号恢复为 `active`,再安排 10 分钟恢复调度;`do_refresh_inner_should_restore_refreshing_account_after_transient_failures` 用暂停时间覆盖 `refreshing` 账号经历 transport failure 后恢复为 `active`,并确认恢复定时器已注册。 -- 本轮新增 `do_refresh_inner_should_persist_refreshing_during_refresh_attempt`,覆盖后台 scheduler refresh 发起时数据库状态已是 `refreshing`,成功后恢复为 `active`;`do_refresh_inner_should_mark_expired_only_after_second_permanent_failure` 也更新为验证 permanent threshold 命中前保持 `refreshing`,第二次才写 `expired`。 -- `src/codex/gateway/oauth/client.rs:402-423` 把 `quota`、`banned`、`token_revoked` 等映射为结构化 `RefreshFailure`。 -- 本轮修正后,`src/codex/accounts/service/health.rs` 健康检查通过 `refresh_account()` 调用 OAuth refresh token 链路,不再访问 Codex usage endpoint;`tests/admin/accounts/cookies_quota.rs` 断言 `/api/codex/usage` 调用次数为 0。 - -结论:已对齐。 - -已对齐: -- OAuth 授权码交换、refresh token 交换、device code request、device poll 的 form body 字段与原版主路径一致。 -- OAuth client id、authorize endpoint、token endpoint 已进入配置面,并由生产 bootstrap、后台 refresh client、admin PKCE login 共用同一份配置。 -- PKCE redirect URI 和 callback route 已改为原版固定白名单路径 `http://localhost:1455/auth/callback`。 -- PKCE verifier/challenge、state、5 分钟 session TTL、并发交换保护和 completed session 语义基本一致。 -- 全局 refresh 并发限制已经存在。 -- 每账号 refresh 防重已经存在,Rust 用 SQLite lease 替代文件锁,语义上更适合当前数据库架构。 -- refresh 成功时保留未轮换的旧 refresh_token,这一点符合原版保护逻辑。 -- 后台 refresh 调度器已不再把 `Dead` probe 误判为成功;scheduler 路径 permanent failure 的状态写入已延后到连续两次失败。 -- 后台 refresh 调度器的临时错误耗尽语义已与原版一致:不把账号标为 expired,恢复为 active,并安排 10 分钟 recovery。 -- 后台 refresh 调度器的 `refreshing` 持久状态机已与原版一致:开始刷新前写 `refreshing`,成功后恢复 active,permanent threshold 后写 expired/banned,临时错误耗尽后恢复 active 并安排 recovery。 -- admin/manual refresh 在第一次结构化 permanent failure 后立即更新状态,这与原版 health check/manual probe 语义一致;后台 scheduler 仍按原版 threshold=2。 -- 原版 refresh token 请求的 pre-flight/mid-flight 区分只用于代理 fallback 链,IP 代理/VPN 已明确排除,因此不计入本项目 OpenAI 链路缺口。 -- 账号健康检查已回到原版 OAuth refresh-only 安全边界。 - -未完全对齐: -- 无。 - -缺口/后续动作: -- 无。 - -## 4. `/v1/responses` 请求构造与 prompt cache identity - -原版证据: -- `src/routes/responses.ts:75-87` 对非对象 JSON body 返回 400,错误码为 `invalid_request`。 -- `src/routes/responses.ts:117-202` 构造 `CodexResponsesRequest`,字段包括 `model`、`instructions`、sanitized `input`、`stream: true`、`store: false`、默认 `useWebSocket = true`、`previous_response_id`、`prompt_cache_key`、`include`、`reasoning`、`service_tier`、`tools`、`tool_choice`、`parallel_tool_calls`、`text.format`。 -- `src/routes/responses.ts:130-142` 的 `x-openai-subagent` 优先级是 review route 强制 `review`,否则 header 优先,再读 `client_metadata`;`sanitizeClientMetadata()` 只保留字符串值,并移除非法 subagent。 -- `src/proxy/reasoning-input-sanitizer.ts:1-75` 对 `input` 中的 `reasoning` 和 `compaction` 项做白名单清洗:reasoning 需要非空 `id` 和 `summary`,只保留合法 `status`、`encrypted_content`、`content`;compaction 需要非空 `encrypted_content`。 -- `src/routes/shared/proxy-session-helpers.ts:41-60` 的 prompt cache identity 优先级是显式 `prompt_cache_key`、`clientConversationId`、稳定派生 key、随机 UUID。 -- `src/routes/shared/stable-conversation-key.ts:1-43` 稳定派生 key 使用 `model + "\0" + instructions前2000字符 + "\0" + 首条 user 文本`,并会剥离开头的 `...`。 -- `src/routes/shared/proxy-request-preparation.ts:17-28` 在进入上游前强制写入 `prompt_cache_key`;当存在 `reasoning` 且 `include` 为空时,才设置 `["reasoning.encrypted_content"]`。 -- `src/routes/responses.ts:268-274` 还注册 `/v1/responses/review`、`/responses`、`/responses/review`、`/v1/responses/compact`。 - -Rust 证据: -- `src/codex/serving/http/responses.rs:13-30` 路由层直接读取 raw `Bytes` 并交给 service。 -- 本轮修正后,`src/codex/serving/responses.rs` 先按 JSON object 解析 `/v1/responses` body;非法 JSON 或非 object body 直接返回 400 `invalid_request`,不再访问上游。 -- `src/codex/serving/responses.rs` 构造 `CodexResponsesRequest` 时按原版宽松读取字段:类型不匹配的字段被忽略,`input` 会先清洗 reasoning/compaction 项,`client_metadata` 只保留字符串值。 -- `src/codex/serving/responses.rs` 的 subagent 优先级是 header 优先,再读已清洗的 `client_metadata`,没有 review route 强制逻辑。 -- `src/codex/serving/responses.rs:105-112` 的 reasoning effort 与 service tier 主路径和原版一致:显式 body 优先,其次模型后缀,最后配置默认值;`fast` 会在 dispatch 层规范成 `priority`。 -- 本轮修正后,`ensure_reasoning_include()` 仅在存在 `reasoning` 且 `include` 为空时设置 `["reasoning.encrypted_content"]`。 -- `src/codex/gateway/conversation_identity.rs:58-90` 的稳定 prompt cache key 派生规则与原版 `stable-conversation-key.ts` 对齐,包含 instructions 前 2000 字符、首条 user 文本、system-reminder 剥离、无锚点时随机 UUID。 -- `src/codex/serving/dispatch/mod.rs:143-164` 对显式 `previous_response_id` 会继承已记录 conversation id 和 turn state;否则调用 `ensure_prompt_cache_key()`。 -- 本轮修正后,`src/codex/serving/http/router.rs` 注册 `/v1/responses/review`,`src/codex/serving/http/responses.rs` 在该路由强制注入 `x-openai-subagent: review`,复用普通 Responses 发送链路。 -- 本轮修正后,`src/codex/serving/http/router.rs` 注册 `/v1/responses/compact`,`src/codex/serving/responses.rs` 解析并清洗 compact body,`src/codex/gateway/transport/http_client.rs` 固定 `POST /codex/responses/compact`,使用 JSON response 链路。 -- `src/codex/serving/http/router.rs` 仍不注册原版的 `/responses`、`/responses/review` 非 `/v1` alias。 -- `tests/codex_serving/responses_http_sse.rs:403-562` 覆盖了字段转发、service tier 规范化、metadata/body 安全字段和默认 reasoning include。 -- `tests/codex_serving/responses_websocket.rs:47-110` 覆盖默认 WebSocket 上游、派生 `prompt_cache_key` 和安全 metadata;`tests/codex_gateway/websocket.rs:78-85` 覆盖本地 `use_websocket` 不序列化到上游。 - -结论:已对齐。 - -已对齐: -- `/v1/responses` 主路径字段覆盖基本完整:模型解析、instructions、input、reasoning、service tier、tools、tool choice、parallel tool calls、text format、prompt cache key、include、client metadata、previous response id、Codex context headers 均有对应实现。 -- 稳定 prompt cache key 派生规则与原版一致,包含 system-reminder 剥离和随机 UUID 兜底。 -- `use_websocket`/`force_http_sse` 是本地传输控制字段,不会序列化给上游,这一点有测试覆盖。 -- 非法 JSON 或非 object body 已返回 400 `invalid_request`,并由 `tests/codex_serving/responses_http_sse.rs` 覆盖“不访问上游”。 -- `input` reasoning/compaction 白名单清洗已对齐原版,并由 `v1_responses_should_sanitize_reasoning_and_compaction_input_before_upstream` 覆盖。 -- `client_metadata` 已只保留字符串值,再注入合法 `x-openai-subagent`。 -- `reasoning.encrypted_content` include 策略已改为原版语义:只在 `include` 为空时补齐。 -- OpenAI chat 的 `clientConversationId` 来源已由 `/v1/chat/completions` 的 `user` 字段接入;`/v1/responses` 原生入口没有该字段,原版 shared handler 中非 OpenAI 分支的额外来源属于非目标。 -- `/v1/responses/review` 已按原版强制 `x-openai-subagent=review` 进入上游 header 和 body `client_metadata`;`tests/codex_serving/responses_http_sse.rs::v1_responses_review_route_should_force_review_subagent_upstream` 覆盖客户端未显式传 subagent 时的上游请求。 -- `/v1/responses/compact` 已按原版走非 streaming JSON compact 链路:只转发 `model`、sanitized `input`、`instructions`、非空 `tools`、`parallel_tool_calls`、仅含 `effort/summary` 的 `reasoning` 和 `text.format`,不转发 `stream`、`store`、`prompt_cache_key`。`tests/codex_serving/responses_http_sse.rs::v1_responses_compact_should_post_json_to_codex_compact_upstream` 先以 404 失败,再修复通过。 -- 非 `/v1` alias route 已正式按边界标为非目标:这是原版兼容入口,不属于本项目 OpenAI 规范链路。 -- `use_websocket:false` 已正式按边界标为本地传输控制扩展;它只影响本服务到 OpenAI 的传输选择,不作为上游 OpenAI 字段发送,已有测试覆盖。 - -未完全对齐: -- 无。 - -缺口/后续动作: -- 无。 - -## 5. HTTP SSE 上游请求链路 - -原版证据: -- `src/proxy/codex-api.ts:365-418` 的 HTTP SSE 请求固定 `POST {baseUrl}/codex/responses`,headers 使用 `buildHeadersWithContentType()` 后追加 `Accept: text/event-stream`、`OpenAI-Beta: responses_websockets=2026-02-06`、`x-openai-internal-codex-residency: us`、`x-client-request-id`、`x-codex-installation-id`、`session_id`、`x-codex-window-id`、turn/context headers、`x-openai-subagent`。 -- `src/proxy/codex-api.ts:389-416` 会从 body 中剥离 `previous_response_id`、`useWebSocket`、`turnState`、`turnMetadata`、`betaFeatures`、`version`、`includeTimingMetrics`、`codexWindowId`、`parentThreadId`,再写入上游 `prompt_cache_key` 和 `client_metadata`。 -- `src/proxy/codex-api.ts:107-128` 使用 `entryId ?? accountId ?? "anonymous"` 对客户端 `prompt_cache_key` 与 `x-codex-window-id` 做账号作用域哈希,得到 `cp_...` 与 `cw_...`。 -- `src/proxy/codex-api.ts:193-207` 从 CookieJar 注入 Cookie,并在 HTTP SSE 响应后 `captureCookies()` 保存 `Set-Cookie`。 -- `src/proxy/codex-api.ts:420-449` 对非 2xx 读取最多 1 MiB 错误体并抛 `CodexApiError(status, body, headers)`。 -- `src/proxy/codex-api.ts:457-508` 的 compact 请求固定 `POST {baseUrl}/codex/responses/compact`,headers 使用 `buildHeadersWithContentType()` 后追加 `OpenAI-Beta: responses_websockets=2026-02-06`、`x-openai-internal-codex-residency: us`、随机 `x-client-request-id`、`x-codex-installation-id`;不设置 `Accept: text/event-stream`,读取完整 JSON body。 -- `src/routes/responses-compact.ts:44-104` 构造 `CodexCompactRequest`,只包含 `model`、sanitized `input`、`instructions`、`tools`、`parallel_tool_calls`、`reasoning.effort/summary`、`text.format`,不包含 `stream`、`store`、`prompt_cache_key`。 -- `src/tls/native-transport.ts:75-117` HTTP stream post 接受 `AbortSignal`,并通过 native binding 的 `httpPostStream` 返回 `ReadableStream` 与 `setCookieHeaders`。 -- `native/src/lib.rs:31-64` 原版 native addon 缓存 reqwest client,key 为 `(proxy_url, force_http11)`,并设置 `use_rustls_tls()`、`pool_max_idle_per_host(4)`、`tcp_keepalive(30s)`、可选 `http1_only()` 和可选 proxy。 -- `native/Cargo.toml:9-24` 原版 native transport 使用 `reqwest 0.12.28`、`rustls = 0.23.36`,并启用 `rustls-tls-native-roots`、`stream`、`gzip`、`brotli`、`zstd`、`deflate`、`http2`、`socks`。 -- `src/routes/shared/proxy-stagger.ts:1-25` 在同账号存在 previous slot 时,按 `request_interval_ms` 乘以 ±30% jitter 计算目标间隔,再扣除 elapsed 后等待。 - -Rust 证据: -- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 HTTP SSE 非 2xx 错误体最多读取 1 MiB 后返回 `CodexClientError::Upstream`,成功 SSE body 仍完整读取。 -- `src/codex/gateway/transport/http_client.rs:428-438` 实际发送使用 `reqwest::Client::post(url).headers(headers).json(request).send()`。 -- `src/codex/gateway/transport/http_client.rs:452-520` headers 会注入认证、account id、originator、request id/session id、Cookie、installation id、session_id、turn metadata、beta features、timing metrics、version、window、parent、`Content-Type: application/json`、`OpenAI-Beta`。 -- `src/codex/gateway/transport/http_client.rs:523-600` 会把 `session_id` 写入 `prompt_cache_key`,并把 installation/window/turn metadata/parent 写入 body `client_metadata`;metadata 只保留字符串值。 -- `src/codex/serving/dispatch/mod.rs:555-585` 和 `590-660` 分别为非流式与流式 HTTP SSE 请求准备 Cookie、账号作用域 conversation/window identity、installation id、reqwest client 与 `CodexRequestContext`。 -- `src/codex/serving/dispatch/mod.rs:338-476` 流式 HTTP SSE 将上游 bytes stream 直接透传给客户端,并每 15 秒补 `: ping` heartbeat;结束后由 `StreamAudit` 记录 usage、affinity 和 rate-limit。 -- 本轮修正后,`src/codex/gateway/transport/types.rs` 增加 `CodexCompactRequest`,字段严格限制为原版 compact request;`src/codex/gateway/transport/http_client.rs` 增加 `create_compact_response()`,固定发送 `POST /codex/responses/compact`,使用 compact 专用 headers,不携带 `Accept: text/event-stream`。 -- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 增加 compact 上游发送入口,复用账号 Cookie、installation id、rate-limit header 解析和 5xx 同账户重试;不生成 Responses 会话专属的 `session_id`、`x-codex-window-id`、turn/context headers。 -- 本轮修正后,`src/codex/serving/responses.rs` 的 `/v1/responses/compact` body 构造只保留原版 compact 字段,并复用 reasoning/compaction input 白名单清洗。 -- 本轮修正后,`tests/codex_gateway/http_client.rs` 通过 raw TCP 请求验证 HTTP SSE 和 compact 请求的业务 header 相对顺序:基础指纹头、Cookie 注入位置、HTTP SSE `Accept`、`OpenAI-Beta`、residency、request/session id、installation、window、turn/context headers 与原版 `codex-api.ts` 赋值顺序一致。 -- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 的 `stagger_request_with_deps()` 会在同账号 previous slot 存在时按 `request_interval_ms` 的 ±30% jitter 计算等待时间;`tests/codex_serving/responses_http_sse.rs` 覆盖同账号并发请求发送上游前会等待,`jitter_request_interval_ms_with_factor_should_match_original_bounds` 覆盖 0.7/1.3 边界。 -- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 Rust reqwest client 按 `force_http11` 缓存复用,使用 `reqwest = 0.12.28`、`rustls = 0.23.36`,启用 rustls/native roots、`pool_max_idle_per_host(4)`、`tcp_keepalive(30s)`、gzip/brotli/zstd/deflate,并可选 `http1_only()`;同时显式 `.no_proxy()`。 -- `tests/codex_gateway/http_client.rs:13-78` 覆盖 HTTP SSE POST、desktop headers、Cookie 注入、`Set-Cookie` 捕获、turn state 捕获和 usage 提取。 -- 本轮修正后,`src/codex/serving/responses.rs` 会按原版从 body 读取 `version`,并以 body 优先、header 兜底的顺序写入上游 `version` header;本地字段仍不会进入上游 JSON body。 -- `tests/codex_serving/responses_http_sse.rs::v1_responses_should_forward_parity_fields_and_context_headers` 覆盖 `/v1/responses` HTTP SSE 路径的 context headers、body control 字段、account-scoped prompt cache key、metadata、subagent、service tier,以及 `turnState`/`turnMetadata`/`betaFeatures`/`includeTimingMetrics`/`version`/`codexWindowId`/`parentThreadId`/`use_websocket` 不泄漏到上游 body。 -- 本轮新增 `tests/codex_serving/responses_http_sse.rs::v1_responses_stream_should_close_http_sse_upstream_when_client_disconnects`,用 raw TCP chunked SSE 上游证明下游 body stream 被 drop 后,上游 HTTP SSE socket 会及时关闭;Rust 虽不暴露 JS `AbortSignal`,但客户端断开触发的上游中断语义已有本地证据。 - -结论:已对齐。 - -已对齐: -- HTTP SSE 主请求的 URL、method、SSE Accept、Content-Type、OpenAI-Beta、residency、request/session id、installation id、window id、turn/context headers、subagent、Cookie 注入、Set-Cookie 捕获、rate-limit/turn-state 捕获均有对应实现。 -- 上游 body 会使用账号作用域 `prompt_cache_key`,并将安全链路字段同步到 `client_metadata`。 -- 非 2xx 错误体读取上限已对齐原版 1 MiB,并由 `codex_backend_client_should_cap_non_success_error_body_at_one_mib` 覆盖。 -- reqwest client 已按 `force_http11` 缓存复用,并对齐原版 `pool_max_idle_per_host(4)` 与 `tcp_keepalive(30s)`;`build_reqwest_client_should_reuse_cached_connection_pool` 覆盖跨 builder 调用的连接池复用。 -- `request_interval_ms` 已生效并按原版 ±30% jitter 计算同账号请求间隔。 -- Rust 与原版 native transport 使用同一 reqwest/rustls 主版本组合,且都支持 `force_http11`。 -- `/v1/responses/compact` 已对齐原版 JSON 上游链路:URL/method、核心 headers、Cookie/Set-Cookie、非 SSE Accept、body 字段白名单、reasoning/text 清洗和 JSON 响应透传均有实现;`v1_responses_compact_should_post_json_to_codex_compact_upstream` 覆盖该路径。 -- HTTP SSE 与 compact 请求的真实 wire header 相对顺序已由 raw TCP 测试覆盖,不再只是字段级断言。 -- HTTP SSE golden test 已覆盖 body control 字段转 header/metadata、账号作用域 identity,以及本地控制字段不进入上游 JSON body。 -- 客户端断开 abort 语义已有 raw TCP 证明:下游 SSE body 被 drop 时,上游 HTTP SSE 连接会在超时窗口内关闭。 -- IP 代理/VPN/proxy config/HttpsProxyAgent 是用户明确排除的非目标。Rust `.no_proxy()` 与原版 proxy 支持的差异不计入本项目 100% 一致性缺口,但需要保留边界说明。 - -非目标差异: -- 原版 native client builder 没有显式 `.no_proxy()`,而是通过 proxy 参数控制;Rust 显式 `.no_proxy()` 符合非代理目标,但也意味着环境代理不会生效。此项按非目标处理,不算 OpenAI 链路缺口。 - -缺口/后续动作: -- 无。 - -## 6. WebSocket 上游链路与连接池 - -原版证据: -- `src/routes/responses.ts:125-131` `/v1/responses` 默认设置 `codexRequest.useWebSocket = true`;`src/proxy/codex-api.ts:276-307` WebSocket 失败时,若带 `previous_response_id` 则不能降级 HTTP SSE,否则可降级 HTTP SSE。 -- `src/proxy/codex-api.ts:313-360` WebSocket URL 为 `{baseUrl}/codex/responses` 的 `wss://`/`ws://` 版本,handshake headers 使用 `buildHeaders()` 加 `OpenAI-Beta`、residency、request/session id、installation/window/turn/context/subagent。 -- `src/proxy/ws-transport.ts:155-171` 只向 `ws` constructor 传 `{ headers }`;原版依赖 `ws@8.19.0` 默认 opening handshake。抓包证据显示默认顺序包含 `Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits`、`Sec-WebSocket-Key`,随后按传入 headers 对象顺序输出业务 headers。 -- `src/proxy/codex-api.ts:335-359` `response.create` frame 包含 `model`、`instructions`、`input`、`store:false`、`stream:true`、`previous_response_id`、`reasoning`、`tools`、`tool_choice ?? "auto"`、`parallel_tool_calls ?? true`、`text`、normalized `service_tier`、account-scoped `prompt_cache_key`、`include`、`client_metadata`。 -- `src/proxy/ws-transport.ts:238-287` 优先使用 pool;busy/cap/dead/disabled/no_key 时 bypass 到 one-shot;复用连接 stale failure 时 one-shot 重试一次。 -- `src/proxy/ws-transport.ts:310-316` one-shot WebSocket handshake 有 20 秒 open timeout。 -- `src/proxy/ws-transport.ts:411-445` `codex.rate_limits` 内部事件只回调 rate-limit,不转发给客户端;首个可见错误 frame 会被分类成 `CodexApiError`;terminal event 后关闭 stream。 -- `src/proxy/ws-pool.ts:535-539` 默认连接池配置为 `enabled=true`、`maxAgeMs=3_300_000`、`maxPerAccount=8`。 -- `src/proxy/ws-pool.ts:156-244` pooled WS 每 25 秒 ping,默认 liveness timeout 为 ping interval 的 2.5 倍;busy 时不 ping。 -- `src/proxy/ws-pool.ts:566-704` pool 以 `${entryId}:${conversationId}` 为 key,严格单请求 in-flight;busy 时 bypass;超过账号 cap 时 bypass;GC 清理 dead/expired idle;`evictByEntryId()` 会对账号状态变化做级联驱逐。 -- `src/routes/shared/proxy-ws-context.ts:21-43` 只有在 `useWebSocket` 且存在稳定 `conversationId` 时才构造 pool context;显式 `previous_response_id` 续链即使 variantHash 变化,也沿用同一 upstream response chain。 -- `src/config-schema.ts:172-181` 暴露 `ws_pool.enabled`、`ws_pool.max_age_ms`、`ws_pool.max_per_account` 配置。 - -Rust 证据: -- `src/codex/gateway/transport/websocket/mod.rs:143-154` 默认 `WebSocketPreferred`,带 `previous_response_id` 时 `WebSocketRequired`,`force_http_sse` 时走 HTTP SSE。 -- `src/codex/gateway/transport/http_client.rs:143-179` 普通 create_response 先尝试 WebSocket;若是 preferred 且错误允许降级,则回落 HTTP SSE;`previous_response_id` required 不降级。 -- `src/codex/gateway/transport/websocket/mod.rs:405-450` WebSocket URL 构造与原版一致,`https://` 转 `wss://`,`http://` 转 `ws://`,路径为 `/codex/responses`。 -- `src/codex/gateway/transport/websocket/codec.rs:14-54` `response.create` frame 字段与原版主路径一致:`tool_choice` 默认 `auto`,`parallel_tool_calls` 默认 `true`,并带 `previous_response_id`、reasoning/tools/text/service_tier/prompt_cache_key/include/client_metadata。 -- `src/codex/gateway/transport/http_client.rs:523-600` 在进入 WS 前把 account-scoped `session_id` 写入 `prompt_cache_key`,并写入 body `client_metadata`。 -- 本轮修正后,WS handshake header profile 已独立于 HTTP SSE profile,不再携带 `Content-Type` 与 `Accept: text/event-stream`;`tests/codex_gateway/websocket.rs` 已覆盖该行为。 -- 本轮修正后,`src/codex/gateway/transport/websocket/opening.rs` 不再使用 `tokio-tungstenite` 默认 `HeaderMap` opening 序列化,而是显式生成原版 `ws@8.19.0` opening request:`Sec-WebSocket-Extensions` 位于 `Sec-WebSocket-Key` 前,业务 headers 按原版对象插入顺序输出,并恢复 `Authorization`、`ChatGPT-Account-Id`、`User-Agent`、`Accept-Encoding`、`Accept-Language`、`OpenAI-Beta`、`Cookie` 等原版大小写。 -- `tests/codex_gateway/websocket.rs::websocket_handshake_should_offer_original_permessage_deflate_extension` 通过 raw TCP 断言 WS opening request 的关键 header 顺序、大小写和 permessage-deflate offer 与原版抓包一致。 -- 本轮修正后,`src/codex/gateway/transport/websocket/deflate.rs` 在服务端接受 `Sec-WebSocket-Extensions: permessage-deflate` 时,会在底层流上解码 server-to-client RSV1 text/binary frame,再交给 `tungstenite` 处理;未协商时保持原始流直通。`tests/codex_gateway/websocket.rs::websocket_should_decode_permessage_deflate_response_frame_when_server_accepts_extension` 先以 `NonZeroReservedBits` 失败,再修复通过,覆盖服务端接受扩展并返回压缩 `response.completed` frame。 -- `src/codex/gateway/transport/websocket/mod.rs:230-328` pooled/reused WS stale failure 会重试一次 one-shot;内部 `codex.rate_limits` 会转为 rate-limit headers;terminal event 转为 SSE chunk。 -- 本轮修正后,`src/codex/gateway/transport/websocket/mod.rs` 的 one-shot WebSocket handshake 使用 20 秒 open timeout;`connect_websocket_with_timeout_should_fail_when_handshake_stalls` 覆盖握手停滞时返回 timeout。 -- `src/codex/gateway/transport/websocket/pool.rs:58-76` 默认 `max_age=55min`、`max_per_account=8`、`maintenance_interval=25s`、`ping_interval=25s`、`ping_timeout=5s`、`liveness_timeout=62.5s`。 -- `src/codex/gateway/transport/websocket/pool.rs:129-170` pool 以 `(base_url, account_id, conversation_id)` 为 key;busy/checking bypass;超过账号 cap bypass;expired idle 关闭。 -- `src/codex/gateway/transport/websocket/pool.rs:225-319` maintenance 会 ping idle connection,并按 max age/liveness timeout 清理。 -- `src/codex/accounts/service/refresh.rs:170-195`、`src/codex/serving/dispatch/limits.rs:8-73`、`src/codex/serving/dispatch/fallback.rs:350-470`、`src/codex/accounts/service/lifecycle.rs:67-77` 在账号 refresh、quota cooldown、fallback 状态变化、账号删除等路径会驱逐账号 WS。 -- `src/config/types.rs`、`config.yaml`、`src/runtime/state.rs` 已暴露并接入 `ws_pool.enabled/max_age_ms/max_per_account`,默认值与原版 `enabled=true/max_age_ms=3_300_000/max_per_account=8` 一致。 -- `src/codex/gateway/transport/websocket/codec.rs` 已按原版区分 one-shot 与 pooled WS 错误分类 profile:one-shot 不提前分类 `websocket_connection_limit_reached`,pooled path 仍将其映射为 503 并标记连接 fatal。 -- `tests/codex_serving/responses_websocket.rs:47-110` 覆盖默认 WebSocket 上游;`tests/codex_serving/responses_websocket.rs:300-360` 覆盖 recorded conversation 复用同一 WS。 -- `tests/config.rs` 覆盖默认 `ws_pool` 配置和 `local.yaml` 覆盖;`v1_responses_websocket_should_not_reuse_connection_when_pool_is_disabled` 覆盖关闭 pool 后仍走 WebSocket 但不复用物理连接。 -- `tests/codex_gateway/websocket.rs::websocket_one_shot_should_passthrough_connection_limit_failed_frame` 与 `websocket_pooled_connection_limit_frame_should_surface_as_503` 覆盖同一错误码在 one-shot/pool 两条路径的原版差异。 -- `tests/codex_gateway/websocket.rs:44-76` 覆盖 transport 策略;`tests/codex_gateway/websocket.rs:376-760` 覆盖 handshake 429、首帧 rotatable error、HTTP SSE fallback、handshake headers 捕获、内部 `codex.rate_limits` 不转发、mid-stream close error。 -- `tests/codex_gateway/websocket/pool.rs:1-559` 覆盖 busy key bypass、账号 cap bypass、keepalive pong/no-pong、过期 GC、shutdown。 - -结论:部分对齐。 - -已对齐: -- 默认上游传输策略已与原版方向一致:普通 `/v1/responses` 优先 WebSocket,`previous_response_id` 必须 WebSocket 且不能安全降级,非历史 WebSocket 传输错误可降级 HTTP SSE。 -- WS URL、`response.create` frame 主字段、tool 默认值、service tier、prompt cache key、client metadata、internal rate-limit event、terminal event SSE 包装均已基本对齐。 -- WS handshake headers 已去掉原版没有的 `Content-Type` 与 `Accept: text/event-stream`。 -- WS opening request 的关键 wire 顺序、大小写和 `Sec-WebSocket-Extensions` offer 已按原版 `ws@8.19.0` 对齐,并由 raw TCP 测试覆盖。 -- WS permessage-deflate 协商后的 server-to-client 压缩 text/binary frame 已支持解码,避免上游接受扩展后因 RSV1 frame 触发 `tungstenite` `NonZeroReservedBits`。 -- one-shot WebSocket handshake 已增加原版同等 20 秒 open timeout;普通无历史请求超时后仍可按原版策略降级 HTTP SSE,`previous_response_id` required WS 不降级。 -- 连接池核心状态机基本对齐:55 分钟 max age、每账号最多 8 条、严格单 in-flight、busy/cap bypass、stale reuse 一次 fresh retry、idle keepalive、liveness/GC、shutdown、账号状态变更驱逐。 -- WebSocket pool 配置面已对齐原版:配置文件显式提供 `ws_pool.enabled/max_age_ms/max_per_account`,运行时按配置启停复用和调整 max age/account cap。 -- `websocket_connection_limit_reached` 已按原版保留路径差异:pooled path 视为 503 fatal 并驱逐;one-shot/bypass path 不在 WS transport 层提前分类,而是透传 `response.failed` frame 给上层处理。 -- Rust 对账号状态变化的 WS 驱逐覆盖面较完整,refresh、quota cooldown、fallback、删除/禁用等路径都会清理对应账号连接。 - -未完全对齐: -- pool key 表达不完全一致。原版 key 是 `${entryId}:${rawConversationId}`;Rust key 是 `(base_url, account_id, accountScopedConversationId)`。行为上通过测试证明同 conversation 会复用,但若后续要与原版日志/配置/诊断完全一致,命名与 key 语义仍不同。 -- Rust pool maintenance 是集中 sweep ping idle connection;原版是每条 `PersistentWs` 自带 interval,busy 时跳过 ping。默认行为接近,但调度模型不是逐字等价。 - -缺口/后续动作: -- 若需要更强证明,可增加真实 OpenAI 上游抓包验证,确认当前 Codex 后端是否实际返回压缩 frame;本地压缩 frame fixture 已覆盖客户端能力。 - -## 7. response/SSE/WS frame 转换链路 - -原版证据: -- `src/proxy/codex-sse.ts:8-41` 的 `parseSSEBlock()` 除标准 `data:` 行外,还解析非标准上游错误流:漂亮打印 JSON 的续行即使没有重复 `data:` 前缀,也会拼入 data;`[DONE]` 会被忽略。 -- `src/proxy/codex-sse.ts:44-95` 的 `parseSSEStream()` 对单个未解析 SSE event buffer 设置 64 MiB 上限;如果 response 不是 SSE 但有 body,会合成 `event: error`,code 为 `non_sse_response`。 -- `src/routes/shared/response-processor.ts:50-229` streaming 入口每 15 秒写 SSE heartbeat;上游读取错误会写一个 Responses 格式的失败 SSE 后再关闭。 -- `src/routes/responses-passthrough.ts:168-333` 的 `streamPassthrough()` 逐个解析 Codex event,记录 response id、usage、function call ids、reasoning replay items;如果上游在 `response.completed`/`response.failed`/`error` 之前关闭,会合成 `response.failed`,code 为 `stream_disconnected`。 -- `src/routes/responses-passthrough.ts:343-488` 的 `collectPassthrough()` 会从 event 流重建最终 response,补齐 output/output_text,提取 usage、image usage、function call ids、reasoning replay items;遇到 `error` 或 `response.failed` 会抛 `Codex API error`,供外层错误分类和 retry/fallback 使用。 -- `src/routes/responses-passthrough.ts:230-267`、`439-463` 对 tuple schema 做 streaming/collect 双路径 reconvert。 -- `src/translation/codex-event-extractor.ts:46-79` 区分 `EmptyResponseError` 与 `UpstreamPrematureCloseError`,后者用于上游无 terminal event 关闭,原版注释明确这种情况不应跨账号重试。 -- `src/translation/codex-api-error-from-event.ts:1-31` 将 `error`/`response.failed` SSE event 转换为 HTTP-equivalent `CodexApiError`,让 non-streaming catch 路径能复用 400/401/402/403/429/502 的恢复逻辑。 -- 2026-06-15 复核 reasoning replay:`src/proxy/reasoning-replay-cache.ts` 以 55 分钟 TTL、512 entries、256 KiB 单条、4 MiB 总量缓存 `reasoning` 与 `function_call` replay items,key 为 `responseId + entryId + conversationId + variantHash`。 -- 2026-06-15 复核 implicit resume:`src/routes/shared/proxy-session-context.ts` 在无显式 `previous_response_id`、存在 continuation input 且 conversation/variant 命中时查找最新 response id;`src/routes/shared/proxy-implicit-resume-request.ts` 会把 `previous_response_id`、`useWebSocket=true`、turnState 和 `reasoningReplayItems + continuationInput` 写回本次上游请求。 - -Rust 证据: -- 本轮修正后,`src/codex/gateway/transport/sse.rs` 的 parser 支持标准 `event/data/id/retry/comment`,同时按原版处理非 `data:` 前缀的 JSON 续行、忽略 `[DONE]`,并在非 SSE body 场景合成 `error/non_sse_response` 事件。 -- `src/codex/gateway/transport/websocket/codec.rs:14-85` 将 WS `response.create` frame 构造成上游 JSON,并把每个可见 WS JSON frame 包装成 `event: \ndata: ` 的 SSE chunk;`codex.rate_limits` 是内部事件,不转发给客户端。 -- `src/codex/gateway/transport/websocket/mod.rs:230-328` 在 WS stream 中遇到 terminal event 后结束;mid-stream close 会返回 `ClosedBeforeTerminal` 传输错误。 -- `src/codex/serving/dispatch/mod.rs:338-476` HTTP SSE streaming 直接透传上游 bytes,并每 15 秒写 `: ping` heartbeat;上游 bytes stream error 会记录 transport error 并以 stream error 结束。 -- `src/codex/serving/dispatch/mod.rs:830-923` WebSocket streaming 将 WS SSE chunk 透传给客户端,并每 15 秒写 heartbeat;WS stream error 会转换为 `std::io::Error` 结束响应流。 -- `src/codex/serving/dispatch/stream.rs:48-104` non-streaming collect 会解析 SSE,拼接 output text、output item、completed response;识别 `error`/`response.failed` 为 `CollectedResponse::Failed`。 -- 本轮修正后,`src/codex/serving/dispatch/stream.rs` 会从 `response.output_item.done` 和 `response.completed.response.output[]` 收集 function call ids 与 replay items;`reasoning`/`function_call` replay item 会进入后续 implicit resume 缓存。 -- 本轮修正后,`src/codex/serving/dispatch/reasoning_replay.rs` 按原版约束缓存 replay items:TTL 55 分钟、512 entries、单项 256 KiB、总量 4 MiB,并对白名单 `reasoning`/`function_call` item 做清洗和去重。 -- 本轮修正后,`src/codex/gateway/transport/sse.rs` 增加与原版 `parseSSEStream()` 一致的单个未解析 SSE event buffer 上限:64 MiB。该限制按事件块计数,遇到空行完成事件后重置;`tests/codex_gateway/usage_events.rs::parse_sse_events_should_reject_single_event_buffer_above_original_limit` 先以 oversized event 被接受失败,再修复通过。 -- 本轮修正后,`src/codex/gateway/protocol/tuple_schema.rs` 按原版 `tuple-schema.ts`/`prepareSchema()` 实现 OpenAI JSON Schema tuple 处理:请求侧把 `prefixItems` 转为数字键 object schema,并注入 `additionalProperties:false`;响应侧按原始 schema 把 `{"0":...}` 还原为数组。 -- 本轮修正后,`/v1/responses` native 链路会把原始 tuple schema 保存在本地 `CodexResponsesRequest::tuple_schema`,该字段 `serde(skip)` 不进入上游;non-streaming collect 和 HTTP SSE/WebSocket streaming 均在返回客户端前 reconvert output text,并按原版从 `output[].content` 同步 `output_text`。`tests/codex_serving/responses_http_sse.rs::v1_responses_should_convert_tuple_schema_before_upstream`、`v1_responses_should_reconvert_tuple_schema_output_for_client`、`v1_responses_stream_should_reconvert_tuple_schema_output_for_client` 覆盖该链路;`tests/fixtures/responses/golden/tuple_reconvert_response.json` 与 `tests/fixtures/responses/golden/tuple_reconvert_stream.sse` 分别对 non-streaming 与 HTTP SSE streaming tuple reconvert 输出做 exact golden 断言。 -- 本轮修正后,`/v1/chat/completions` OpenAI Chat 链路也接入相同 tuple schema 处理:`response_format.json_schema.schema` 上游前会转换,non-streaming message content 和 streaming content delta 会还原给客户端;`tests/codex_serving/chat_completions.rs::chat_completions_should_convert_and_reconvert_tuple_schema` 与 `chat_completions_stream_should_reconvert_tuple_schema` 覆盖该链路。 -- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 在无显式 `previous_response_id` 的 full-history continuation 中,会按 conversation/variant 找 55 分钟内最新 response,校验 instructions hash 与 function call continuation,随后写入 `previous_response_id`、强制 WebSocket、截取 continuation input,并在 input 前注入 reasoning replay items;若该隐式续链遇到 previous-response 错误,会恢复原始 full-history request 后同账号重试。 -- `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_implicitly_resume_full_history_continuation_with_reasoning_replay` 覆盖第二轮上游请求自动携带 `previous_response_id`、复用 prompt cache key,并将 cached reasoning replay item 放在 continuation input 前。 -- 本轮新增 `tests/fixtures/responses/golden/reasoning_replay_request.json`,同一个 reasoning replay 测试会移除动态 `prompt_cache_key`/`client_metadata` 后对第二轮 `response.create` 上游请求做 exact golden 断言,锁定 `previous_response_id`、截断后的 continuation input 与 replay item 顺序。 -- `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing` 覆盖隐式续链 `previous_response_not_found` 后,下一次上游请求恢复完整 3 条历史 input 且不带 `previous_response_id`。 -- 本轮修正后,`src/codex/serving/responses.rs:145-365` non-streaming 对空响应最多跨账号重试 2 次;`CollectedResponse::Failed` 会转换为可分类的 `CodexClientError::Upstream`,429/402/403 会复用账号 retry/fallback 状态机,不再固定降级为 502。 -- 本轮修正后,`src/codex/serving/dispatch/stream.rs` 会把 `response.failed` 中的 `token_invalid`、`token_expired`、`account_deactivated` 映射为 401,与原版 WebSocket rotatable error code 集合一致;`tests/fixtures/responses/http_sse/response_failed_token_invalid.sse` 和 `tests/fixtures/responses/golden/response_failed_auth_recovery.json` 覆盖 non-streaming mid-SSE auth failure 标记当前账号 expired、fallback 到下一账号,并精确比对最终 Responses JSON。 -- 本轮修正后,`src/codex/serving/responses.rs` 会复用第一次 SSE collect 结果生成最终响应,不再对同一个 upstream body 二次解析。 -- 本轮修正后,`src/codex/serving/http/errors.rs` 增加 Responses 专用错误外壳:普通上游错误返回 `type:error` + `server_error/codex_api_error`,429 返回 `rate_limit_error/rate_limit_exceeded`,无可用账号返回 `no_available_accounts`;`src/codex/serving/responses.rs` 的 `/v1/responses` 与 `/v1/responses/compact` 非流式错误路径已切到该外壳,Chat 仍保留 OpenAI Chat 错误格式。 -- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 增加 request-level recovery 分类,识别 `previous_response_not_found` 和 `No tool output found for function call`;`src/codex/serving/responses.rs`、`src/codex/serving/dispatch/mod.rs` 在账号 fallback 前先清除 stale `previous_response_id`/`turn_state` 并同账号重试一次。 -- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 增加集中 `UpstreamRecoveryAction` 与 `UpstreamRecoveryState` 分类入口,优先级为 request recovery,再 account retry,最后返回错误;`src/codex/serving/responses.rs`、HTTP SSE streaming 和 WebSocket streaming 的请求建立/collect 错误路径已改为复用该入口。 -- `tests/codex_serving/upstream_errors.rs:67-194` 覆盖 non-streaming `response.failed` 按 `rate_limit_exceeded` 返回 429、streaming 透传 `response.failed` 并记录失败审计。 -- 本轮修正后,`tests/codex_serving/upstream_errors.rs` 覆盖 non-streaming `error` 事件返回 `codex_api_error`,`response.failed` 429 返回 `rate_limit_exceeded`;`tests/codex_serving/responses_http_sse.rs` 覆盖 `/v1/responses` 和 `/v1/responses/compact` 无可用账号返回 `no_available_accounts`,以及 compact fallback exhausted 429 返回 `rate_limit_exceeded`。 -- 本轮新增 `tests/fixtures/responses/http_sse/response_failed_quota.sse` 与 `tests/codex_serving/upstream_fallback.rs::v1_responses_should_classify_non_stream_sse_failure_and_retry_next_account`,覆盖 non-streaming mid-SSE `quota_exceeded` 会标记当前账号 `quota_exhausted` 并 fallback 到下一账号。 -- 本轮新增 `tests/fixtures/responses/http_sse/previous_response_not_found.sse`、`tests/fixtures/responses/http_sse/unanswered_function_call.sse` 与 `tests/codex_serving/upstream_fallback.rs::v1_responses_should_strip_history_after_http_sse_previous_response_not_found`、`v1_responses_should_strip_history_after_http_sse_unanswered_function_call`,覆盖 HTTP SSE non-streaming collect 阶段收到 stale-history 类 `error` 事件后会走同账号 strip-and-retry。 -- 本轮新增 `tests/fixtures/responses/websocket/previous_response_not_found.json`、`tests/fixtures/responses/websocket/unanswered_function_call.json`,并用 `tests/codex_serving/responses_websocket.rs` 覆盖 non-streaming 与 streaming `previous_response_not_found`、non-streaming unanswered function call 的同账号 strip-and-retry。 -- `tests/codex_gateway/usage_events.rs` 覆盖 multiline data、非标准 JSON 续行、`[DONE]` 忽略和非 SSE JSON body 转 `non_sse_response`。 -- 本轮新增 `tests/fixtures/responses/golden/sse_standard_events.json`、`tests/fixtures/responses/golden/sse_pretty_json_events.json`、`tests/fixtures/responses/golden/non_sse_body_event.json`,并在 `tests/codex_gateway/usage_events.rs` 中对标准 SSE、漂亮打印 JSON 续行、非 SSE body 合成事件做 exact golden 断言。 -- `tests/codex_gateway/websocket.rs:376-760` 覆盖 WS 内部 rate-limit event 不转发、首个 rotatable error、mid-stream close error 和 fallback。 -- 本轮新增 `tests/fixtures/responses/golden/websocket_completed.sse`,并在 `tests/codex_gateway/websocket.rs::ordinary_response_should_use_websocket_transport_by_default` 中对 WebSocket `response.completed` JSON frame 转下游 SSE chunk 做 exact golden 断言,锁定 `event:`、`data:` 和 SSE 终止分隔符格式。 -- 本轮修正后,HTTP SSE streaming 在上游正常 EOF 但缺少 `response.completed`/`response.failed`/`error` 时,会给客户端追加原版格式的 `response.failed`,错误码为 `stream_disconnected`;`tests/codex_serving/upstream_errors.rs::v1_responses_stream_should_synthesize_response_failed_when_http_sse_closes_before_terminal` 覆盖该行为。 -- 本轮新增 `tests/fixtures/responses/golden/stream_premature_close.sse`,并在 `tests/codex_serving/upstream_errors.rs::v1_responses_stream_should_synthesize_response_failed_when_http_sse_closes_before_terminal` 中对 HTTP SSE premature close 的完整下游 SSE 输出做 exact golden 断言;动态 `resp_proxy_*` id 在测试侧 redaction 后比较。 -- 本轮新增 `tests/fixtures/responses/golden/cloudflare_challenge_no_fallback.json`,并在 `tests/codex_serving/upstream_fallback.rs::v1_responses_should_return_502_when_cloudflare_challenge_has_no_fallback` 中对 Cloudflare challenge 无备用账号的 Responses error shell 做 exact golden 断言,锁定 `server_error/codex_api_error`、502 语义和 account exhaustion message。 -- 本轮修正后,streaming `/v1/responses` 在上游请求建立前 fallback exhausted 时,不再返回 OpenAI JSON error 外壳,而是按原版 `streamErrorResponse()`/`PASSTHROUGH_FORMAT.formatStreamError()` 语义返回 HTTP 200 `text/event-stream` 的 `response.failed` SSE;HTTP SSE 401/402/model unsupported/path-block 单账号无 fallback,以及 WebSocket 429/402/model unsupported/path-block fallback exhausted 已由 `tests/codex_serving/upstream_fallback.rs` 和 `tests/codex_serving/responses_websocket.rs` 做结构断言,锁定 `event: response.failed`、动态 `resp_proxy_*` id、`response.error == error`、错误 `type/code` 和 account exhaustion/message 关键语义。这里不再保存每个 mock 组合的 exact stream golden,因为这些是代理生成的下游错误流组合,不是原版或上游提供的返回流样本。 -- 本轮修正后,合成 `response.failed` 的 JSON 字段顺序已按原版 `JSON.stringify()` 插入顺序输出:外层 `type,response,error`,error 对象 `type,code,message`;`stream_premature_close.sse` golden 同步覆盖该顺序,避免 Rust `serde_json::Value` 默认 key 排序导致 wire 格式回退。 -- 本轮修正后,WebSocket streaming 在首个可见 frame 已发送后遇到 `ClosedBeforeTerminal` 或其它未见 terminal 的 body stream 错误时,也会追加同样的 `response.failed/stream_disconnected`,不再把 body stream error 直接暴露给客户端;`tests/codex_serving/responses_websocket.rs::v1_responses_websocket_stream_should_synthesize_response_failed_when_closed_before_terminal` 覆盖该行为。 -- 本轮修正后,HTTP SSE 与 WebSocket streaming 的 heartbeat 首次触发延后到 15 秒,不会因为 `tokio::time::interval()` 的立即 tick 抢在上游首个可见 frame 前面;`tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_stream_first_frame_before_terminal_event` 覆盖首个 WebSocket delta 可先于 terminal 返回给客户端。 - -结论:部分对齐。 - -已对齐: -- 标准 SSE 事件解析、WS JSON frame 到 SSE chunk 的基本转换、terminal event 判断、`codex.rate_limits` 内部事件过滤、heartbeat 15 秒节奏、usage 提取、function call ids 提取、空响应重试等主路径已经具备。 -- WebSocket `response.completed` frame 到 SSE chunk 已有 exact golden 覆盖,确认 raw WS JSON 按原版对下游表现为 `event: response.completed` + 单个 `data:` JSON frame。 -- SSE parser 已对齐原版的标准事件解析和三个容错点:漂亮打印 JSON 续行、`[DONE]` 忽略、非 SSE body 合成 `error/non_sse_response`;标准 SSE、漂亮打印 JSON 续行、非 SSE body 均已有 exact golden 覆盖。 -- streaming 路径对客户端保持 SSE 格式,HTTP SSE 与 WebSocket 两种上游传输都能以 SSE 返回给下游。 -- `response.failed`/`error` 在 streaming 路径会被透传并写入审计;non-streaming 路径也能识别为上游失败,并已对 429/402/403/401 进入账号状态机和 fallback。 -- request-level `previous_response_not_found` 与 unanswered function call 已按原版优先于账号 fallback 处理:忘记 stale affinity,清除 `previous_response_id`/`turn_state`,同账号重试一次。 -- retry/recovery 分类优先级已集中为 `UpstreamRecoveryAction`:request recovery 优先于 account retry,model unsupported 只允许一次,再进入最终错误响应。 -- HTTP SSE non-streaming collect 阶段的 `previous_response_not_found` 与 unanswered function call 已有端到端覆盖,确认 mid-SSE error event 会复用同账号 strip-and-retry。 -- streaming premature close 已按原版对齐:HTTP SSE 正常 EOF、HTTP SSE body stream error、WebSocket body stream error 在缺少 terminal event 时都会合成 `event: response.failed`,错误码 `stream_disconnected`,并进入现有 stream 审计失败记录;合成事件的字段顺序也已按原版固定。 -- streaming fallback exhausted 外壳已按原版对齐:上游请求建立前的账号级错误在无备用账号时返回 HTTP 200 SSE `response.failed`,而不是 OpenAI JSON error;401 token invalid、402 quota exhausted、model unsupported、Cloudflare path-block 的 HTTP SSE 单账号无 fallback,以及 WebSocket 429/402/model unsupported/path-block fallback exhausted 均已有结构断言覆盖。该类测试只锁定原版稳定语义,不再把 mock account exhaustion 场景组合成 exact golden 返回流。 -- HTTP SSE premature close 已有 exact golden 覆盖,锁定先透传已收到 delta、再追加 `response.failed/stream_disconnected`、并以 SSE 双换行结束的完整输出格式。 -- 单个未解析 SSE event buffer 已按原版限制为 64 MiB,避免异常上游响应在解析阶段无界增长。 -- OpenAI Responses 与 OpenAI Chat 的 tuple schema request conversion/reconvert 已对齐原版,不涉及非 OpenAI translator;Responses non-streaming 与 HTTP SSE streaming tuple reconvert 已有 exact golden 覆盖。 -- Responses pooled path 的非流式最终错误外壳已按原版 `PASSTHROUGH_FORMAT` 对齐:普通上游错误为 `codex_api_error`,429 为 `rate_limit_exceeded`,无账号为 `no_available_accounts`;compact 路径复用同一 Responses 外壳,`compact_rate_limit_fallback_exhausted.json` 与 `compact_no_available_accounts.json` 已做 exact golden 覆盖。 -- OpenAI Chat 最终错误外壳继续保持 Chat/OpenAI 形状而不是 Responses passthrough 形状;`tests/fixtures/chat/golden/no_available_accounts.json`、`rate_limit_fallback_exhausted.json`、`quota_exhausted_fallback_exhausted.json` 精确锁定无账号、429 fallback exhausted、402 fallback exhausted 的 `openai_error` 输出。 - -未完全对齐: -- 无。 - -缺口/后续动作: -- 无。原版没有完整 golden fixture 矩阵作为一致性目标。 - -## 8. fallback、retry、错误分类 - -原版证据: -- `src/proxy/error-classification.ts:37-148` 分类 429 retry-after、402 quota、403 Cloudflare challenge、403 ban、401 token invalid、400 `previous_response_not_found`、400 unanswered function call、空 body 404 Cloudflare path-block、model not supported。 -- `src/routes/shared/proxy-retry-classifier.ts:34-68` 的恢复优先级是:implicit resume replay、strip stale `previous_response_id`、再交给通用错误处理。 -- `src/routes/shared/proxy-retry-recovery.ts:27-96` 对 `previous_response_not_found` 和 `No tool output found for function call` 只 strip 一次 `previous_response_id`/`turnState`,忘记 stale affinity,并在同账号重试。 -- `src/routes/shared/proxy-retry-recovery.ts:98-151` 有 Cascading Ban Defense:只有 preferred account 已 banned/disabled 且实际换账号时,才主动 strip `previous_response_id` 和 `turnState`。 -- `src/routes/shared/proxy-error-handler.ts:63-185` 处理上游 `CodexApiError`:model unsupported 只重试一次其他账号;429 写 cachedQuota rate_limit 并 fallback;402 标记 quota_exhausted;CF challenge 进入 cooldown;非 CF 403 标记 banned;401 标记 expired/banned 并 fallback;空 body 404 清 cookie,连续 3 次 auto-disable;其他错误保留 body 返回。 -- `src/routes/shared/proxy-error-retry-transition.ts:43-102` 和 `proxy-fallback-account-retry.ts:39-75` 统一处理 release、restore implicit resume、fallback account acquire、API rebuild、无 fallback 时的 account exhaustion response。 -- `src/utils/retry.ts:1-25` 对同一上游 attempt 的 5xx `CodexApiError` 做最多 2 次指数退避重试。 -- `src/routes/shared/non-streaming-handler.ts:144-204` 把 collect 中的 `CodexApiError` 重新抛给外层统一 catch,确保 mid-SSE `previous_response_not_found`/unanswered function call 与 HTTP-time 错误走同一恢复逻辑。 -- `src/routes/shared/proxy-upstream-attempt.ts:71-96` 每次上游请求后记录 egress log,并应用 rate-limit headers。 - -Rust 证据: -- `src/codex/serving/dispatch/fallback.rs:20-211` 把 429、402、403 Cloudflare challenge、403 ban、空 body 404 Cloudflare path-block、401 token invalid/deactivated 分类成 `UpstreamAccountRetry`。 -- `src/codex/serving/dispatch/fallback.rs:331-446` 对 429 设置 quota cooldown 并记录 request attempt;402 设置 `QuotaExhausted`;Cloudflare challenge 删除 cookies 并设置 persisted cooldown;空 body 404 清理当前账号 cookies、记录 1 小时滑窗内 path-block 次数,连续 3 次设置 `Disabled`;401 token invalid 设置 `Expired`,401 deactivated 设置 `Banned`;403 ban 设置 `Banned`;状态变化会 evict WS pool。 -- 本轮修正后,`src/codex/serving/dispatch/account_refresh.rs` 已删除;serving request path 不再对上游 401 做同账号 OAuth refresh,refresh 仍保留在账号健康检查、后台调度和 admin/manual refresh 链路。 -- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 在 HTTP SSE/WS streaming 请求建立前,遇到可分类错误会尝试 acquire fallback account;显式 `previous_response_id` 仍保持 WebSocket required,不会降级 HTTP SSE,但 429/402/403/401/model unsupported 等账号级错误会按原版通用 error handler 尝试备用账号。 -- 本轮修正后,`src/codex/serving/responses.rs` non-streaming 请求前错误与 mid-SSE `error`/`response.failed` 都会进入集中 `UpstreamRecoveryAction` 分类;显式 `previous_response_id` 遇到可分类账号错误不再有专用 hold 分支,会进入同一 fallback 状态机。 -- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 的 HTTP SSE streaming 与 WebSocket streaming 请求建立失败也会先尝试 request-level recovery,`previous_response_not_found` 可同账号 strip-and-retry。 -- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 抽出集中 `UpstreamRecoveryAction` 分类入口,单元测试覆盖 request recovery 优先于 account retry、request recovery 只使用一次后委派给账号 retry、model unsupported 只重试一次;`src/codex/serving/responses.rs`、HTTP SSE streaming 和 WebSocket streaming 请求建立/collect 错误路径已复用该入口。 -- 本轮修正后,`src/codex/serving/dispatch/transition.rs` 抽出 `execute_upstream_request_recovery_transition_with_deps()` 与 `execute_upstream_account_recovery_transition_with_deps()`:request recovery 统一负责 forget stale affinity、restore implicit resume snapshot、清除 `previous_response_id`/`turn_state` 与审计日志;account recovery 统一负责账号状态副作用、fallback account acquire 与 account exhaustion message 合成。 -- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 不再直接 acquire fallback account,只保留错误分类、fallback response message、账号状态/cooldown/cookie/WS eviction 副作用;`tests/architecture/serving_boundary.rs::serving_dispatch_has_dedicated_recovery_transition_boundary` 约束 transition 文件边界和 fallback 不再直接 acquire。 -- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 在 non-streaming、HTTP SSE streaming、WebSocket streaming 的上游 request attempt 外层加入 `retry_upstream_5xx()`:仅对 `CodexClientError::Upstream` 的 5xx 做同账号最多 2 次重试,退避为 1000ms、2000ms,与原版 `withRetry()` 默认策略一致。 -- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 识别 4xx `model not supported`、`model_not_supported`、`model not available`、`model_not_available`,只允许一次备用账号 retry,不标记账号状态,也不驱逐 WebSocket pool。 -- 本轮修正后,`src/codex/serving/dispatch/stream.rs` 会把 non-streaming collect 得到的 `response.failed` `model_not_supported`/`model_not_available` 合成为 400 上游错误,使它进入同一 model fallback 状态机。 -- `src/codex/serving/http/errors.rs` 保留 Chat/通用 OpenAI error helper,同时为 Responses 增加 `PASSTHROUGH_FORMAT` 风格 helper;无 fallback 的空 body 404 会按原版 path-block 语义返回 502 `Upstream blocked the request (Cloudflare path-block)`,Responses JSON 外壳为 `codex_api_error`。 -- `src/codex/accounts/cloudflare_challenge.rs:1-120` 实现了 CF path-block tracker,连续次数超过 1 小时会重置,阈值为 3。 -- `tests/codex_serving/upstream_fallback.rs:40-1400` 覆盖 429 fallback、streaming 429 fallback、402 标记并 fallback、403 ban、Cloudflare challenge cooldown/cookie 清理、Cloudflare path-block 404 清 cookies 并 fallback、连续 3 次 path-block 后禁用账号、401 token invalid 标记 expired 后 fallback、401 deactivated 标记 banned、无 fallback 时返回 401。 -- `tests/codex_serving/responses_websocket.rs` 覆盖 WebSocket 非历史请求先因 429 fallback 到备用账号,再因 401 将备用账号标记 expired 并返回 401,不做同账号 refresh。 -- 本轮新增 `tests/fixtures/responses/http_sse/previous_response_not_found.sse`、`tests/fixtures/responses/http_sse/unanswered_function_call.sse` 与 `tests/codex_serving/upstream_fallback.rs` 覆盖 HTTP SSE non-streaming mid-SSE stale-history 类错误复用同账号 strip-and-retry。 -- 本轮新增 `tests/fixtures/responses/http_sse/after_model_unsupported_retry.sse`、`tests/fixtures/responses/http_sse/response_failed_model_unsupported.sse`,并用 `tests/codex_serving/upstream_fallback.rs` 覆盖 HTTP-time model unsupported fallback、non-streaming mid-SSE model unsupported fallback、以及 model unsupported 最多只重试一次。 -- 本轮新增 `tests/fixtures/responses/http_sse/after_5xx_retry.sse`、`tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse`,并用 `tests/codex_serving/upstream_fallback.rs` 覆盖 non-streaming 与 streaming 两条路径在同账号连续 2 次 5xx 后第三次成功,且不会切换到备用账号。 -- `tests/codex_serving/responses_websocket.rs` 覆盖 `previous_response_id` WS 429 fallback 到备用账号且继续携带同一 `previous_response_id`,并覆盖非历史 WS fallback 和 fallback 账户 refresh。 - -结论:已对齐。 - -已对齐: -- 429、402、403 ban、403 Cloudflare challenge 的主路径已经具备账号状态更新和 fallback account retry。 -- non-streaming mid-SSE `response.failed`/`error` 的 429、402、403 已不再固定返回 502,会进入同一账号状态机和 fallback 逻辑;`quota_exceeded` fixture 已覆盖 quota exhausted 标记与备用账号接管。 -- `previous_response_not_found` 与 unanswered function call 已优先于账号 fallback 处理,清除 stale history 后同账号重试一次;streaming 与 non-streaming 的 WS 首帧错误均有测试覆盖。 -- 5xx same-account retry 已对齐原版:上游 request attempt 对 5xx 同账号最多重试 2 次,退避 1000ms、2000ms;non-streaming 和 streaming HTTP SSE 均有集成测试覆盖,WebSocket streaming 也复用同一请求建立 helper。 -- CF path-block 404 已接入主链路:空 body 404 会清理当前账号 cookies、记录 1 小时滑窗内连续次数、尝试 fallback account,达到 3 次后把账号标记为 `disabled`;无 fallback 时返回 502,并保留 `Upstream blocked the request (Cloudflare path-block)` 消息。 -- model unsupported fallback 已对齐原版:账号 plan 不支持当前 model 时最多换一个备用账号重试一次,不改变账号状态;HTTP-time 错误与 non-streaming mid-SSE `response.failed` 均有测试覆盖。 -- 401 token invalid fallback 已对齐原版:request path 不再 refresh 同账号;普通 401 会标记当前账号 `expired` 并尝试备用账号,包含 `deactivated` 的 401 会标记 `banned`;HTTP SSE streaming/non-streaming 与 WebSocket fallback exhausted 均有测试覆盖。 -- `response.failed` auth recovery 已对齐原版 WebSocket rotatable error code 语义:mid-SSE `token_invalid` 会转换为 401,进入同一账号 fallback transition,并由 `response_failed_auth_recovery.json` golden 锁定最终 Responses JSON。 -- fallback exhausted response body 已对齐原版:当已经尝试 fallback 但没有可用账号时,Rust 会按账号池状态生成 `All accounts exhausted (...)` / `No accounts available...` 前缀,再保留原始上游错误消息;HTTP SSE stream、WebSocket stream、Responses non-stream、Compact 和 Chat fallback exhausted 路径复用同一消息构造,`v1_responses_should_mark_expired_and_return_401_when_401_has_no_fallback` 覆盖默认 streaming SSE 响应体,`v1_responses_non_stream_should_mark_expired_and_return_401_when_401_has_no_fallback` 覆盖非流式 JSON 响应体,compact/chat 的基础最终错误外壳已有 exact golden 覆盖。 -- Responses/compact fallback exhausted 的 JSON 外壳已对齐原版:429 使用 `rate_limit_error/rate_limit_exceeded`,其它账号级错误使用 `server_error/codex_api_error`,无可用账号使用 `server_error/no_available_accounts`。 -- Cloudflare challenge fallback exhausted status/message 已对齐原版:上游 403 challenge 会作为 retry decision 暴露 502 和 `Upstream blocked the request (Cloudflare challenge)`,无备用账号时返回该 502 语义;`cloudflare_challenge_no_fallback.json` golden 覆盖单账号无 fallback 的完整 Responses error shell。 -- Rust 的 Cloudflare challenge 处理比原版更持久:会把 cooldown 写入数据库,并清理对应账号 cookies。 -- 显式 `previous_response_id` 的账号级错误恢复已对齐原版:不降级 HTTP SSE,仍通过 WebSocket 向备用账号重试;若备用账号无法识别该 server-side history,再由 `previous_response_not_found` strip-and-retry 恢复。 -- retry 分类优先级已集中:`UpstreamRecoveryAction` 先处理 implicit/stale history recovery,再处理账号级 retry/fallback,最后返回错误,避免 request recovery 与 account retry 在不同入口各自判断。 -- recovery transition 边界已补齐:request-level stale history 恢复和 account-level fallback acquire/account exhaustion response 已由 `dispatch/transition.rs` 统一执行;Responses non-streaming、compact、Chat、HTTP SSE streaming、WebSocket streaming 均复用同一 account recovery transition。 - -未完全对齐: -- 无。 - -缺口/后续动作: -- 无。当前 transition 已集中 request recovery 与 account fallback acquire/account exhaustion;各入口按自身协议生成最终 `Response`/SSE chunk,这是与原版 Responses passthrough、OpenAI Chat 兼容和 compact JSON 三类外壳对应的边界。 - -## 9. rate-limit、usage、quota、cookie 持久化 - -原版证据: -- `src/proxy/cookie-jar.ts:27-45` cookie 持久化格式 v2 记录 value 与 expires;自动捕获白名单只有 `cf_clearance`,关键 cookie 立即异步落盘,普通变更 1 秒 debounce。 -- `src/proxy/cookie-jar.ts:78-102` 构造 Cookie header 时跳过过期 cookie;`cleanupExpired()` 每 5 分钟清理并持久化。 -- `src/proxy/rate-limit-headers.ts:44-85` 从 `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-code-review-*`/`x-codex-review-*`/`x-code-review-*` 解析 rate-limit,并转为 normalized quota。 -- 2026-06-15 复核 `x-codex-active-limit`:原版 `src/proxy/rate-limit-headers.ts:2-18` 只在注释列出 `{prefix}-active-limit`;`ParsedRateLimit`、`parseRateLimitHeaders()`、`rateLimitToQuota()` 和全仓 `rg` 均没有解析或消费该字段。 -- `src/proxy/rate-limit-headers.ts:101-169` 解析 `codex.rate_limits` WS 内部 event,支持 review limit name 归类。 -- `src/auth/quota-utils.ts:1-105` 从 `/codex/usage` body 归一化 plan、primary/secondary/code_review/additional_rate_limits/credits。 -- `src/routes/shared/proxy-rate-limit.ts:28-49` passive rate-limit 更新 cachedQuota、同步 window_reset_at/limit_window_seconds;若 primary 已达 100 且 reset_at 在未来,会主动写入 rate-limit 侧效应。 -- `src/auth/account-registry.ts:215-266` 429 会合成或更新 cachedQuota.rate_limit,且不会缩短已知更晚 reset_at;`countRequest` 时同步增加 request_count/window_request_count。 -- `src/auth/account-registry.ts:414-480` release 时记录 request_count、token usage、cached tokens、image_generation tokens、empty response、window counters。 -- `src/auth/account-registry.ts:520-584` window 到期会重置窗口计数并推进下一窗口;cachedQuota 离线 reset 后会设置 `quotaVerifyRequired=true`。 -- `src/auth/account-registry.ts:520-545` 手动 reset usage 会清空累计和窗口计数,但保留 `window_reset_at` 与 `limit_window_seconds`,并用 `window_counters_reset_at` 记录本次 reset 时间。 -- `src/routes/shared/proxy-handler.ts:88-139` 在账号被 dirty quota 标记时,请求进入前最多做 5 次 `/usage` 校验,避免本地窗口推断把仍然限流的账号重新放回池。 - -Rust 证据: -- `src/codex/accounts/cookies/repository.rs:1-111` cookie 存在 SQLite `account_cookies`,value 加密,唯一键为 `(account_id, domain, name, path)`;自动捕获白名单只有 `cf_clearance`。 -- `src/codex/accounts/cookies/repository.rs:70-130` 构造 Cookie header 时按 domain/path 过滤并跳过过期 cookie,同名 cookie 会按更长 path 优先输出;`delete_account_cookies()` 支持按账号清理。 -- 本阶段修正后,`src/codex/gateway/transport/endpoints.rs` 集中定义 Codex upstream endpoint URL/path 规则;HTTP SSE、compact、WebSocket、dirty quota usage 和账号 usage 查询读取 Cookie 时都使用真实 upstream request path。 -- 本阶段修正后,`tests/codex_accounts/cookie_store.rs::cookie_repository_should_scope_cookie_replay_by_domain_and_path` 覆盖 repository 层 domain/path 过滤和同名 cookie 的长 path 优先;`tests/codex_serving/responses_http_sse.rs::v1_responses_should_scope_upstream_cookie_by_codex_response_path` 先以只带根路径 Cookie 失败,再修复为 `/codex/responses` 请求携带 `/codex` 与根路径 Cookie。 -- 本轮修正后,`src/codex/accounts/cookies/repository.rs` 提供 `cleanup_expired(now)`,会按现有 RFC2822/RFC3339 过期解析规则持久删除过期 cookie;`src/codex/tasks/cookie_cleanup.rs` 每 5 分钟运行一次清理,并在 `src/runtime/tasks/coordinator.rs` 中随后台任务启动。 -- `src/platform/storage/schema.sql:68-83` `account_usage` 已持久化 `window_request_count`、`window_input_tokens`、`window_output_tokens`、`window_cached_tokens`、`window_started_at`、`window_reset_at`、`limit_window_seconds`、`last_used_at`。 -- 本轮修正后,`src/platform/storage/schema.sql` 的 `accounts.quota_verify_required` 可持久化本地 quota reset 后的 dirty verification 标记,并通过 `AccountRepository::list_pool_accounts()` 恢复到运行时 `Account`。 -- `src/codex/accounts/repository/usage.rs:12-44` `record_usage()` 会累计 total/window request 和 token 计数,并设置 `last_used_at`。 -- `src/codex/accounts/repository/usage.rs:63-99` `sync_rate_limit_window()` 会在 reset_at 漂移超过窗口 50% 或默认 3600 秒时重置窗口计数,否则只同步 reset_at/limit_window_seconds。 -- 本阶段修正后,`src/codex/accounts/repository/usage.rs` 的手动 reset usage 会保留既有 `window_reset_at` 与 `limit_window_seconds`,并在存在窗口边界时把 `window_started_at` 更新为本次 reset 时间;`src/codex/accounts/pool.rs` 的运行时 reset 同步保留窗口边界。 -- `src/codex/accounts/repository/accounts.rs:329-562` list pool accounts 时会把持久化 usage/window 字段恢复到运行时 `Account`。 -- `src/codex/gateway/transport/rate_limits.rs:58-144` rate-limit header 与 `codex.rate_limits` event 解析规则基本对应原版,并在 passive quota 缺 credits 时保留已有 credits。 -- 2026-06-15 复核 `x-codex-active-limit`:Rust 全仓除本文档外没有 active-limit 代码引用;`src/codex/gateway/transport/rate_limits.rs` 与原版一致,只解析 primary、secondary 和 code_review/review header family。 -- `src/codex/serving/dispatch/limits.rs:8-67` response headers 或 WS rate-limit updates 会更新 `quota_json`、同步运行时和数据库窗口,并在 primary limit reached 时写 quota cooldown、驱逐 WS。 -- `src/codex/serving/dispatch/usage.rs:7-30` 成功 usage 会同步运行时窗口 token usage,并写入数据库累计 usage。 -- `src/codex/accounts/pool.rs:403-441` acquire 时更新运行时 `request_count`、`window_request_count`、`last_used_at`,并在已知窗口长度但无窗口开始时间时推导窗口 reset。 -- `src/codex/accounts/pool.rs:502-571` 运行时会在窗口到期时重置窗口计数、清除过期 quota cooldown,并推进下一窗口 reset。 -- 本轮修正后,`src/codex/accounts/pool.rs` 在本地解除 `quota_limit_reached` 时会设置 `quota_verify_required=true`,避免本地窗口推断后直接把账号当成干净账号使用;`acquire_should_refresh_expired_cooldowns_before_selecting_account` 覆盖该标记。 -- 本轮修正后,`CodexUpstreamService::acquire_account_for_request()` 会在 dirty 账号真正进入 `/codex/responses` 前调用 `/api/codex/usage`/`/codex/usage` 校验;若 upstream 仍返回 `limit_reached`,会清理 dirty、同步 quota/window/cooldown、释放当前 slot、排除该账号并最多重试 5 个账号;`acquire_account_for_request_should_verify_dirty_quota_and_try_next_account` 覆盖该行为。passive rate-limit headers 成功更新 quota 时也会清理 dirty 标记,`v1_responses_should_passively_cache_rate_limit_headers` 覆盖该行为。 -- 本轮修正后,`src/codex/gateway/transport/usage_events.rs` 的 SSE usage 提取按原版 Responses passthrough 语义优先使用 `response.completed.response.usage`,不会把 `response.created`/中间事件中的重复 usage 累加;若没有 completed usage,才回退到最后一份可见 usage。 -- 本阶段重新核对 image_generation usage:原版 `src/types/codex-events.ts:288-298` 和 `src/routes/responses-passthrough.ts:147-156` 从 `response.tool_usage.image_gen.input_tokens/output_tokens` 提取图片工具 token,并明确这部分与 host-model `usage.input_tokens/output_tokens` 分开统计。 -- 原版 `src/routes/shared/proxy-handler-utils.ts:12-29` 只在请求声明 `tools: [{type: "image_generation"}]` 时标记 image request attempt;`image_output_tokens > 0` 记为成功,否则记为失败。`src/auth/account-registry.ts:414-480` release 时同时累计 total 与 window 的 image token、image request success/failure。 -- 本阶段修正后,`src/codex/gateway/transport/usage_events.rs` 的 `TokenUsage` 已增加 `image_input_tokens/image_output_tokens`,`extract_usage()` 和 `extract_sse_usage()` 会从 `response.tool_usage.image_gen` 读取图片工具 token,且 `total_tokens` 仍只表示 host-model token,符合原版“图片工具单独计费”的语义。 -- 本阶段修正后,`src/platform/storage/schema.sql`、`src/codex/accounts/repository/usage.rs`、`src/codex/accounts/repository/accounts.rs` 和运行时 `Account` 已加入 total/window image token 与 image request success/failure 字段;`src/codex/serving/dispatch/usage.rs` 按请求是否声明 `image_generation` tool 和 `image_output_tokens > 0` 记录成功或失败。 -- `tests/codex_accounts/repository.rs:230-326` 覆盖 window usage 持久化与恢复。 -- 本阶段新增/扩展 `tests/codex_accounts/repository.rs::account_repository_reset_usage_should_preserve_rate_limit_window_metadata` 和 `tests/admin/accounts/cookies_quota.rs::admin_account_reset_usage_should_clear_local_counters_and_pool_last_used`,覆盖手动 reset usage 清空计数但不丢失持久层和运行时池的 rate-limit window metadata。 -- `tests/codex_serving/responses_http_sse.rs:279-350` 覆盖 passive rate-limit headers 缓存 quota、窗口字段和 cooldown。 -- `tests/codex_accounts/pool_scheduling.rs:150-466` 覆盖窗口过期重置、least_used 的 quota/window/request_count 排序和 runtime request count。 - -结论:已对齐。 - -已对齐: -- 之前审计中提到的窗口统计字段已经不再缺失:Rust schema、repository、runtime pool 和测试都覆盖了 window request/token/reset/limit_window_seconds。 -- Cookie 自动捕获白名单与原版一致,只自动保存 `cf_clearance`,不会保存 `__cf_bm`。 -- Rust 使用 SQLite + AES 加密保存 cookie,比原版 JSON 文件更适合当前数据库架构;主请求链路能注入 Cookie 并捕获 `Set-Cookie`。 -- Cookie 过期清理已对齐原版长期运行语义:读取时跳过过期 cookie,同时后台调度器每 5 分钟持久删除过期行;`cookie_repository_cleanup_expired_cookies_should_delete_only_expired_rows` 与 `cookie_cleanup_scheduler_should_delete_expired_cookie_rows` 覆盖 repository 和 scheduler 两层。 -- Cookie domain/path 精细化已确认为 Rust 新架构选择:原版 per-account map 不做浏览器式 path 过滤,Rust 在无历史负担下保留 SQLite `(account_id, domain, name, path)` 唯一键,并按真实 upstream request path 选择 Cookie。这不是回退到原版简单 map 的缺口。 -- primary/secondary/code_review rate-limit header 和 `codex.rate_limits` event 的解析与 quota 归一化主路径基本对齐,并能保留已有 credits。 -- usage input/output/cached token 的成功路径持久化、窗口统计和重启恢复已对齐到可用水平。 -- SSE usage 提取已对齐原版最终 usage 语义,不再对多个事件里重复出现的同一份 usage 做累计;`extract_sse_usage_should_use_completed_response_usage_without_merging_earlier_usage` 覆盖该行为。 -- least_used 依赖的 request_count、window_reset_at、quota_limited 字段已经进入运行时调度和数据库恢复路径。 -- account retry decision 的失败请求持久 request_count 已对齐原版 release 语义:进入 429/402/403/401/model unsupported/Cloudflare 等账号 fallback/hold 处理时会写入一次无 token 的 request attempt;`v1_responses_should_mark_quota_exhausted_after_402_and_retry_next_account` 覆盖 402 fallback 失败账号计数。 -- 非 account retry decision 的最终失败请求持久 request_count 已对齐到原版 release 语义:未进入账号 fallback/hold 的最终错误会写入一次无 token 的 request attempt;`v1_responses_should_record_request_count_when_5xx_retries_are_exhausted` 覆盖 5xx 内部重试 3 次但持久 request_count 只计 1 次。 -- transport error 的最终失败请求持久 request_count 已有覆盖;`v1_responses_should_record_request_count_when_http_transport_fails` 证明 HTTP transport 失败也会按原版 release 语义写入一次无 token 的 request attempt。 -- dirty quota verification 已对齐原版主语义:`quota_verify_required` 进入 schema、runtime `Account` 和启动恢复路径;本地解除 quota 限制时会把账号标成 dirty;请求前会用 upstream `/usage` 校验 dirty 账号,仍限流时释放并切换账号,最多 5 次,校验失败时保留 dirty 标记并继续当前账号。 -- quota window reset 已对齐原版语义:窗口自然过期、passive header reset 漂移和手动 reset usage 都会清空窗口计数;Rust 使用 `window_started_at` 表达当前窗口计数开始/重置时间,而不是再增加一个重复的 `window_counters_reset_at` 字段。手动 reset usage 现在与原版一致保留 `window_reset_at` 和 `limit_window_seconds`,不会破坏后续 least_used 窗口排序。 -- image_generation usage 已对齐原版主语义:`tool_usage.image_gen` token 与 host-model token 分开累计;请求声明 `tools: [{type: "image_generation"}]` 且 `image_output_tokens > 0` 记成功,否则记失败;total/window 字段都能持久化并恢复到运行时账号。覆盖测试包括 `extract_usage_reads_image_generation_tool_usage_separately`、`extract_sse_usage_should_read_completed_image_generation_tool_usage`、`sqlite_schema_should_persist_image_generation_usage_columns`、`account_repository_should_accumulate_image_generation_usage_counters`、`account_repository_should_restore_window_usage_into_runtime_pool_accounts`、`v1_responses_should_record_image_generation_usage_when_tool_succeeds`、`v1_responses_should_record_failed_image_generation_attempt_when_tool_has_no_output`。 -- `x-codex-active-limit` 已确认不是当前对齐缺口:原版只在注释中提及,实际没有解析、存储或调度消费;Rust 保持相同未使用状态。若未来 OpenAI/Codex upstream 开始实际依赖该字段,应作为新需求同时重新审计原版与 Rust。 - -未完全对齐: -- 无。 - -缺口/后续动作: -- 无。 - -## 10. session affinity、implicit resume、reasoning replay - -原版证据: -- `src/auth/session-affinity.ts:1-181` 维护 response_id 到 account/conversation/turnState/instructionsHash/inputTokens/functionCallIds/variantHash 的映射;TTL 4 小时,每 10 分钟清理。 -- `src/routes/shared/proxy-session-context.ts:45-121` 为每个请求构造 explicit/implicit session context:prompt cache identity、continuation input start、chain conversation id、variant hash、implicit previous response candidate、preferred account、turnState、function call 校验输入。 -- `src/routes/shared/proxy-session-helpers.ts:8-16` implicit resume 最大年龄为 55 分钟,与 WS pool max age 对齐,避免旧 `previous_response_id` 落到新 LB 后端。 -- `src/routes/shared/proxy-session-helpers.ts:34-56` prompt cache identity 优先级为显式 `prompt_cache_key`、`clientConversationId`、稳定派生 key、随机 UUID。 -- `src/routes/shared/proxy-session-helpers.ts:58-70` variant identity 由 `codexWindowId` 与稳定 anchor 组成;随后 `computeVariantHash(instructions, tools, variantIdentity)` 区分并发分支。 -- `src/routes/shared/proxy-session-helpers.ts:92-147` `evaluateImplicitResume()` 会校验 previous response 是否存在、continuation 是否非空、账号是否匹配、instructions hash 是否一致、function_call_output 是否都能对应上轮 function_call,且识别 self-contained replay。 -- `src/routes/shared/proxy-implicit-resume-request.ts:30-78` implicit resume 激活时会写入 `previous_response_id`、强制 `useWebSocket=true`、把 input 截成 continuation,并在前面插入 reasoning replay items;失败恢复时还原原请求。 -- `src/routes/shared/proxy-implicit-resume-lifecycle.ts:42-119` 管理 implicit resume 激活、跳过警告、WebSocket failure 后完整历史 replay、usage hint 和 restore。 -- 本轮复核确认:原版 `UsageHint` 由 shared implicit resume lifecycle 生成,但 OpenAI chat translator `codex-to-openai.ts` 和 Responses passthrough `responses-passthrough.ts` 不消费它;实际消费点是非 OpenAI translator 的 cache token 估算。按当前边界,非 OpenAI translator 不移植,因此 usage hint 不计入 OpenAI 链路缺口。 -- `src/proxy/reasoning-replay-cache.ts:1-247` reasoning replay cache TTL 55 分钟,限制 512 entries、单项 256 KiB、总 4 MiB;只缓存白名单 reasoning/function_call replay item,并按 entry/conversation/variant 匹配。 -- `src/routes/shared/streaming-handler.ts:74-108` streaming 成功 completed 后记录 affinity 和 reasoning replay cache;invalid encrypted content 会驱逐 replay identity。 -- `src/routes/shared/non-streaming-handler.ts:96-121` collect 成功后记录 affinity、function call ids 和 reasoning replay cache。 - -Rust 证据: -- `src/codex/serving/dispatch/affinity.rs:1-76` `AffinityEntry` 字段覆盖 account_id、conversation_id、turn_state、instructions_hash、input_tokens、function_call_ids、variant_hash、created_at。 -- `src/codex/serving/dispatch/affinity.rs:78-154` `SessionAffinityRepository` 持久化 session affinity 到 SQLite,包含 expires_at 和 function_call_ids_json。 -- `src/codex/serving/dispatch/affinity.rs:156-344` runtime `SessionAffinityMap` TTL 4 小时、清理间隔 10 分钟,支持 record/restore/lookup account/conversation/turnState/instructions/inputTokens/functionCallIds/latest/forget。 -- `src/runtime/bootstrap.rs` 与 `tests/runtime/startup.rs:197-336` 覆盖启动后从 SQLite 恢复 session affinity,并用 `previous_response_id` 路由回记录账号。 -- `src/codex/serving/dispatch/mod.rs:142-164` 显式 `previous_response_id` 会继承已记录 conversation id 到 `prompt_cache_key`,并补 turn_state;无显式 `previous_response_id` 时会先确保 prompt cache key,再尝试 implicit resume。 -- `src/codex/serving/dispatch/mod.rs:167-186` 显式 `previous_response_id` 会查 affinity preferred account,并传给 account pool acquire。 -- `src/codex/serving/dispatch/mod.rs:760-820` completed response 后记录 affinity,并持久化到 repository;记录 usage input_tokens、function_call_ids、variant_hash,并同步写入 reasoning replay cache。 -- `src/codex/gateway/conversation_identity.rs:53-90` 稳定 prompt cache key 派生规则与原版 stable key 对齐。 -- `src/codex/serving/dispatch/affinity.rs` 的 `compute_variant_hash()` 已改为原版基础形态:`instructions + "\0" + JSON.stringify(tools) + optional identity` 后取 SHA-256 前 12 位。 -- `src/codex/serving/dispatch/implicit_resume.rs` 实现 continuation input start、function_call_output 校验、missing/unanswered 检查和 self-contained replay 跳过。 -- `src/codex/serving/dispatch/reasoning_replay.rs` 实现 reasoning replay cache:TTL 55 分钟、512 entries、单项 256 KiB、总量 4 MiB,按 account/conversation/variant 匹配。 -- `src/codex/serving/dispatch/stream.rs` 会从 completed SSE 中收集 replay items;streaming 和 non-streaming completed 都通过同一个 affinity 记录函数写入 cache。 -- `src/codex/serving/dispatch/implicit_resume.rs` 提供 request snapshot/restore;non-streaming 与 streaming request recovery 在隐式续链 previous-response 错误后会先恢复 snapshot,再清除 `previous_response_id`/`turn_state` 并同账号重试。 -- `tests/codex_serving/responses_websocket.rs:476-590` 覆盖显式 previous_response_id 路由到记录账号、继承 previous_response_id、持久化 function_call_ids/input_tokens/expires_at。 -- 本轮新增 `tests/fixtures/responses/websocket/completed_with_reasoning_replay.json` 和 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_implicitly_resume_full_history_continuation_with_reasoning_replay`,覆盖 completed response 记录 replay item 后,下一轮 full-history continuation 自动变成 implicit resume request。 -- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing`,覆盖隐式续链失败后的 full-history restore/replay。 -- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_not_implicitly_resume_unmatched_function_call_output`,覆盖 continuation 中的 `function_call_output.call_id` 与已记录 function call 不匹配时不会错误启用 implicit resume。 -- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_not_implicitly_resume_self_contained_function_call_replay`,覆盖 full history 已自包含 `function_call` 与 `function_call_output` 时不会错误启用 implicit resume。 -- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_implicitly_resume_after_sqlite_affinity_restore`,覆盖真实请求写入 SQLite session affinity 后,重建 AppState、恢复账号池和 affinity,再发 full-history continuation 仍会设置 `previous_response_id` 并截取 continuation input。 - -结论:已对齐。 - -已对齐: -- 显式 `previous_response_id` 的核心 affinity 已对齐:response_id 能映射回账号、conversation id、turn_state,后续请求可路由到同一账号并继续 WebSocket 链。 -- Rust 增加了 SQLite 持久化与启动恢复,这是对当前无历史负担架构更优雅的增强;原版 session affinity 本身是内存单例。 -- TTL 4 小时、清理 10 分钟、function_call_ids、instructions hash、input_tokens、variant_hash 字段都已经出现在 Rust 数据模型中。 -- prompt cache stable key 派生规则本身与原版对齐。 -- stale affinity recovery 已接入:上游 `previous_response_not_found` 或 unanswered function call 时会忘记 stale affinity,清除 `previous_response_id`/`turn_state`,同账号重试一次。 -- implicit resume 主路径已接入:无显式 `previous_response_id`、存在 continuation input、conversation/variant 命中、instructions hash 一致且 function call continuation 合法时,Rust 会自动设置 `previous_response_id`、强制 WebSocket、截取 continuation input 并注入 turn_state。 -- implicit resume failure restore 已接入:隐式续链遇到 previous-response 错误时,会恢复原始 full-history input,清除 `previous_response_id`/`turn_state` 并同账号重试。 -- reasoning replay cache 主路径已接入:completed response 的 `reasoning`/`function_call` replay items 会按原版 TTL/容量/大小约束缓存,并在 implicit resume 时插入 continuation input 前部。 -- function call continuation 预检已接入:missing/unanswered/self-contained replay 会阻止错误的 implicit resume。 -- variant_hash 已不再序列化整个 request,基础算法已对齐为 instructions/tools/optional identity 的 12 位 hash。 -- variant identity 已接入:Rust 会在 session preparation 阶段、implicit resume 改写 input 前按原版语义预先保存 identity;`codexWindowId` 会进入 `window:{id}`,显式 `prompt_cache_key` 且存在 stable anchor 时会进入 `anchor:{derivedConversationId}`,后续 implicit resume lookup、affinity record 和 reasoning replay 使用同一个 variant hash。 -- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_not_implicitly_resume_across_codex_windows`,覆盖同一显式 `prompt_cache_key` 下不同 `codexWindowId` 不会串用 `previous_response_id`;新增 affinity 单元测试覆盖 window identity 和显式 prompt cache anchor。 -- invalid encrypted content 驱逐已接入:Rust 会识别 `error`/`response.failed` 中的 `invalid_encrypted_content` 或等价的 invalid/encrypted/content 文本,并按 `account_id + conversation_id + variant_hash` 驱逐 reasoning replay cache;non-streaming、HTTP SSE streaming audit、WebSocket streaming audit 都使用同一驱逐方法。 -- 本轮新增 `tests/fixtures/responses/websocket/invalid_encrypted_content.json` 与 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_evict_reasoning_replay_after_invalid_encrypted_content`,覆盖 replay item 被上游判无效后,后续 implicit resume 不再注入同一个 encrypted reasoning item。 -- missing/unmatched function call output、self-contained function call replay、SQLite restore 后继续 implicit resume 的端到端测试已补齐,覆盖了之前仅有实现但缺少端到端证明的边界。 - -已对齐: -- OpenAI chat `clientConversationId` 已接入。原版 `/v1/chat/completions` 会把 OpenAI `user` 字段作为 shared handler 的 `clientConversationId`;Rust 现在将 `ChatCompletionRequest.user` 写入 Codex 请求的内部 `client_conversation_id` 和 `prompt_cache_key`,进入上游前仍按账号作用域转换为 `cp_...` session id。 -- 本轮新增 `tests/codex_serving/chat_completions.rs::chat_completions_should_use_user_as_client_conversation_id`,覆盖 OpenAI chat `user` 进入上游 `prompt_cache_key`。 - -未完全对齐: -- 无。 - -非目标: -- usage hint 不移植。它在原版只影响非 OpenAI cache token 展示/估算,不影响 OpenAI chat translator 或 Responses passthrough;Rust 只保留 OpenAI 链路时不需要该兼容层。 - -缺口/后续动作: -- 无。 diff --git a/docs/OPENAI_CODEX_PARITY_AUDIT.md b/docs/OPENAI_CODEX_PARITY_AUDIT.md deleted file mode 100644 index 280538ad1..000000000 --- a/docs/OPENAI_CODEX_PARITY_AUDIT.md +++ /dev/null @@ -1,119 +0,0 @@ -# OpenAI Codex 原版链路对齐审计 - -日期:2026-06-19 - -范围:对比原版 Node.js 仓库 `/home/zyy/桌面/Codes/codex-proxy`,审计 Rust 版的安全链路、请求链路、响应链路。这里的“代理”不包含 IP 代理、VPN、`HttpsProxyAgent`、本地代理探测等网络代理能力,这部分明确不移植。 - -## 结论 - -当前 Rust 版已补齐关键安全模拟字段、默认 WebSocket 上游请求、稳定 `prompt_cache_key`、`previous_response_id` 账号亲和性、WS 首帧错误分类、WS 实时边收边转发、账号 + conversation 维度的 WS 连接复用、implicit resume、strip-and-retry、reasoning replay。IP 代理/VPN 能力明确不移植。 - -本轮已补齐请求发送细节和账号生命周期差异:自动 Cookie 捕获白名单、`Max-Age` 优先级、账号刷新/管理状态变化后的 WS pool 驱逐、`request_interval_ms` 发送前 stagger、`least_used` 的 reset 缺失排序。当前没有保留的 OpenAI/Codex 请求链路迁移阻塞项;IP 代理/VPN 仍是明确非目标。 - -## 1. 安全链路和指纹 - -原版证据: -- `src/fingerprint/manager.ts` 构造 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`、`sec-ch-ua`、默认浏览器头和 header order。 -- `src/proxy/codex-api.ts` 在 WS/HTTP 两条路径都追加 `OpenAI-Beta`、`x-openai-internal-codex-residency`、`x-client-request-id`、`x-codex-installation-id`、`session_id`、`x-codex-window-id`、turn metadata、parent thread、`x-openai-subagent`。 -- `buildCodexClientMetadata()` 会把 installation/window/turn/parent 写入 body 的 `client_metadata`,不是只发 header。 - -Rust 状态: -- 到位:`src/codex/gateway/transport/headers.rs` 构造账号、residency、request id、UA、sec-ch、默认 header 和顺序。 -- 到位:`src/codex/gateway/transport/http_client.rs` 将 installation/window/turn/parent/subagent 同步到 header 和 `client_metadata`。 -- 到位:`src/codex/gateway/installation.rs` 兼容读取 `~/.codex/installation_id`,否则落库生成。 -- 到位:`src/runtime/bootstrap.rs` 从数据库加载自动更新指纹,避免实际请求继续使用硬编码默认值。 -- 边界:Rust 使用 `reqwest + rustls` 传输,未声称复制原版 native transport 的完整 Chrome TLS 指纹。IP 代理/VPN 相关能力不属于移植范围。 - -## 2. 请求链路 - -原版证据: -- `src/routes/responses.ts` 默认 `codexRequest.useWebSocket = true`。 -- `stable-conversation-key.ts` 用 model、instructions、首条 user 文本派生稳定 conversation key。 -- `proxy-session-context.ts` / `proxy-request-preparation.ts` 确保 `prompt_cache_key` 总是写入上游请求。 -- `session-affinity.ts` 记录 `response_id -> entryId/conversationId/turnState`,后续 `previous_response_id` 优先回到原账号。 - -Rust 状态: -- 到位:普通 `/v1/responses` 默认走 WebSocket,上游失败且是传输/不支持类错误才降级 HTTP SSE。 -- 到位:`src/codex/gateway/identity.rs` 已增加稳定 conversation key 派生,空请求使用 UUID 防止所有空请求共享同一缓存链。 -- 到位:请求发上游前会确保 `prompt_cache_key`,再按账号作用域派生成 `cp_*`,并同步到 `session_id` / `x-client-request-id`。 -- 到位:`src/codex/serving/dispatch/affinity.rs` 已接入 runtime;成功 response 会记录 affinity,显式 `previous_response_id` 会优先选择原账号并继承原 conversation identity。 - -## 3. 响应链路 - -原版证据: -- `ws-transport.ts` 打开 WS 后返回 `ReadableStream`,把每个 JSON frame 包装成 SSE 事件。 -- 首帧 `error` / `response.failed` 如属于可轮换账号错误,会转成 `CodexApiError` 状态码,让统一重试/换账号逻辑接管。 -- 未知错误码继续作为 SSE 透传,避免误轮换。 - -Rust 状态: -- 到位:WS frame 会转成 SSE 文本,客户端仍看到 `text/event-stream`。 -- 到位:WS 握手状态码、`retry-after`、cookie、rate-limit header 会被捕获。 -- 到位:WS 首帧 `usage_limit_reached`、`rate_limit_exceeded`、`quota_exhausted`、`token_expired`、`banned`、`previous_response_not_found` 等会映射成对应 HTTP 状态,不走 HTTP SSE fallback。 -- 到位:WS streaming path 在首个非错误 frame 到达后立即返回客户端响应,后续 frame 边收边转成 SSE;usage、affinity、event log 在 body stream 收尾阶段记录。 - -## 4. WebSocket 连接池 - -原版证据: -- `ws-pool.ts` 说明 upstream LB 以物理 WS 连接做后端粘滞,连接复用能保持 prompt cache。 -- 默认 `maxAgeMs = 3_300_000`,即 55 分钟。 -- key 以账号和 conversation 维度复用,一条 WS 严格单 in-flight,busy/cap/dead 时旁路 one-shot。 - -Rust 状态: -- 到位:`src/codex/gateway/transport/websocket.rs` 增加进程内 `CodexWebSocketPool`,默认 55 分钟过期,默认每 25 秒维护空闲连接。 -- 到位:池 key 由 base URL、本地账号和派生后的 conversation/prompt cache key 组成;同 key busy、账号连接数达到上限或 pool disabled/shutdown 时旁路 one-shot,避免排队和死锁。 -- 到位:复用连接如果在首帧前发现已被对端关闭,会丢弃后用 fresh one-shot WS 重试一次;mid-stream 提前关闭会向客户端 stream 报错,不做重试。 -- 到位:空闲连接支持 keepalive ping/pong 和 liveness timeout,周期 sweep 会关闭过期、无响应或被 shutdown/evict 的连接;`websocket_connection_limit_reached` 会按连接级 fatal 错误剔除。 -- 到位:被动 rate-limit、上游 fallback 状态变更、refresh 成功/失败、管理端 disable/delete/batch lifecycle 都会驱逐该账号的 WS;`AccountService` 和 `CodexUpstreamService` 使用同一个应用级共享 pool。 - -## 5. Cookie 捕获语义 - -原版证据: -- `src/proxy/cookie-jar.ts` 的 `CAPTURABLE_COOKIE_NAMES` 只包含 `cf_clearance`。 -- 自动捕获 `Set-Cookie` 会跳过 `__cf_bm`;管理端手动 `set()` 不受白名单限制。 -- `Max-Age` 会优先于 `Expires`,`Max-Age<=0` 代表立即过期。 - -Rust 状态: -- 到位:`CookieRepository::capture_set_cookie()` 自动捕获只允许 `cf_clearance`。 -- 到位:`parse_set_cookie()` 支持 `Max-Age`,并让它优先于 `Expires`。 -- 到位:手动 `set_cookie_header()` 可以持久化任意 Cookie,这一点已保留。 - -## 6. 请求发送节流 - -原版证据: -- `routes/shared/proxy-handler.ts` 获取账号后调用 `staggerIfNeeded(acquired.prevSlotMs)`。 -- fallback 到备用账号后同样会在发送上游请求前 stagger。 - -Rust 状态: -- 到位:`AccountPool::acquire_with()` 返回 `previous_slot_at`。 -- 到位:`CodexUpstreamService::acquire_account*()` 保留完整 `AcquiredAccount`,普通请求、stream 请求和 fallback 账号请求在发送上游前按 `auth.request_interval_ms` sleep。 - -## 7. 账号调度排序 - -原版证据: -- `least_used` 的优先级是 quota-exhausted 降权、两侧都有 `window_reset_at` 时比较 reset、`request_count`、LRU。 - -Rust 状态: -- 到位:quota 降权、两侧都有 `window_reset_at` 时比较 reset、`request_count` 和 LRU 已实现。 -- 到位:任一侧缺少 reset 时继续比较 `request_count`,不再把 `Some(window_reset_at)` 永远排在 `None` 前面。 - -## 8. Responses 续接状态机 - -原版证据: -- shared handler 包含 implicit resume、strip-and-retry、reasoning replay 的状态机。 - -Rust 状态: -- 到位:显式 `previous_response_id` 会强制 WebSocket 并优先回到原账号。 -- 到位:implicit resume 会基于已记录 affinity/replay 条目续接同账号、同 conversation、同变体历史。 -- 到位:strip-and-retry 覆盖 `previous_response_not_found`、unanswered function call、invalid encrypted reasoning replay,并会在需要时恢复完整历史。 -- 到位:reasoning replay 缓存、记录、驱逐和 SQLite affinity restore 后续接均由 crate-local tests 覆盖。 - -## 9. 非目标:IP 代理/VPN - -原版包含本地代理探测、`proxy_url`、`HttpsProxyAgent`、账号代理池等能力。Rust 版不移植这些 IP 代理/VPN 功能,也不新增 `proxy_url` 配置。安全链路只关注 Codex/OpenAI 请求自身的身份、指纹、Cookie、header、body metadata 和 WS 语义。 - -## 当前验证点 - -- `crates/adapters/tests/codex.rs` 覆盖默认 WS、opening/header/payload 捕获、WS 安全字段、握手错误、首帧错误分类、deflate、ping、pool、idle timeout、rate-limit/metadata 捕获。 -- `crates/server/tests/openai_chat_upstream.rs` 覆盖默认 WS 上游、派生 `prompt_cache_key`、`client_metadata` 安全字段、`previous_response_id` 原账号亲和性、WS 首帧实时返回、同 conversation 连接复用、限流/刷新路径、implicit resume、strip-and-retry、reasoning replay。 -- `crates/core/tests/protocol.rs` 覆盖 WebSocket 纯协议转换、错误分类、Responses payload 顺序、事件形状校验和 replay 相关元数据抽取。 -- `crates/server/tests/openai_diagnostics_routes.rs` 覆盖 `/debug/diagnostics`、`/debug/fingerprint`、`/debug/upstream` 本地诊断和 secret redaction。 diff --git a/docs/OPENAI_REQUEST_ALIGNMENT.md b/docs/OPENAI_REQUEST_ALIGNMENT.md deleted file mode 100644 index fe862ecb6..000000000 --- a/docs/OPENAI_REQUEST_ALIGNMENT.md +++ /dev/null @@ -1,254 +0,0 @@ -# OpenAI 请求链路一致性审查报告 - -本文档记录了 `codex-proxy-rs` (Rust) 与 `codex-proxy` (Node.js) 在 OpenAI 请求链路上的对比审查和修复工作。 - -## 审查日期 -2026-06-19 - -## 当前结论 - -Rust 版已经补齐 OpenAI/Codex 主请求链路的核心路径:默认 WebSocket 上游、`previous_response_id` 账号亲和性、稳定 `prompt_cache_key`、关键安全 header、`client_metadata`、WS frame 到 SSE 的响应转换、usage/rate-limit 记录、HTTP SSE fallback、implicit resume、strip-and-retry、reasoning replay。 - -本轮改造后,直接影响 OpenAI 交互语义和请求发送行为的差异已补齐:自动 Cookie 捕获白名单、`Max-Age` 优先级、账号生命周期后的 WS pool 驱逐、`request_interval_ms` 发送前 stagger、`least_used` reset 缺失排序。 - -IP 代理/VPN、本地代理探测和 `HttpsProxyAgent` 不属于 Rust 版迁移目标。除此之外,当前审计没有保留的 OpenAI/Codex 请求链路迁移阻塞项。 - -## 本轮已对齐项 - -### 自动捕获 Cookie 策略 - -原版只允许自动捕获 `cf_clearance`,避免重放 `__cf_bm` 这类与 IP、UA、TLS 指纹和时序强绑定的 Bot Management session cookie。Rust 此前会自动持久化所有 `Set-Cookie`,包括 `__cf_bm`。 - -已完成: -- 自动 `Set-Cookie` 捕获只允许 `cf_clearance` -- 管理端手动注入 Cookie 不受白名单限制 -- `Max-Age` 优先级高于 `Expires` - -### WebSocket Pool 生命周期驱逐 - -原版在账号 refreshed、banned、disabled、rate-limited 等状态变化后会按账号驱逐 WS pool,避免旧 token、旧风控状态或旧后端粘滞连接继续复用。Rust 此前只有被动 rate-limit 和部分 fallback 状态会驱逐,账号刷新、管理端禁用/删除、批量状态更新等生命周期路径没有统一驱逐同一个共享 pool。 - -已完成: -- `AccountService` 和 `CodexUpstreamService` 共享同一个 `CodexWebSocketPool` -- refresh 成功后驱逐旧连接 -- refresh 失败导致 Expired/QuotaExhausted/Banned/Disabled 后驱逐 -- 管理端 disable/delete/batch lifecycle 后驱逐 - -### 请求间隔发送节流 - -原版在账号池返回 `prevSlotMs` 后,发送上游请求前会按 `request_interval_ms` 做 stagger。Rust 的 `AccountPool` 已返回 `previous_slot_at`,但此前服务层丢弃了该字段,实际发送上游请求前没有等待。 - -已完成: -- acquire 返回值保留 `previous_slot_at` -- 普通请求、stream 请求、fallback 账号请求在发 OpenAI/Codex 上游前统一执行 stagger -- 只延迟同账号连续请求,不影响账号选择 - -### `least_used` 中 `window_reset_at` 的比较语义 - -原版只有当两个账号都有 `window_reset_at` 且值不同时才比较 reset 时间;否则继续比较 `request_count` 和 LRU。Rust 此前把 `Some(window_reset_at)` 永远排在 `None` 前面,会让缺少 reset 数据但请求更少的账号被不合理降权。 - -已完成: -- `(Some(a), Some(b))` 且不相等时比较 reset -- 任一侧缺失 reset 时继续比较 `request_count` - -## 历史未对齐项(已关闭) - -### Responses 隐式续接和 reasoning replay 状态机 - -原版 shared handler 包含 implicit resume、strip-and-retry、reasoning replay 等更细的 Responses 状态机。这个缺口已经在当前 Rust workspace 中关闭: - -- `crates/core/src/serving/implicit_resume.rs` 保存纯策略; -- `crates/core/src/serving/reasoning_replay.rs` 保存 replay 缓存策略; -- `crates/runtime/src/services.rs` 在 Responses 非流式、HTTP SSE 流式、WebSocket 路径中接入恢复、驱逐和历史还原; -- `crates/server/tests/openai_chat_upstream.rs` 覆盖 WebSocket implicit resume、SQLite 恢复后续接、跨 window 拒绝、自包含 function-call replay 拒绝、未匹配 function-call 输出拒绝、invalid encrypted reasoning replay 后驱逐、previous_response_not_found / unanswered_function_call strip-and-retry、SSE/WebSocket invalid reasoning replay strip-and-retry。 - -## 已修复的问题 - -#### 1. 缺失的关键 HTTP Headers - -**问题:** Rust 项目缺少 Node.js 项目中的关键请求头 -- `x-codex-installation-id` - 设备唯一标识 -- `session_id` - 会话 ID(用于会话关联和提示缓存) - -**修复:** -- 新增 `src/codex/gateway/installation.rs` 模块 - - 实现 Installation ID 管理逻辑 - - 优先读取 `~/.codex/installation_id`(兼容真实 Codex Desktop) - - 次优从 `/installation_id` 读取或生成新 UUID - - 使用 `OnceLock` 实现单例缓存 - -- 新增 `src/codex/gateway/identity.rs` 模块 - - 实现 `build_conversation_identity()` 函数 - - 从 `prompt_cache_key` 派生账号作用域的会话 ID - - 使用 SHA256 哈希生成确定性标识符 - - 格式:`cp_{hash[..32]}` (conversation) 或 `cw_{hash[..32]}` (window) - -- 更新 `src/codex/gateway/transport/http_client.rs` - - 在 `CodexRequestContext` 添加 `installation_id` 和 `session_id` 字段 - - 在 `request_headers()` 方法中添加这两个 header - -- 更新 `src/codex/serving/dispatch/mod.rs` - - 在发送请求前构建 conversation identity - - 获取 installation ID 并传递到请求上下文 - - 自动将派生的 `conversation_id` 设置为 `session_id` header - -#### 2. 会话亲和性 (Session Affinity) - -**Node.js 实现:** -```typescript -const identity = this.buildConversationIdentity(request); -if (identity.conversationId) { - headers["session_id"] = identity.conversationId; - headers["x-client-request-id"] = identity.conversationId; -} -``` - -**Rust 实现(已修复):** -```rust -let identity = build_conversation_identity( - request.prompt_cache_key.as_deref(), - request.codex_window_id.as_deref(), - account_scope, -); -// ... -session_id: identity.conversation_id.as_deref(), -``` - -## 对比矩阵 - -| 功能 | Node.js | Rust (修复后) | 状态 | -|------|---------|---------------|------| -| `x-codex-installation-id` | ✓ | ✓ | ✅ 一致 | -| `session_id` header | ✓ | ✓ | ✅ 一致 | -| Conversation identity 派生 | ✓ | ✓ | ✅ 一致 | -| SHA256 哈希算法 | ✓ | ✓ | ✅ 一致 | -| 账号作用域绑定 | ✓ | ✓ | ✅ 一致 | -| Window ID fallback | ✓ | ✓ | ✅ 一致 | -| Installation ID 持久化 | ✓ | ✓ | ✅ 一致 | -| `~/.codex/installation_id` 兼容 | ✓ | ✓ | ✅ 一致 | - -## 已验证的一致性 - -### 请求头对比 - -**Node.js (`codex-api.ts:320-392`):** -```typescript -headers["x-codex-installation-id"] = installationId; -headers["session_id"] = identity.conversationId; -headers["x-codex-window-id"] = identity.windowId; -headers["OpenAI-Beta"] = "responses_websockets=2026-02-06"; -headers["x-openai-internal-codex-residency"] = "us"; -``` - -**Rust (`transport/client.rs:296-337`):** -```rust -insert_optional_header(&mut headers, "x-codex-installation-id", context.installation_id)?; -insert_optional_header(&mut headers, "session_id", context.session_id)?; -insert_optional_header(&mut headers, "x-codex-window-id", context.codex_window_id)?; -headers.insert(HeaderName::from_static("openai-beta"), - HeaderValue::from_static("responses_websockets=2026-02-06")); -headers.insert(HeaderName::from_static("x-openai-internal-codex-residency"), - HeaderValue::from_static("us")); -``` - -### 身份派生逻辑 - -**算法一致性:** -``` -SHA256(kind + "\0" + account_scope + "\0" + client_value)[..32] -``` - -**前缀映射:** -- `"conversation"` → `"cp_"` -- `"window"` → `"cw_"` - -**Fallback 行为:** -- 如果没有显式 `window_id`,则派生为 `{conversation_id}:0` - -## 测试验证 - -所有新增测试通过: -``` -test codex::gateway::identity::tests::test_build_account_scoped_identity_deterministic ... ok -test codex::gateway::identity::tests::test_build_account_scoped_identity_different_accounts ... ok -test codex::gateway::identity::tests::test_build_account_scoped_identity_window_prefix ... ok -test codex::gateway::identity::tests::test_build_conversation_identity_empty ... ok -test codex::gateway::identity::tests::test_build_conversation_identity_fallback_window ... ok -test codex::gateway::identity::tests::test_build_conversation_identity_full ... ok -test codex::gateway::installation::tests::test_read_from_codex_home ... ok -test codex::gateway::installation::tests::test_generate_and_persist ... ok -``` - -## 文件变更清单 - -### 新增文件 -1. `src/codex/gateway/installation.rs` - Installation ID 管理 -2. `src/codex/gateway/identity.rs` - Conversation identity 派生 - -### 修改文件 -1. `src/codex/gateway/mod.rs` - 导出新模块 -2. `src/codex/gateway/transport/http_client.rs` - 添加新 header 字段 -3. `src/codex/serving/dispatch/mod.rs` - 集成身份派生逻辑 -4. `src/codex/accounts/service/health.rs` - 更新 context 构造 -5. `src/codex/models/service.rs` - 更新 context 构造 -6. `src/codex/serving/diagnostics.rs` - 更新 context 构造 -7. `Cargo.toml` - 添加 `dirs = "5.0.1"` 依赖 - -## 其他保持一致的部分 - -以下功能在审查前已经与 Node.js 项目一致: - -### 1. 协议转换 (OpenAI ↔ Codex) -- ✅ System/developer messages → `instructions` -- ✅ User/assistant/tool messages → `input` array -- ✅ Tool calls → `function_call` items -- ✅ Reasoning effort 解析和映射 -- ✅ Service tier 归一化 -- ✅ Structured output (`json_schema`) - -### 2. SSE 流处理 -- ✅ Codex SSE events → OpenAI chat completion chunks -- ✅ `response.output_text.delta` → content delta -- ✅ `response.function_call_arguments.delta` → tool_calls delta -- ✅ `response.completed` → finish_reason + usage + `[DONE]` - -### 3. 错误处理与重试 -- ✅ 429 → 账号冷却 + 备用账号 -- ✅ 401 → OAuth 刷新 + 重试 -- ✅ 403 Cloudflare → 删除 cookies + 冷却 -- ✅ 402 → 标记配额耗尽 + 备用账号 - -### 4. Cookie 管理 -- ✅ 自动捕获 `Set-Cookie` 时只持久化 `cf_clearance` -- ✅ `Max-Age` 优先于 `Expires` -- ✅ 持久化到数据库 -- ✅ 构建 `Cookie` header - -### 5. WebSocket 支持 -- ✅ `previous_response_id` 时自动使用 WebSocket -- ✅ 相同的 header 传递逻辑 - -## 修复进度 - -已完成: -1. ✅ 添加 `x-codex-installation-id` header -2. ✅ 添加 `session_id` header -3. ✅ 实现 conversation identity 派生逻辑 -4. ✅ 实现 installation ID 持久化 -5. ✅ 所有测试通过 -6. ✅ 编译无错误 -7. ✅ implicit resume / strip-and-retry / reasoning replay 状态机已迁移并由 crate-local 测试覆盖 - -## 下一步建议 - -1. **集成测试**:使用真实账号进行端到端测试,验证会话关联是否正常工作 -2. **监控**:观察 `session_id` 和 `installation_id` 在日志中的出现,确保正确传递 -3. **性能验证**:确认 prompt cache 命中率是否提升(session affinity 的主要效果) -4. **兼容性测试**:验证与真实 Codex Desktop 的 `~/.codex/installation_id` 共享是否正常 - -## 参考 - -- Node.js 实现:`/home/zyy/桌面/Codes/codex-proxy/src/proxy/codex-api.ts` -- Rust 实现:`/home/zyy/Codes/codex-proxy-rs/crates/{core,adapters,runtime,server}/` -- Installation ID 规范:Node.js `src/proxy/installation-id.ts` -- Identity 派生规范:Node.js `buildAccountScopedIdentity()` 方法 diff --git a/docs/RELIABILITY_ENHANCEMENTS.md b/docs/RELIABILITY_ENHANCEMENTS.md deleted file mode 100644 index 039cc99d7..000000000 --- a/docs/RELIABILITY_ENHANCEMENTS.md +++ /dev/null @@ -1,258 +0,0 @@ -# 响应链路可靠性增强 - 完成报告 - -## 完成日期 -2026-06-13 - -## 状态 -✅ **所有增强功能已实现并通过验证** - ---- - -## 实现的功能 - -### 1. ✅ 空响应检测与重试机制 - -**功能描述:** -检测无输出且 `output_tokens = 0` 的空响应,自动重试最多 2 次,提升偶发空响应的容错能力。 - -**实现位置:** -- `src/codex/serving/dispatch/stream.rs` - - 新增 `CollectedResponse::Empty` 枚举变体 - - 新增 `is_empty_response()` 检测函数 - -- `src/codex/serving/responses.rs` - - 添加重试循环逻辑 - - `MAX_EMPTY_RETRIES = 2` - - 详细日志记录 - -**检测逻辑:** -```rust -fn is_empty_response(response: &Value, output_text: &str, output_items: &[Value]) -> bool { - if !output_text.trim().is_empty() { - return false; - } - if !output_items.is_empty() { - return false; - } - let output_tokens = response - .pointer("/usage/output_tokens") - .and_then(Value::as_u64) - .unwrap_or(0); - output_tokens == 0 -} -``` - -**重试流程:** -1. 检测到空响应 → 记录警告日志 -2. 重试计数 +1,继续循环 -3. 超过 2 次重试 → 返回 502 错误 -4. 日志包含 `emptyResponse: true` 和 `retryAttempt` - ---- - -### 2. ✅ SSE Heartbeat 机制 - -**功能描述:** -在流式响应中每 15 秒发送一次心跳包 (`: ping\n\n`),防止长时间推理导致代理/隧道超时断连。 - -**实现位置:** -- `src/codex/serving/dispatch/mod.rs` - `responses_stream()` 函数 - -**实现方式:** -```rust -use tokio::time::{interval, Duration}; -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); -const HEARTBEAT_CHUNK: &[u8] = b": ping\n\n"; - -tokio::select! { - chunk_result = upstream.next() => { - // 处理实际数据 - } - _ = heartbeat_timer.tick() => { - // 发送心跳包 - Some((Ok(HEARTBEAT_CHUNK.into()), ...)) - } -} -``` - -**特性:** -- 使用 `tokio::select!` 并发监听数据和心跳定时器 -- 不影响实际数据传输 -- 符合 SSE 规范(注释行格式) - ---- - -## 验证结果 - -### ✅ 编译验证 -```bash -cargo build -# Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.94s -``` - -### ✅ 单元测试 -``` -running 20 tests -test result: ok. 20 passed; 0 failed -``` - -### ✅ 集成测试 -``` -running 49 tests -test result: ok. 49 passed; 0 failed -``` - -### ✅ Clippy 检查 -```bash -cargo clippy --all-targets --all-features -- -D warnings -# Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.54s -``` - -**修复的 Clippy 警告:** -- `manual implementation of Option::map` - 优化为 `.map()` 链式调用 - ---- - -## 代码质量 - -### Rust 最佳实践符合性 - -**✅ 借用与所有权:** -- 函数参数使用 `&str`, `&Value` 引用 -- 避免不必要的 `clone()` - -**✅ 错误处理:** -- 使用 `Result` -- 避免 `unwrap()` / `expect()` - -**✅ 性能:** -- 使用 `tokio::select!` 实现零成本并发 -- 避免中间 `.collect()` 调用 -- 心跳包使用静态字节切片 - -**✅ 代码可读性:** -- 常量使用大写 `SNAKE_CASE` -- 关键逻辑有中文注释 -- 函数职责单一 - ---- - -## 与 Node.js 参考实现对比 - -| 功能 | Node.js | Rust | 状态 | -|------|---------|------|------| -| **空响应重试** | ✓ (MAX_EMPTY_RETRIES = 2) | ✓ | ✅ 一致 | -| **Heartbeat 间隔** | 15 秒 | 15 秒 | ✅ 一致 | -| **Heartbeat 格式** | `: ping\n\n` | `: ping\n\n` | ✅ 一致 | -| **重试日志** | ✓ | ✓ | ✅ 一致 | - ---- - -## 影响分析 - -### 性能影响 -- **空响应重试:** 仅在空响应时触发,正常流程零开销 -- **Heartbeat:** 使用 `tokio::select!`,零成本并发,无性能损失 - -### 可靠性提升 -- ✅ 降低偶发空响应导致的请求失败率 -- ✅ 防止长推理(>30秒)时连接超时 -- ✅ 提升整体服务稳定性 - -### 兼容性 -- ✅ Heartbeat 符合 SSE 规范,客户端会自动忽略注释行 -- ✅ 重试机制对客户端透明 -- ✅ 无破坏性变更 - ---- - -## 测试建议 - -### 功能测试 -1. **空响应重试:** - - 模拟上游返回空响应(output_tokens = 0) - - 验证自动重试 2 次 - - 验证日志包含 `emptyResponse: true` - -2. **Heartbeat:** - - 请求长时间推理(>30秒) - - 使用 Wireshark/tcpdump 捕获数据包 - - 验证每 15 秒发送 `: ping\n\n` - -### 压力测试 -- 并发 100+ 长时间流式请求 -- 验证 Heartbeat 不影响吞吐量 -- 验证内存稳定 - ---- - -## 文件变更清单 - -### 修改文件 (3) -1. `src/codex/serving/dispatch/stream.rs` - - `+1` 枚举变体 `CollectedResponse::Empty` - - `+15` 行 `is_empty_response()` 函数 - - `+5` 行空响应检测逻辑 - -2. `src/codex/serving/responses.rs` - - `+2` 行常量声明 - - `+30` 行空响应重试循环 - - `+20` 行 Empty 响应处理 - -3. `src/codex/serving/dispatch/mod.rs` - - `+15` 行 Heartbeat 实现 - - 重构流式响应为 `tokio::select!` 模式 - -### 修复的 Clippy 警告 (1) -- `src/codex/gateway/identity.rs` - 简化为 `.map()` 链式调用 - ---- - -## 后续优化建议 - -### 可选增强(低优先级) -1. **可配置的重试次数** - - 当前硬编码为 2 次 - - 可考虑从配置文件读取 - -2. **可配置的 Heartbeat 间隔** - - 当前硬编码为 15 秒 - - 不同网络环境可能需要不同间隔 - -3. **空响应统计** - - 记录空响应发生频率 - - 用于监控和告警 - -### 已确认无需实现 -- ❌ `/v1/chat/completions` 流式 Heartbeat - - 原因:该端点返回预处理的完整 SSE 字符串,不是真正的流 - - 不会出现长时间等待 - ---- - -## 总结 - -✅ **响应链路可靠性已达到 Node.js 参考实现水平** - -两个关键增强功能均已实现并通过验证: -- ✅ 空响应自动重试(容错能力提升) -- ✅ SSE Heartbeat(防止长推理超时) - -代码质量: -- ✅ 通过所有单元测试和集成测试 -- ✅ Clippy 检查无警告 -- ✅ 符合 Rust 最佳实践 - -性能: -- ✅ 零成本并发实现 -- ✅ 无额外内存开销 -- ✅ 不影响正常请求吞吐量 - -准备好投入生产环境使用。🚀 - ---- - -## 参考文档 -- Node.js 实现: `/home/zyy/桌面/Codes/codex-proxy/src/routes/shared/response-processor.ts` -- Rust 实现: `/home/zyy/Codes/codex-proxy-rs/src/codex/serving/dispatch/` -- 响应链路对比: `docs/RESPONSE_CHAIN_ALIGNMENT.md` diff --git a/docs/RESPONSE_CHAIN_ALIGNMENT.md b/docs/RESPONSE_CHAIN_ALIGNMENT.md deleted file mode 100644 index 9da8fe3aa..000000000 --- a/docs/RESPONSE_CHAIN_ALIGNMENT.md +++ /dev/null @@ -1,238 +0,0 @@ -# 响应链路一致性审查报告 - -## 审查日期 -2026-06-13 - -## 审查结论 - -✅ **核心响应链路与 Node.js 参考实现基本一致** - -发现的差异大部分是**设计差异**或**功能增强**,而非 bug。 - ---- - -## 详细对比 - -### 1. 响应头处理 - -#### ✅ Set-Cookie 处理 -**Node.js & Rust:** 都完整提取并持久化 `Set-Cookie` 响应头 -- Node.js: 通过 `CookieJar.captureRaw()` -- Rust: 通过 `persist_upstream_cookies_with_deps()` - -#### ✅ Turn-State 处理 -**Node.js:** 仅发送 `x-codex-turn-state` 请求头 -**Rust:** 发送请求头 + 从响应头提取并用于 session affinity - -**结论:** Rust 实现更完整,Node.js 可考虑补充 - -#### ✅ Rate-Limit 头 -**Node.js:** 仅 WebSocket 响应提取 -**Rust:** 提取所有 rate-limit 相关响应头并记录到日志 - -**结论:** Rust 监控能力更强 - ---- - -### 2. SSE 流式响应 - -#### ✅ 事件类型映射 -完全一致的事件类型支持: -- `response.output_text.delta` - 文本增量 -- `response.reasoning_summary_text.delta` - 推理增量 -- `response.output_item.added` - 函数调用开始 -- `response.function_call_arguments.delta` - 函数参数增量 -- `response.function_call_arguments.done` - 函数参数完成 -- `response.completed` - 响应完成 -- `response.failed` / `error` - 错误事件 - -#### ✅ OpenAI SSE 格式转换 -完全一致的格式: -- 初始角色 chunk: `{delta: {role: "assistant"}}` -- 文本增量: `{delta: {content: "..."}}` -- 工具调用增量: `{delta: {tool_calls: [...]}}` -- 最终 chunk: `{finish_reason, usage}` -- 结束标记: `data: [DONE]` - -#### ⚠️ Heartbeat 机制 -**Node.js:** 15秒心跳机制,发送 `: ping\n\n` 防止连接超时 -**Rust:** 缺少心跳机制 - -**影响:** 长时间推理(>30秒)可能导致代理/隧道超时断连 - -**建议:** 添加心跳机制(优先级:中) - ---- - -### 3. 非流式响应 - -#### ✅ `/v1/chat/completions` 非流式 -**Node.js & Rust:** 都将 Codex SSE 转换为 OpenAI `ChatCompletionResponse` 格式 -- 完整的 `choices[0].message` 对象 -- 包含 `usage` 统计 -- `finish_reason` 正确设置 - -#### ✅ `/v1/responses` 非流式 -**Node.js & Rust:** 都返回 **原始 Codex 格式** - -**重要说明:** `/v1/responses` 是 Codex Responses API 的透传端点,**设计上就是返回原始格式**,这不是 bug。 - ---- - -### 4. 错误响应 - -#### ✅ 上游错误格式 -**Node.js & Rust:** 都将 Codex 错误转换为 OpenAI 错误格式: -```json -{ - "error": { - "message": "...", - "type": "server_error", - "code": "upstream_error" - } -} -``` - -#### ⚠️ 空响应处理 -**Node.js:** 空响应重试机制(`MAX_EMPTY_RETRIES = 2`) -**Rust:** 直接返回 502 错误 - -**影响:** Rust 对偶发空响应容错能力较弱 - -**建议:** 添加空响应重试(优先级:中) - ---- - -### 5. 响应增强 - -#### ✅ Usage 提取 -**Node.js & Rust:** 都从 `response.completed.usage` 提取并转换为 OpenAI 格式: -```json -{ - "usage": { - "prompt_tokens": ..., - "completion_tokens": ..., - "total_tokens": ..., - "prompt_tokens_details": {"cached_tokens": ...}, - "completion_tokens_details": {"reasoning_tokens": ...} - } -} -``` - -#### ℹ️ Session Affinity -**Node.js:** 完整的 affinity map + reasoning replay cache -**Rust:** 基础的 turn_state 记录 - -**结论:** 两种实现策略不同,都能工作 - ---- - -## 关键差异总结 - -| 项目 | Node.js | Rust | 评估 | -|------|---------|------|------| -| **响应头提取** | 缺少 turn-state 响应头 | 完整提取 | ✅ Rust 更完整 | -| **Rate-limit 监控** | 部分提取 | 完整提取 | ✅ Rust 更强 | -| **SSE 事件映射** | 完整 | 完整 | ✅ 一致 | -| **OpenAI 格式转换** | 完整 | 完整 | ✅ 一致 | -| **`/v1/responses` 格式** | Codex 原始 | Codex 原始 | ✅ 一致(设计) | -| **Heartbeat 机制** | 15秒心跳 | 无 | ⚠️ 需添加 | -| **空响应重试** | 2次重试 | 无重试 | ⚠️ 需添加 | -| **错误格式** | OpenAI 格式 | OpenAI 格式 | ✅ 一致 | -| **Usage 提取** | 完整 | 完整 | ✅ 一致 | - ---- - -## 需要修复的问题 - -### 优先级:中 - -#### 1. 添加 SSE Heartbeat 机制 -**问题:** 长时间推理时连接可能超时 -**参考:** Node.js `src/routes/shared/response-processor.ts` -```typescript -const HEARTBEAT_CHUNK = ": ping\n\n"; -setInterval(() => { - if (Date.now() - lastActivity >= 15000) { - writer.write(HEARTBEAT_CHUNK); - } -}, 15000); -``` - -**建议实现位置:** `src/codex/serving/dispatch/stream.rs` - -#### 2. 添加空响应重试机制 -**问题:** 偶发空响应直接失败 -**参考:** Node.js `EmptyResponseError` + retry loop - -**建议实现位置:** `src/codex/serving/dispatch/mod.rs` - -### 优先级:低 - -#### 3. Node.js 补充 turn-state 响应头提取 -**问题:** Node.js 未从响应中提取 turn-state -**建议:** 参考 Rust 实现提取并用于 session tracking - ---- - -## 无需修复的"差异" - -### ✅ `/v1/responses` 返回格式 -**不是 bug!** 这是 Codex Responses API 的透传端点,设计上就是返回原始 Codex 格式。 -- `/v1/chat/completions` → 返回 OpenAI 格式 ✓ -- `/v1/responses` → 返回 Codex 格式 ✓ - -### ✅ Session Affinity 实现差异 -两种不同的实现策略: -- Node.js: affinity map + reasoning cache -- Rust: turn_state tracking - -都能满足会话连续性需求,不需要强制统一。 - ---- - -## 验证建议 - -### 1. 功能验证 -- ✅ 流式响应格式 - 已验证 -- ✅ 非流式响应格式 - 已验证 -- ✅ 错误响应格式 - 已验证 -- ✅ Usage 统计 - 已验证 -- ⚠️ 长时间推理 - 需测试(验证是否超时) - -### 2. 性能验证 -- ✅ SSE 事件解析性能 -- ✅ 响应转换性能 -- ⚠️ 空响应发生频率 - 需监控 - -### 3. 兼容性验证 -- ✅ OpenAI SDK 兼容性 -- ✅ 第三方客户端兼容性 -- ✅ `/v1/responses` 原生客户端兼容性 - ---- - -## 结论 - -**响应链路整体一致性:95%** - -核心功能完全一致: -- ✅ SSE 事件映射和转换 -- ✅ OpenAI 格式兼容 -- ✅ 错误处理 -- ✅ Usage 统计 - -需要补充的功能: -- ⚠️ Heartbeat 机制(防止长推理超时) -- ⚠️ 空响应重试(提升容错能力) - -这两个功能属于**可靠性增强**,不影响基本功能正确性。 - ---- - -## 参考文档 - -- Node.js 响应处理: `/home/zyy/桌面/Codes/codex-proxy/src/routes/` -- Rust 响应处理: `/home/zyy/Codes/codex-proxy-rs/src/codex/serving/` -- SSE 转换: `src/codex/gateway/protocol/codex_to_openai.rs` -- 流处理: `src/codex/serving/dispatch/stream.rs` diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 8f9bde266..000000000 --- a/docs/api.md +++ /dev/null @@ -1,717 +0,0 @@ -# API Contract - -`codex-proxy-rs` exposes two API families. The `/admin/*` path is reserved for -the future human Web console; machine-readable management endpoints live under -`/api/admin/*`. - -## `/v1/*` - -These endpoints are OpenAI-compatible and are authenticated only by enabled local client API keys created through `/api/admin/api-keys` and sent as `Authorization: Bearer cpr_...`. -Responses include an `X-Request-Id` header for tracing, but the body stays OpenAI-compatible and does not include a custom `requestId` field. - -Error body: - -```json -{ - "error": { - "message": "Invalid client API key", - "type": "invalid_request_error", - "param": null, - "code": "invalid_api_key" - } -} -``` - -Streaming errors after SSE has started use: - -```text -event: error -data: {"error":{"message":"Upstream failed","type":"server_error","param":null,"code":"upstream_error"}} -``` - -### Model Routes - -`GET /v1/models` returns the OpenAI-compatible model list. The default static Codex model is `gpt-5.5`. - -`GET /v1/models/catalog` returns Codex model metadata for UI use. Aliases are used for request parsing but are not exposed as standalone models. - -`GET /v1/models/{id}` returns an OpenAI-compatible model object or a `404` OpenAI error body with `code=model_not_found`. - -`GET /v1/models/{id}/info` returns the extended Codex catalog entry for a known model. - -Model name parsing supports configured aliases plus `-low`, `-medium`, `-high`, `-xhigh`, `-fast`, and `-flex` suffixes. - -### `GET /debug/fingerprint` - -Returns the local runtime Codex Desktop fingerprint summary. The route is local-only and rejects forwarded remote requests using `x-forwarded-for` or `x-real-ip`. - -Success response: - -```json -{ - "source": "staticDefault", - "originator": "Codex Desktop", - "appVersion": "26.519.81530", - "buildNumber": "3178", - "platform": "darwin", - "arch": "arm64", - "chromiumVersion": "146", - "userAgent": "Codex/26.519.81530 (darwin; arm64) Chromium/146" -} -``` - -### `GET /debug/upstream` - -Runs a local-only Codex backend connectivity probe against the Codex models endpoint using the runtime TLS and Desktop fingerprint headers. The route rejects forwarded remote requests using `x-forwarded-for` or `x-real-ip`, returns only status metadata, and does not expose account tokens or upstream response bodies. - -Success response for a reachable backend that rejects the synthetic diagnostic token: - -```json -{ - "target": "codexModels", - "status": "reachable", - "backendBaseUrl": "https://chatgpt.com/backend-api", - "endpoint": "https://chatgpt.com/backend-api/codex/models?client_version=26.519.81530", - "reachable": true, - "statusCode": 401, - "authorization": "rejected", - "durationMs": 23, - "error": null -} -``` - -### `GET /debug/diagnostics` - -Returns a local-only runtime diagnostics summary. The route is intended for production-local troubleshooting and rejects forwarded remote requests using `x-forwarded-for` or `x-real-ip`. - -Success response: - -```json -{ - "status": "ok", - "runtime": { - "packageName": "codex-proxy-rs", - "packageVersion": "0.1.0" - }, - "paths": { - "config": "config.yaml", - "localConfig": "local.yaml", - "databaseUrl": "sqlite://.runtime/data/codex-proxy-rs.sqlite" - }, - "transport": { - "backendBaseUrl": "https://chatgpt.com/backend-api", - "tls": { - "forceHttp11": false - }, - "fingerprint": { - "source": "staticDefault", - "originator": "Codex Desktop", - "appVersion": "26.519.81530", - "buildNumber": "3178", - "platform": "darwin", - "arch": "arm64", - "chromiumVersion": "146", - "userAgent": "Codex/26.519.81530 (darwin; arm64) Chromium/146" - } - }, - "accounts": { - "repositoryAvailable": true, - "authenticatedState": true, - "pool": { - "total": 1, - "active": 1, - "expired": 0, - "quotaExhausted": 0, - "refreshing": 0, - "disabled": 0, - "banned": 0 - }, - "capacity": { - "maxConcurrentPerAccount": 3, - "totalSlots": 3, - "usedSlots": 0, - "availableSlots": 3 - } - }, - "settings": { - "defaultModel": "gpt-5.5", - "refreshEnabled": true, - "rotationStrategy": "least_used", - "quotaSkipExhausted": true, - "logsEnabled": false - } -} -``` - -### `POST /v1/responses` - -Uses imported Codex accounts to call `POST /codex/responses` on the configured Codex backend. The upstream request is sent with Codex Desktop headers, account bearer token, optional account id, request id, and encrypted account-scoped Cookie replay. - -When the client omits `stream` or sets `"stream": false`, the Rust route collects upstream HTTP SSE until `response.completed` and returns the completed OpenAI-compatible response JSON. If upstream sends assistant output through `response.output_item.done` or `response.output_text.delta` events while the completed payload has an empty `output`, the route reconstructs a standard assistant `output` message and synchronizes `output_text`. - -When the client sets `"stream": true`, the Rust route returns `text/event-stream` and passes through upstream SSE frames while collecting usage for the account after the stream finishes. Both modes capture upstream `Set-Cookie`, replay encrypted account-scoped Cookies, record account usage when usage appears in SSE, and write a `v1.response` event log with `requestId`, `accountId`, `route`, `model`, `statusCode`, `latencyMs`, and non-secret metadata. - -For non-streaming clients, upstream HTTP SSE `event: error` and `event: response.failed` terminal frames return a `502` OpenAI-compatible `upstream_error` JSON body instead of an empty-response error. For streaming clients, those terminal frames remain passthrough SSE output and the lifecycle log is marked as a failed upstream SSE response. - -If Codex returns `401` and the account has a stored refresh token, the route refreshes the account through the OpenAI OAuth token endpoint once, persists the rotated access token, preserves the old refresh token when the server omits a new one, updates the in-process account pool, and retries the same request once. -Refresh failures are recorded as non-secret `account.refresh` event logs with the request id, account id, model, `trigger`, `failure`, and resulting `accountStatus`. - -Responses use the Codex WebSocket upstream by default while keeping the client-facing response as SSE. `previous_response_id` always uses WebSocket so upstream conversation history is preserved. The local extension field `use_websocket: false` forces HTTP SSE for requests that do not require upstream history; the removed camelCase `useWebSocket` alias is ignored and no longer toggles transport. - -## `/api/admin/*` - -Admin endpoints are authenticated only by HttpOnly admin session cookies. -Admin JSON uses lower camelCase field names. Every admin response includes an `X-Request-Id` header, and every JSON body includes `requestId`. - -Use real HTTP status codes and body-level frontend codes together. Do not return HTTP `200` for failed requests. The body `code` exists for frontend branching; the HTTP status remains the transport truth. - -### `GET /api/admin/diagnostics` - -Returns the same non-secret runtime diagnostics summary as `/debug/diagnostics`, wrapped in the admin envelope and gated by the admin session cookie. - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "status": "ok", - "runtime": { - "packageName": "codex-proxy-rs", - "packageVersion": "0.1.0" - }, - "transport": { - "backendBaseUrl": "https://chatgpt.com/backend-api" - }, - "accounts": { - "authenticatedState": true - } - }, - "requestId": "req_01" -} -``` - -### `POST /api/admin/login` - -Authenticates only with the configured admin password stored in `admin_users`. Client API keys (`Bearer cpr_...`) are ignored by the admin login flow and cannot create admin sessions. - -Request: - -```json -{ - "password": "admin-password" -} -``` - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "expiresAt": "2026-06-11T12:00:00Z" - }, - "requestId": "req_01" -} -``` - -Success also returns: - -```http -Set-Cookie: cpr_admin_session=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600 -``` - -Invalid password response uses HTTP `401` and body code `40102`. - -### `GET /api/admin/settings` - -Returns the in-scope runtime settings visible to the admin UI. This endpoint uses admin sessions, not client API keys. - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "defaultModel": "gpt-5.5", - "defaultReasoningEffort": null, - "serviceTier": null, - "modelAliases": {}, - "refreshEnabled": true, - "refreshMarginSeconds": 300, - "refreshConcurrency": 2, - "maxConcurrentPerAccount": 3, - "requestIntervalMs": 50, - "rotationStrategy": "least_used", - "tierPriority": [], - "quotaRefreshIntervalMinutes": 5, - "quotaWarningThresholds": { - "primary": [80, 90], - "secondary": [80, 90] - }, - "quotaSkipExhausted": true, - "logsEnabled": false, - "logsCapacity": 2000, - "logsCaptureBody": false, - "usageHistoryRetentionDays": null - }, - "requestId": "req_01" -} -``` - -### `PATCH /api/admin/settings` - -Updates retained settings and writes them to root `local.yaml` as a config overlay. The route requires the admin session cookie. Client API keys and removed TypeScript-era fields are not accepted. - -Request: - -```json -{ - "defaultModel": "gpt-6", - "defaultReasoningEffort": "medium", - "serviceTier": "priority", - "modelAliases": { - "fast": "gpt-6-fast" - }, - "refreshEnabled": false, - "refreshMarginSeconds": 180, - "refreshConcurrency": 3, - "maxConcurrentPerAccount": 5, - "requestIntervalMs": 125, - "rotationStrategy": "round_robin", - "tierPriority": ["pro", "plus"], - "quotaRefreshIntervalMinutes": 15, - "quotaWarningThresholds": { - "primary": [75, 90], - "secondary": [65, 95] - }, - "quotaSkipExhausted": false, - "logsEnabled": false, - "logsCapacity": 3000, - "logsCaptureBody": true, - "usageHistoryRetentionDays": 60 -} -``` - -Success response uses the same `data` shape as `GET /api/admin/settings`. - -Validation: - -- `rotationStrategy` must be `least_used`, `round_robin`, or `sticky`. -- Count/capacity/retention fields that represent concurrency, capacity, intervals, or retention must be greater than zero where zero would disable required service behavior. -- Quota warning thresholds must be between `0` and `100`. -- Unknown settings fields return HTTP `400` with body code `40001`. - -### `GET /api/admin/logs` - -Returns cursor-paginated event logs for admin troubleshooting. The list uses the standard admin page envelope and lower camelCase fields. - -Supported query filters: - -- `kind` -- `level` -- `requestId` -- `accountId` -- `route` -- `model` -- `statusCode` -- `search` across message and metadata text - -Example item: - -```json -{ - "id": "log_01", - "requestId": "req_01", - "kind": "v1.response", - "level": "info", - "accountId": "acct_01", - "route": "/v1/responses", - "model": "gpt-5.5", - "statusCode": 200, - "latencyMs": 123, - "message": "v1 responses 已完成", - "metadata": { - "stream": false, - "usage": { - "inputTokens": 7, - "outputTokens": 4, - "cachedTokens": 2, - "totalTokens": 11 - } - }, - "createdAt": "2026-06-11T12:00:00Z" -} -``` - -### `GET /api/admin/logs/state` - -Returns the current retained logging configuration plus the number of stored SQLite event logs. - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "enabled": false, - "capacity": 2000, - "captureBody": false, - "storedCount": 42 - }, - "requestId": "req_01" -} -``` - -### `PATCH /api/admin/logs/state` - -Updates the in-process logging state used by admin log controls. This is runtime-only; persistent configuration changes still go through `PATCH /api/admin/settings`. - -Request: - -```json -{ - "enabled": true, - "capacity": 2000, - "captureBody": false -} -``` - -Success response uses the same data shape as `GET /api/admin/logs/state`. - -### `GET /api/admin/logs/{id}` - -Returns a single stored event log by id using the same event shape as list items. Missing ids return HTTP `404` with body code `40401`. - -### `DELETE /api/admin/logs` - -Clears stored SQLite event logs. - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "cleared": 42 - }, - "requestId": "req_01" -} -``` - -### `GET /api/admin/usage-stats` - -Returns cursor-paginated account usage counters recorded after `/v1/*` calls. The endpoint never returns account access tokens, refresh tokens, Cookie values, or client API keys. - -Example item: - -```json -{ - "accountId": "acct_01", - "email": "user@example.com", - "label": "primary", - "planType": "plus", - "requestCount": 2, - "inputTokens": 14, - "outputTokens": 8, - "cachedTokens": 3, - "lastUsedAt": "2026-06-11T12:00:00Z" -} -``` - -### `GET /api/admin/usage-stats/summary` - -Returns global usage totals across accounts. - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "accountCount": 1, - "requestCount": 2, - "inputTokens": 14, - "outputTokens": 8, - "cachedTokens": 3 - }, - "requestId": "req_01" -} -``` - -### `GET /api/admin/api-keys` - -Returns local client API keys with cursor pagination. The plaintext key and hash are never returned by list endpoints. - -### `POST /api/admin/api-keys` - -Creates a local client API key for `/v1/*`. The plaintext value is returned only in this response; store it client-side and use it as `Authorization: Bearer `. - -Request: - -```json -{ - "name": "cursor" -} -``` - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "id": "key_01", - "name": "cursor", - "prefix": "cpr_xxxxxxxx", - "enabled": true, - "createdAt": "2026-06-11T12:00:00Z", - "lastUsedAt": null, - "plaintext": "cpr_full_key_returned_once" - }, - "requestId": "req_01" -} -``` - -### `GET /api/admin/api-keys/export` - -Exports Rust-local client API key metadata for backup or migration. The export never includes plaintext keys, key hashes, or the raw auth pepper. Because `cpr_` keys are stored only as peppered HMAC hashes, imports rotate keys instead of preserving the original plaintext. - -Optional query: - -```http -GET /api/admin/api-keys/export?ids=key_01,key_02 -``` - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "sourceFormat": "rustLocalClientApiKeys", - "rotationRequired": true, - "apiKeys": [ - { - "id": "key_01", - "name": "cursor", - "label": "automation", - "prefix": "cpr_xxxxxxxx", - "enabled": true, - "createdAt": "2026-06-11T12:00:00Z", - "lastUsedAt": null - } - ] - }, - "requestId": "req_01" -} -``` - -### `POST /api/admin/api-keys/import` - -Imports Rust-local client API key metadata exported by this service. Import creates new local `cpr_` keys using the target instance's pepper and returns the new plaintext values only in this response. Clients must replace the old keys with these rotated plaintext values. - -Request: - -```json -{ - "sourceFormat": "rustLocalClientApiKeys", - "rotationRequired": true, - "apiKeys": [ - { - "id": "key_01", - "name": "cursor", - "label": "automation", - "prefix": "cpr_xxxxxxxx", - "enabled": true - } - ] -} -``` - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "imported": 1, - "skipped": 0, - "rotated": true, - "keys": [ - { - "sourceId": "key_01", - "sourcePrefix": "cpr_xxxxxxxx", - "id": "key_02", - "name": "cursor", - "label": "automation", - "prefix": "cpr_yyyyyyyy", - "enabled": true, - "createdAt": "2026-06-11T12:05:00Z", - "lastUsedAt": null, - "plaintext": "cpr_new_full_key_returned_once" - } - ] - }, - "requestId": "req_01" -} -``` - -### `GET /api/admin/accounts` - -Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned. - -### `GET /api/admin/accounts/quota-warnings` - -Returns active quota warnings computed from cached account quota snapshots in SQLite. The route requires an admin session cookie and does not call the upstream Codex backend. - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "warnings": [ - { - "accountId": "acct_01", - "email": "user@example.com", - "window": "primary", - "level": "warning", - "usedPercent": 85.0, - "resetAt": 1770000100 - } - ], - "updatedAt": "2026-06-13T00:00:00Z" - }, - "requestId": "req_01" -} -``` - -### `POST /api/admin/accounts/import` - -Imports accounts into encrypted SQLite storage. The request body uses the native Rust import format. The only supported shape is an object with `accounts`; unknown container or account fields are rejected by producing no importable accounts. - -Request: - -```json -{ - "accounts": [ - { - "id": "acct_01", - "email": "user@example.com", - "accountId": "chatgpt-account", - "userId": "chatgpt-user", - "label": "primary", - "planType": "plus", - "token": "access-token", - "refreshToken": "refresh-token", - "status": "active" - } - ] -} -``` - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "imported": 1, - "skipped": 0, - "sourceFormat": "native" - }, - "requestId": "req_01" -} -``` - -### `GET /api/admin/accounts/export` - -Exports native Rust account backups. The default format is native; `format=native` is the only explicit format accepted. Any other format returns HTTP `400`. - -Optional query: - -```http -GET /api/admin/accounts/export?ids=acct_01,acct_02&format=native -``` - -Success response: - -```json -{ - "code": 200, - "message": "OK", - "data": { - "sourceFormat": "native", - "accounts": [ - { - "id": "acct_01", - "email": "user@example.com", - "accountId": "chatgpt-account", - "userId": "chatgpt-user", - "label": "primary", - "planType": "plus", - "token": "access-token", - "refreshToken": "refresh-token", - "status": "active", - "accessTokenExpiresAt": null, - "addedAt": "2026-06-11T12:00:00Z", - "updatedAt": "2026-06-11T12:00:00Z" - } - ] - }, - "requestId": "req_01" -} -``` - -List body: - -```json -{ - "code": 200, - "message": "OK", - "data": [], - "page": { - "limit": 50, - "nextCursor": null - }, - "requestId": "req_01" -} -``` - -Error body: - -```json -{ - "code": 40101, - "message": "Admin login required", - "data": null, - "requestId": "req_01" -} -``` - -Pagination uses cursor ordering by `(created_at desc, id desc)`, default `limit=50`, max `limit=200`. - -Rust structs use `PascalCase` type names and `snake_case` fields internally, then expose lower camelCase through serde: - -```rust -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiEnvelope<T> { - pub code: u32, - pub message: String, - pub data: T, - pub request_id: String, -} -``` diff --git a/docs/architecture-audit.md b/docs/architecture-audit.md deleted file mode 100644 index 04eafca86..000000000 --- a/docs/architecture-audit.md +++ /dev/null @@ -1,6796 +0,0 @@ -# Architecture Migration Audit - -Date: 2026-06-19 - -This document records the current audit state for the workspace architecture -defined in `docs/architecture.md`. - -## Current Verdict - -The architecture migration is no longer blocked by empty source files, empty -source directories, empty Rust function/module shells, unwired `xtask`, root -behavior tests left under `tests/`, or an unmapped baseline behavior-test suite. - -Current source-level audit result: - -- `codegraph sync . && codegraph status .` was rerun on 2026-06-19 and reports - the index up to date: 285 files, 4,332 nodes, 13,241 edges. -- `tests/` now contains root architecture tests only; behavior tests are under - `crates/*/tests`. -- Baseline root behavior-test migration is now guarded for all 46 old files - that contain `#[test]` / `#[tokio::test]`; the 8 old root `mod` aggregators - are not counted as behavior suites. -- Current test inventory after the latest migration pass: 495 test attributes - across 65 Rust test files, including 43 crate-local integration test files. -- `find tests crates src -type f \( -name '*.rs' -o -name '*.toml' -o -name - '*.md' \) -empty` returned no source/document/manifest empty files. -- `find tests crates src -type d -empty ...` returned no empty source/test - directories. -- Placeholder/empty-shell scan in `crates src tests` only matched the - architecture guard's own sample strings in - `tests/architecture/placeholder_implementations.rs`. -- `crates/xtask` is wired and intentionally thin: it dispatches - `build-web`, `check-architecture`, and `release` to real commands. - -The current completion state is therefore: source migration placement and -obvious scaffold cleanup are complete by current evidence. The broad -verification gate below has passed, and older OpenAI/Codex parity documents have -been reconciled to the current crate-local coverage. - -## Audit Inputs - -- Baseline before the migration squash: - `f07442a28b0c186bd22b598a53cc4856ab4b2445` -- Current audited head: - `2aa27475723474469d442efab690f70b8f1f0fc9` -- CodeGraph: - - `codegraph sync . && codegraph status .` run during this audit. - - Result: synced after the latest diagnostics/trace/log-rotation/test-mapping - migration pass. - - Status after latest sync: 285 files, 4,332 nodes, 13,241 edges. -- Empty local directories: - - Follow-up `find tests crates src ... -type d -empty` found no remaining - empty source/test directories. - -## 2026-06-19 Current Full Migration Audit Pass - -Status: complete; source/test placement gaps found in this pass have been -implemented, and the full verification gate has passed. - -New gaps found and migrated in this pass: - -- Old `tests/codex_serving/diagnostics_route.rs` behavior was only partially - represented in crate-local tests. Added - `crates/server/tests/openai_diagnostics_routes.rs` and implemented: - `/debug/diagnostics` path/fingerprint/capacity fields, `/debug/fingerprint`, - `/debug/upstream`, local-only guards, and secret redaction. -- Old `tests/runtime/http_trace.rs` behavior had no clear crate-local target. - Added `crates/server/tests/http_trace_middleware.rs`, implemented - `server::middleware::trace::http_trace_layer()`, and mounted it in the - server router. -- Old `tests/platform/log_rotation.rs` rolling appender behavior had no - crate-local test. Added `crates/platform/tests/log_rotation.rs`. -- `tests/architecture/test_migration.rs` now maps all 46 old behavior files to - current crate-local test files, instead of only a high-risk subset. - -Targeted verification run in this pass: - -- `cargo test -p codex-proxy-server --test openai_diagnostics_routes -- --nocapture` - -> 4 passed. -- `cargo test -p codex-proxy-server --test http_trace_middleware -- --nocapture` - -> 1 passed. -- `cargo test -p codex-proxy-platform --test log_rotation -- --nocapture` - -> 2 passed. -- `cargo test --test architecture test_migration -- --nocapture` - -> 3 passed. -- `cargo fmt --all -- --check` -> passed. -- `cargo check --workspace --tests` -> passed. -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - -> passed after removing a `runtime` boundary violation in the diagnostics - probe and factoring the HTTP trace layer type. -- `cargo test --workspace --tests` -> passed. -- `cargo run -p xtask -- check-architecture` -> passed; 27 architecture - tests passed. -- `cargo doc --workspace --no-deps` -> passed. -- `cargo run -p xtask -- release` -> passed: - `cargo fmt --all -- --check`, `cargo test --workspace --all-targets`, - `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`, - `pnpm install --frozen-lockfile`, and `pnpm build`. -- Final `codegraph sync . && codegraph status .` -> index up to date with 285 - files, 4,332 nodes, and 13,241 edges. -- Final empty-file and empty-directory scans across `tests crates src` returned - no entries. -- Final placeholder/empty-shell scan across `tests crates src` only matched the - architecture guard's own sample strings. - -Current remaining work before claiming final migration completion: none found -by this pass. The migration is complete by current source, test, architecture, -documentation, and release-gate evidence. - -Note: the long slice log below is retained as historical evidence. Earlier -"Blocking Findings" entries may describe states that were later closed by -subsequent slices and by this current audit pass. - -## Implementation Progress - -### 2026-06-18 Slice 1: Core Installation ID IO Boundary - -Status: implemented and verified in this slice. - -Changes: - -- Added an architecture guard so `crates/core/src` fails tests if it uses - filesystem or environment/path IO patterns: `std::fs`, `std::env`, `dirs::`, - `std::path`, `PathBuf`, or `Path::`. -- Reworked `crates/core/src/gateway/installation.rs` into pure UUID rules: - `generate_installation_id()` and `parse_installation_id()`. -- Moved Codex Desktop/data-dir installation ID file resolution and persistence - to `platform::storage`. -- Removed `dirs` and `tempfile` from `codex-proxy-core` dependencies. -- Added crate-local tests for the old lookup order: - Codex Desktop file, data-dir file, then generate-and-persist. - -Verification run so far: - -- `cargo test --test architecture source_imports_should_respect_layer_boundaries` -- `cargo test -p codex-proxy-platform storage::paths::tests::resolve_installation_id` -- `cargo test -p codex-proxy-core gateway::installation::tests` -- `cargo fmt --all -- --check` -- `cargo test --test architecture` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `cargo test --workspace --all-targets` -- `codegraph sync .` - -Note: - -- The first full workspace test attempt exhausted disk space while writing - `target/`. `cargo clean` removed 72.1 GiB of build artifacts, then - `cargo test --workspace --all-targets` was rerun successfully. - -Remaining after this slice: - -- Runtime startup/request wiring still needs to call the new - `platform::storage` installation ID API where the real startup path is - restored. **Closed later in Slice 7.** -- This only closes the direct `core` IO boundary violation; it does not close - the server entrypoint, xtask, runtime task, WebSocket, serving dispatch, or - deleted-test migration findings. - -### 2026-06-18 Slice 2: Placeholder Guard, Xtask Wiring, And Server Main - -Status: implemented and targeted verification passed in this slice. - -Changes: - -- Added `tests/architecture/placeholder_implementations.rs` to fail when Rust - sources contain machine-detectable placeholder markers: - - `fn main() {}` - - `not wired yet` - - `后续承载` -- Replaced `crates/server/src/main.rs` empty entrypoint with a real startup - path that: - - loads `config.yaml`/local config through `platform::config`; - - initializes logging when enabled; - - connects SQLite through `platform::storage`; - - loads/creates the master secret box and API key pepper; - - constructs `runtime::state::AppState`; - - serves the Axum router with `ctrl_c` graceful shutdown. -- Wired `cargo run -p xtask -- check-architecture` to actually execute - `cargo test --test architecture`. -- Wired `cargo run -p xtask -- build-web` to run - `pnpm install --frozen-lockfile` and `pnpm build` in `web/`. -- Wired `cargo run -p xtask -- release` to run Rust format/test/clippy gates - and then the web build command. -- Replaced root crate docs that described future placeholder behavior with - current responsibility descriptions. - -Verification run so far: - -- `cargo test --test architecture rust_sources_should_not_keep_placeholder_markers` -- `cargo check -p codex-proxy-server --all-targets` -- `cargo check -p xtask --all-targets` -- `cargo test --test architecture` -- `cargo run -p xtask -- check-architecture` -- `cargo run -p xtask -- build-web` -- `cargo run -p xtask -- release` - -Note: - -- The first `xtask build-web` run failed on npm registry `ECONNRESET`/fetch - errors while `pnpm install --frozen-lockfile` was retrying. The command was - restarted and then completed successfully, including `pnpm build`. - -Remaining after this slice: - -- Server startup is no longer an empty function, but it still does not prove the - full baseline startup parity: account restoration, session-affinity - restoration, remaining background task parity details, and installation ID - request wiring still need explicit migration and tests. Installation ID - request wiring was closed later in Slice 7. -- `xtask release` is now wired and has been run successfully once. It should be - kept in the final verification gate after the remaining migration slices - stabilize. - -### 2026-06-18 Slice 3: Runtime Cleanup Task Wiring - -Status: cookie/session cleanup task slices and migrated-task coordinator wiring -implemented; targeted verification passed. - -Changes: - -- Added `SqliteCookieStore::cleanup_expired(now)` to persistently delete - expired account cookies. -- Added `SqliteAdminSessionStore::cleanup_expired_sessions(now)` to persistently - delete expired admin sessions. -- Implemented `runtime::tasks::cookie_cleanup::CookieCleanupTask` with: - - default 5-minute interval; - - `start()` returning the shared `SchedulerHandle`; - - `cleanup_once()` and testable `cleanup_once_at(now)`. -- Implemented `runtime::tasks::session_cleanup::SessionCleanupTask` with: - - configured interval; - - `start()` returning the shared `SchedulerHandle`; - - `cleanup_once()` and testable `cleanup_once_at(now)`. -- Added crate-local runtime tests proving cleanup deletes only expired rows and - leaves unexpired rows intact. -- Added `BackgroundTaskCoordinator::task_names()` so runtime task wiring can be - regression-tested without depending on private handle internals. -- Added runtime task stores to `Services` so background tasks are assembled from - the same SQLite adapters that power request/admin services. -- Wired `start_background_tasks(&AppState)` to start the migrated - `cookie_cleanup`, `session_cleanup`, and `model_refresh` tasks. -- Wired the server entrypoint to own the background task coordinator and shut it - down after Axum graceful shutdown returns. -- Added an architecture guard requiring the server entrypoint to start runtime - background tasks and shut the coordinator down. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test tasks cleanup_task_should_delete_only_expired` -- `cargo test -p codex-proxy-runtime --test tasks start_background_tasks_should_register_migrated_runtime_tasks` -- `cargo test --test architecture server_entrypoint_should_own_background_task_lifecycle` - -Remaining after this slice: - -- `token_refresh.rs` and `quota_refresh.rs` are still one-line modules. -- `start_background_tasks(&AppState)` now wires the migrated cleanup/model - tasks, but it still does not wire token refresh, quota refresh, or fingerprint - update. -- Startup now owns the coordinator lifetime for the wired tasks, but full - baseline parity still depends on migrating the remaining schedulers. - -### 2026-06-18 Slice 4: Token And Quota Refresh Task Migration - -Status: token/quota task minimum runtime loops implemented and targeted -verification passed. - -Changes: - -- Added `core::accounts::jwt::jwt_expiration(token)` so runtime token refresh - can persist refreshed access-token expiry without duplicating JWT parsing. -- Implemented `runtime::tasks::token_refresh::TokenRefreshTask` with: - - core `RefreshScheduler` and `TokenRefresher` port usage; - - SQLite account scanning via `SqliteAccountStore`; - - refreshed access-token persistence through `update_from_claims`; - - status-only persistence for scheduler status transitions; - - periodic `start()` integration with `SchedulerHandle`. -- Added a runtime integration test proving a due account refresh writes the new - access token, preserves the refresh token when upstream omits it, stores the - new JWT expiry, and keeps the account active. -- Added `SqliteAccountStore::update_quota_json()` and `get_quota_json()` based - on the old quota repository behavior. -- Migrated the old usage-to-quota snapshot normalization into - `core::serving::quota::quota_from_usage()`. -- Implemented `runtime::tasks::quota_refresh::QuotaRefreshTask` with: - - scan of active `quota_limit_reached` accounts; - - Codex usage fetch through the existing `CodexBackendClient`; - - normalized quota JSON persistence in SQLite; - - in-process minimum refresh interval tracking for the periodic loop. -- Added runtime quota refresh test using a local mocked Codex usage endpoint. -- Extended `RuntimeConfig` with auth/quota/admin task settings. -- Exposed the assembled Codex backend client from `Services` so runtime tasks - reuse the same adapter instance. -- Wired `start_background_tasks(&AppState)` to include `token_refresh` when - auth refresh is enabled, and `quota_refresh` using configured quota interval. - -Verification run so far: - -- `cargo test -p codex-proxy-core accounts::jwt::tests::jwt_expiration_should_return_exp_as_utc_datetime` -- `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_persist_refreshed_access_token_and_keep_refresh_token` -- `cargo test -p codex-proxy-adapters --test account_repository account_repository_should_update_quota_json_and_fetched_at` -- `cargo test -p codex-proxy-runtime --test quota_refresh quota_refresh_task_should_fetch_usage_for_quota_locked_accounts_and_store_quota` -- `cargo test -p codex-proxy-runtime --test tasks start_background_tasks_should_register_migrated_runtime_tasks` - -Remaining after this slice: - -- Token refresh is functional but not yet at full old parity: no per-account - timer map, in-flight tracking, crash recovery states, exponential retry loop, - or refresh lease store usage. -- Quota refresh is functional for active quota-locked accounts, but admin - explicit quota route parity and all old quota edge-case tests are still - incomplete. -- Server startup still needs account/session-affinity restoration. - -### 2026-06-18 Slice 5: Fingerprint Update Task Wiring - -Status: implemented and targeted coordinator verification passed. - -Changes: - -- Added `FingerprintRepository` to runtime repository/task-store assembly. -- Stored the runtime fingerprint in `Services` so background tasks use the same - version/build values as the request clients. -- Added default runtime constants for the Codex Desktop appcast URL and optional - `data/extracted-fingerprint.json` path. -- Wired `start_background_tasks(&AppState)` to start `fingerprint_update` with - the assembled repository and current runtime fingerprint version/build. -- Extended the runtime coordinator test to require `fingerprint_update` in the - task list. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test tasks start_background_tasks_should_register_migrated_runtime_tasks` - -Remaining after this slice: - -- Fingerprint update is wired, but the broader old fingerprint update tests - still need to be audited against current crate-local coverage. -- Server startup still needs account/session-affinity restoration. - -### 2026-06-18 Slice 6: WebSocket Adapter Shell Cleanup - -Status: placeholder unit structs removed; targeted adapter and architecture -verification passed. - -Changes: - -- Replaced `CodexWebSocketConnection` unit struct with a connection description - that stores the WebSocket endpoint and ordered opening headers. -- Added `CodexWebSocketConnection::opening_audit_snapshot()` so opening header - order can be audited through the core `OpeningAuditSnapshot` type. -- Replaced `CodexWebSocketPool` unit struct with a concrete pool policy holding - `max_per_account` and `max_age`. -- Added pool policy helpers for connection capacity and age-based recycling. -- Extended the placeholder architecture guard to reject the WebSocket adapter - unit structs if they regress. -- Added adapter tests for connection header-order preservation and pool policy. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_` -- `cargo test --test architecture rust_sources_should_not_keep_placeholder_markers` -- `cargo test --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - -Remaining after this slice: - -- This removes the obvious WebSocket adapter shells, but it is not full - WebSocket parity. Real `tokio-tungstenite` connect/send/receive, pooling of - live sockets, deflate handling, and the large old WebSocket behavior tests are - still incomplete. - -### 2026-06-18 Slice 7: Installation ID Request Context Wiring - -Status: implemented and targeted verification passed. - -Changes: - -- Added `AppState::with_pool_secret_api_key_hasher_and_installation_id()` and a - lower-level fingerprint-aware constructor so tests and startup can inject the - runtime installation ID without touching `core` filesystem rules. -- Updated the server entrypoint to resolve the data directory through - `platform::storage`, load/create the installation ID there, and inject it into - runtime state. -- Stored the optional installation ID in `Services` and threaded it through: - - OpenAI Chat Completions dispatch; - - OpenAI Responses dispatch; - - admin account health probes; - - admin model refresh requests; - - background model refresh; - - background quota refresh. -- Extended the pure `core::gateway::ports::CodexModelCatalogRequest` with an - optional `installation_id` field so model catalog fetches can carry runtime - request context without introducing IO into `core`. -- Updated the Codex adapter so auxiliary JSON requests (`usage`, model catalog, - connectivity probes) include `x-codex-installation-id` when context provides - it, matching the existing Responses/compact request behavior. -- Added a server route regression test proving `/v1/chat/completions` forwards - the runtime installation ID to the mocked Codex upstream. -- Added an architecture guard requiring the server entrypoint to load the - platform installation ID and inject it into runtime state. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_usage_should_use_original_auxiliary_headers` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_forward_runtime_installation_id_to_codex` -- `cargo test -p codex-proxy-adapters --test codex` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-core --test models` -- `cargo test --test architecture` - -Remaining after this slice: - -- Server startup still needs account/session-affinity restoration. -- Full serving dispatch/fallback/recovery parity remains incomplete. -- Full live WebSocket transport/pooling/parity remains incomplete, although - pure transport selection plus opening/payload audit snapshots are restored. - -### 2026-06-18 Slice 8: Session Affinity Store And Startup Restore - -Status: core/store/runtime restore path implemented and targeted verification -passed. - -Changes: - -- Replaced the thin `core::serving::affinity` placeholder with pure - `SessionAffinityEntry`, `StoredSessionAffinity`, and `SessionAffinityMap` - types. -- Implemented active-record restore, previous-response account lookup, - conversation lookup, turn-state lookup, variant-aware latest-response lookup, - and forget/size helpers in the pure core map. -- Implemented `SqliteSessionAffinityStore` over the existing - `session_affinities` schema: - - upsert response affinity with TTL-derived expiry; - - list active records; - - delete expired records; - - parse persisted timestamps and function-call ID JSON. -- Added the session affinity store to runtime repository assembly. -- Added `RuntimeSessionAffinityService` with an in-memory core map guarded by - `tokio::sync::RwLock` and restore/lookup/record APIs. -- Added `AppState::restore_session_affinity_from_repository()` and - `restore_session_affinity_from_repository_now()`. -- Wired the server entrypoint to restore session affinity mappings from SQLite - before background task startup and serving. -- Added an architecture guard requiring server startup to restore session - affinity. - -Verification run so far: - -- `cargo test -p codex-proxy-core --test session_affinity` -- `cargo test -p codex-proxy-adapters --test session_affinity` -- `cargo test -p codex-proxy-runtime --test session_affinity` -- `cargo check -p codex-proxy-server --all-targets` -- `cargo test --test architecture` - -Remaining after this slice: - -- Startup still needs active account pool restoration into a runtime pool. - **Closed later in Slice 9.** -- The runtime OpenAI dispatch path still does not use the session affinity map - for preferred-account selection or record new response affinities; that - belongs with the broader serving dispatch/fallback/recovery migration. -- Expired session affinity cleanup is available in the store but is not yet - wired into a background task. - -### 2026-06-18 Slice 9: Account Pool Startup Restore - -Status: implemented and targeted verification passed. - -Changes: - -- Added `RuntimeAccountPoolService` that owns a core `AccountPool` behind a - `tokio::sync::Mutex` and restores its contents from the existing - `AccountStore` port. -- Mapped runtime account pool options from application config: - max concurrent requests per account, stale slot TTL, rotation strategy, - quota-limited skipping, and tier priority. -- Exposed account acquisition/release helpers on the runtime account pool - service for later serving dispatch integration. -- Added `Services::account_pool` and - `AppState::restore_account_pool_from_repository()`. -- Wired the server entrypoint to restore the runtime account pool from SQLite - before restoring session affinity, starting background tasks, and serving. -- Added an architecture guard requiring server startup to restore the runtime - account pool. -- Added a runtime integration test proving an active persisted account is loaded - into the runtime pool and can be acquired. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test account_pool_restore` -- `cargo check -p codex-proxy-server --all-targets` -- `cargo test --test architecture` - -Remaining after this slice: - -- Runtime OpenAI Chat/Responses dispatch still directly scans the account store - and must be moved to the restored account pool. **Closed later in Slice 10.** -- Session affinity is restored but is not yet used for preferred-account - selection or recording new response affinities. -- Serving fallback/recovery, quota transitions, implicit resume, and stream - audit parity remain incomplete. - -### 2026-06-18 Slice 10: Dispatch Uses Restored Account Pool - -Status: implemented and targeted verification passed. - -Changes: - -- Reworked `ChatDispatchService` and `ResponseDispatchService` to acquire - upstream accounts from `RuntimeAccountPoolService` instead of scanning - `AccountStore` for the first active account. -- Released the account pool slot after the upstream Codex request returns, - preserving the core pool's concurrency accounting for subsequent dispatches. -- Updated server upstream route fixtures to restore the account pool before - exercising OpenAI routes. -- Added a regression test proving `/v1/chat/completions` dispatches from the - restored runtime pool even if the SQLite row is changed after startup restore. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - -Remaining after this slice: - -- Session affinity is restored but is not yet used to set - `preferred_account_id` for previous-response requests. **Closed later in - Slice 11.** -- New response affinities are not yet recorded after successful upstream - responses. -- Fallback/recovery, quota transitions, implicit resume, and stream audit parity - remain incomplete. - -### 2026-06-18 Slice 11: Session Affinity Preferred Account Dispatch - -Status: implemented and targeted verification passed. - -Changes: - -- Extended OpenAI Responses request translation to preserve - `previous_response_id` in the Codex request. -- Updated `ResponseDispatchService` to use restored session affinity state for - previous-response requests: - - look up preferred account ID and pass it to `AccountAcquireRequest`; - - restore prompt cache key from the affinity conversation ID when absent; - - restore turn state from affinity when the incoming request lacks it. -- Added a server regression test with two restored accounts proving - `/v1/responses` chooses the account bound to `previous_response_id` instead of - the default pool winner. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_prefer_session_affinity_account_for_previous_response` - -Remaining after this slice: - -- New response affinities are not yet recorded after successful upstream - responses. **Closed later in Slice 12.** -- Fallback/recovery, quota transitions, implicit resume, and stream audit parity - remain incomplete. - -### 2026-06-18 Slice 12: Session Affinity Recording - -Status: implemented and targeted verification passed. - -Changes: - -- Updated `ResponseDispatchService` to record a session affinity entry after a - successful `response.completed` upstream result. -- Persisted the completed response ID, selected account ID, conversation ID, - upstream turn state, input token count when available, and created timestamp - through `RuntimeSessionAffinityService`. -- Kept affinity recording best-effort: persistence failures are logged and do - not turn an otherwise successful upstream response into a 5xx. -- Added a server regression test proving a successful `/v1/responses` call - writes the completed response ID to SQLite session affinity storage. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_session_affinity_for_completed_response` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo fmt --all -- --check` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Fallback/recovery, dirty quota transitions, implicit resume edge cases, and - stream audit parity remain incomplete. -- Expired session affinity cleanup is available in the store but is not yet - wired into a background task. **Closed later in Slice 13.** - -### 2026-06-18 Slice 13: Session Affinity Cleanup Task Wiring - -Status: implemented and targeted verification passed. - -Changes: - -- Added `runtime::tasks::session_affinity_cleanup::SessionAffinityCleanupTask` - with: - - periodic `start()` integration through the shared `SchedulerHandle`; - - `cleanup_once()` and testable `cleanup_once_at(now)`; - - cleanup through the existing `SqliteSessionAffinityStore::delete_expired`. -- Added the SQLite session-affinity store to `BackgroundTaskStores` so - background tasks use runtime-assembled repositories. -- Wired `start_background_tasks(&AppState)` to register - `session_affinity_cleanup` after admin session cleanup. -- Added runtime task coverage proving only expired session-affinity rows are - deleted and the coordinator registers the new task. -- Updated `docs/architecture.md` and architecture source-tree whitelists so the - new task file is part of the precise runtime task layout rather than an - out-of-band source file. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test tasks session_affinity_cleanup_task_should_delete_only_expired_affinities` -- `cargo test -p codex-proxy-runtime --test tasks start_background_tasks_should_register_migrated_runtime_tasks` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Fallback/recovery, dirty quota transitions, implicit resume edge cases, and - stream audit parity remain incomplete. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Admin explicit quota route parity and old quota edge-case tests remain - incomplete. - -### 2026-06-18 Slice 14: Admin Explicit Account Quota Route - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old explicit `GET /api/admin/accounts/{account_id}/quota` - behavior into the new `server`/`runtime` split. -- Added `AdminAccountService::account_quota()` to: - - load the selected active account from SQLite; - - call the Codex usage endpoint through the assembled `CodexBackendClient`; - - normalize the usage response with `core::serving::quota::quota_from_usage`; - - persist the normalized quota snapshot via `SqliteAccountStore`; - - return normalized `quota` plus upstream `raw` usage without exposing stored - token secrets. -- Added the admin HTTP handler and router entry for - `/api/admin/accounts/{account_id}/quota`. -- Added a server regression test migrated from the old quota suite proving the - route calls upstream with the selected account token, stores quota JSON, and - does not return access/refresh tokens. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota_should_fetch_usage_store_quota_and_not_return_secrets` -- `cargo test -p codex-proxy-server --test admin_accounts_routes` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `codegraph sync .` - -Remaining after this slice: - -- Old quota edge-case tests still need migration: inactive account errors, - upstream failure mapping, quota persistence failure behavior, and quota - warning edge cases beyond the current cached-warning test. -- Fallback/recovery, dirty quota transitions, implicit resume edge cases, and - stream audit parity remain incomplete. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. - -### 2026-06-18 Slice 15: Admin Account Quota Upstream Failure Shape - -Status: implemented and targeted verification passed. - -Changes: - -- Added a server regression test for the old explicit quota route behavior when - Codex usage fetch fails. -- Reworked the account quota HTTP handler to use a quota-specific error - response so upstream fetch failures return: - - HTTP `502 Bad Gateway`; - - admin code `50201`; - - message `Failed to fetch quota from Codex API`; - - `data.error` containing the public upstream failure detail. -- Kept standard admin errors (`401`, `404`, inactive account conflicts, and - persistence failures) on the normal admin error path. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota_should_return_bad_gateway_when_usage_fetch_fails` -- `cargo test -p codex-proxy-server --test admin_accounts_routes` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. -- Fallback/recovery, dirty quota transitions, implicit resume edge cases, and - stream audit parity remain incomplete. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. - -### 2026-06-18 Slice 16: Responses Rate-Limit Account Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Added a server regression test migrated from the old upstream fallback suite - proving non-streaming `/v1/responses` retries the next restored runtime-pool - account after the first account returns HTTP `429` with `retry-after`. -- Added `RuntimeAccountPoolService::mark_quota_limited_until()` so runtime - dispatch can update the in-memory core pool instead of keeping fallback state - in the HTTP/server layer. -- Reworked `ResponseDispatchService::complete()` to: - - acquire accounts through the restored runtime pool with an exclusion list; - - call Codex using the selected account; - - release the selected account slot after each attempt; - - on upstream `429`, mark that account quota-limited until the retry-after - cooldown and retry with the next available account; - - preserve the existing completed-response session affinity recording path - for the account that actually succeeds. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Fallback/recovery remains incomplete: streaming Responses fallback, Chat - fallback, 5xx same-account retry, exhausted-account error aggregation, - auth/cloudflare recovery, implicit resume, stream audit, and persisted dirty - quota transitions still need migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 17: Chat Rate-Limit Account Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Added a server regression test proving `/v1/chat/completions` retries the - next restored runtime-pool account after the first account returns HTTP `429` - with `retry-after`. -- Reused the runtime rate-limit fallback path for Chat dispatch: - - acquire accounts through `RuntimeAccountPoolService`; - - release the selected account slot after each attempt; - - on upstream `429`, mark the account quota-limited until the retry-after - cooldown and exclude it from the next acquisition. -- Extracted a shared runtime helper for constructing Codex response requests - with selected account context so Chat and Responses do not drift in auxiliary - headers, turn state, installation ID, or account ID handling. -- Stabilized the OpenAI upstream route test fixtures by moving - `access_token_expires_at` from a same-day timestamp to a far-future timestamp; - the fixed timestamp had started expiring during the audit run and caused - account-pool acquisition to fail before any upstream request. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Fallback/recovery remains incomplete: streaming Responses fallback, 5xx - same-account retry, exhausted-account error aggregation, - auth/cloudflare recovery, implicit resume, stream audit, and persisted dirty - quota transitions still need migration. The HTTP 429 cooldown persistence - path was closed later in Slice 18. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 18: Persisted Quota Cooldown Transition - -Status: implemented and targeted verification passed. - -Changes: - -- Extended the existing Chat rate-limit fallback regression test to assert that - the first account is persisted as quota-limited after an upstream HTTP `429`: - `quota_limit_reached = 1` and `quota_cooldown_until` is a future timestamp. -- Added `AccountStore::mark_quota_limited_until()` to the core account-store - port so runtime dispatch can persist quota state without depending on a - concrete SQLite adapter. -- Implemented the new port in `SqliteAccountStore`, updating - `quota_limit_reached`, clearing `quota_verify_required`, writing - `quota_cooldown_until`, and refreshing `updated_at`. -- Updated `RuntimeAccountPoolService::mark_quota_limited_until()` to write the - quota cooldown to storage and still update the in-memory account pool. Storage - errors are logged and do not prevent the current request from retrying another - account. -- Updated the runtime task fake account store for the expanded port. - -TDD evidence: - -- Before the implementation, the targeted test failed as expected at the new - SQLite assertion with `quota_limit_reached` still `0`. -- After the implementation, the same test passed. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- HTTP 429 cooldown persistence is restored for the shared runtime fallback - path used by non-streaming Chat and Responses dispatch, but fallback/recovery - remains incomplete: streaming Responses fallback, 5xx same-account retry, - exhausted-account error aggregation, auth/cloudflare recovery, implicit - resume, and stream audit still need migration. The non-streaming Responses - 5xx same-account retry path was closed later in Slice 19. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 19: Responses 5xx Same-Account Retry - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old upstream fallback behavior test proving non-streaming - `/v1/responses` retries the same account after transient upstream HTTP 5xx - failures before falling back to another account. -- Added a Responses-specific runtime helper that retries the selected account - for upstream HTTP 5xx responses up to two additional attempts. -- Kept HTTP 429 handling on the existing quota-cooldown fallback path; 429 still - marks the account quota-limited and acquires the next eligible account. -- Kept Chat dispatch unchanged in this slice; the migrated old behavior covered - non-streaming Responses. - -TDD evidence: - -- Before implementation, the migrated test failed with HTTP `502` and only one - request to the primary account. -- After implementation, the same test passed and confirmed three primary - requests with zero secondary requests. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_retry_same_account_after_5xx_before_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Fallback/recovery remains incomplete: streaming Responses fallback, streaming - 5xx retry, exhausted-account error aggregation, auth/cloudflare recovery, - implicit resume, and stream audit still need migration. -- Persisted per-request usage for selected accounts is not yet restored. - **Closed for request_count in Slice 20; token deltas remain incomplete.** -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 20: Persist Selected-Account Request Count - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old fallback test expectation that an account selected for a - failed non-streaming `/v1/responses` request still records one persisted - request in `account_usage`. -- Added `AccountStore::record_usage_delta()` plus a default - `record_request()` helper to the core account-store port so runtime account - acquisition can persist request-count usage through the same abstraction used - by the account pool. -- Implemented `record_usage_delta()` in `SqliteAccountStore` using the existing - `record_usage(..., UsageDelta::default())` semantics for request-count usage. -- Updated `RuntimeAccountPoolService::acquire_with()` and `acquire()` so every - selected account records one persisted external request. Persistence failures - are logged and do not block dispatch. -- Updated the runtime fake account store for the expanded port. - -TDD evidence: - -- Before implementation, the migrated test failed with `request_count = 0`. -- After implementation, the same test passed with `request_count = 1` and three - upstream attempts on the selected account. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_request_count_when_5xx_retries_are_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-runtime --test account_pool_restore` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Request-count persistence is restored for selected accounts, including failed - requests and multi-account fallback attempts, but persisted token deltas from - successful Responses/Chat completions are still incomplete. **Closed for - non-streaming Responses in Slice 21; Chat and streaming paths remain - incomplete.** -- The old exhausted-account HTTP/status/error aggregation shape is still not - restored; current OpenAI upstream failures still map through the current 502 - handler. -- Fallback/recovery remains incomplete: streaming Responses fallback, streaming - 5xx retry, auth/cloudflare recovery, implicit resume, and stream audit still - need migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 21: Persist Responses Token Usage Delta - -Status: implemented and targeted verification passed. - -Changes: - -- Extended the migrated Responses HTTP 429 fallback test with the old usage - assertions: - - the primary 429 account records one request and zero tokens; - - the successful secondary account records one request plus the completed - response input/output token usage. -- Extended `core::accounts::usage::AccountUsageDelta` with cached and image - token fields so runtime can pass token deltas through a core-owned type. -- Reworked SQLite `UsageDelta` and `RECORD_USAGE_SQL` so `request_count` is a - bound delta rather than a fixed `1`; this allows token-only updates with - `request_count = 0`. -- Added conversion from core `AccountUsageDelta` into SQLite `UsageDelta` with - saturating `u64 -> i64` conversion. -- Updated `RuntimeAccountPoolService` to persist successful Responses token - usage after `response.completed` is parsed and to sync the in-memory account - pool window token counters. - -TDD evidence: - -- Before implementation, the extended 429 fallback test failed with the - secondary account usage row `(1, 0, 0)` instead of `(1, 5, 2)`. -- After implementation, the same test passed. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-adapters --test account_repository` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Persisted token usage is restored for non-streaming Responses completion - paths, but Chat completion token persistence and streaming Responses token - persistence remain incomplete. **Chat completion token persistence closed in - Slice 22.** -- Empty-response counters, image request success/failure counters, and old - exhausted-account HTTP/status/error aggregation are still not restored. -- Fallback/recovery remains incomplete: streaming Responses fallback, streaming - 5xx retry, auth/cloudflare recovery, implicit resume, and stream audit still - need migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 22: Persist Chat Token Usage Delta - -Status: implemented and targeted verification passed. - -Changes: - -- Extended the Chat Completions upstream regression test to assert that a - successful `/v1/chat/completions` request persists `account_usage` with one - request plus the completed response input/output token usage. -- Updated `ChatDispatchService` to keep the selected account ID from the - successful dispatch attempt and call the shared - `RuntimeAccountPoolService::record_token_usage()` after the Chat SSE response - is parsed successfully. -- Kept token persistence after successful SSE parsing so invalid or empty - upstream responses do not record completed token deltas. - -TDD evidence: - -- Before implementation, the extended Chat test failed with persisted usage - `(1, 0, 0)` instead of `(1, 9, 3)`. -- After implementation, the same test passed. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_dispatch_to_codex_and_return_openai_response` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Persisted token usage is restored for non-streaming Responses and Chat - completion paths, but streaming Responses token persistence remains - incomplete. -- Empty-response counters, image request success/failure counters, and old - exhausted-account HTTP/status/error aggregation are still not restored. -- Fallback/recovery remains incomplete: streaming Responses fallback, streaming - 5xx retry, auth/cloudflare recovery, implicit resume, and stream audit still - need migration. Non-streaming Responses HTTP 402 quota fallback was closed in - Slice 23. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 23: Responses 402 Quota Exhausted Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old upstream fallback behavior proving non-streaming - `/v1/responses` treats upstream HTTP `402` as quota exhaustion for the - selected account, marks that account `quota_exhausted`, and retries the next - available account. -- Added `AccountStore::set_status()` to the core account-store port. -- Implemented the new status port in `SqliteAccountStore` by reusing the - existing status update path. -- Added `RuntimeAccountPoolService::set_status()` to persist status changes and - update the in-memory account pool, logging persistence failures without - panicking. -- Updated Responses dispatch to handle upstream HTTP `402` by marking the - selected account `QuotaExhausted`, excluding it, and continuing the fallback - acquire loop. - -TDD evidence: - -- Before implementation, the migrated 402 fallback test failed with HTTP `502` - instead of retrying the secondary account. -- After implementation, the same test passed and confirmed the primary account - was persisted as `quota_exhausted` with one failed request recorded. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_mark_quota_exhausted_after_402_and_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-adapters --test account_repository` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Non-streaming Responses HTTP 402 quota fallback is restored, but SSE - `response.failed` quota classification, streaming quota fallback, and no - fallback exhausted-account error aggregation remain incomplete. Non-streaming - SSE quota failure fallback was closed in Slice 24. -- Auth/cloudflare recovery, implicit resume, and stream audit still need - migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 24: Responses SSE Quota Failure Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old non-streaming Responses SSE failure behavior proving - `response.failed` with a quota error marks the selected account - `quota_exhausted` and retries the next available account. -- Moved Responses SSE collection into the per-account attempt loop so fallback - decisions can inspect `CollectedResponse::Failed` before the dispatch result - is finalized. -- Added quota-failure classification for SSE failures using known upstream - codes (`quota_exceeded`, `insufficient_quota`) and quota-bearing messages. -- Preserved the existing error path for non-quota SSE failures by breaking out - of the attempt loop and returning `ResponseDispatchError::Failed`. - -TDD evidence: - -- Before implementation, the migrated SSE quota failure test failed with HTTP - `502` instead of retrying the secondary account. -- After implementation, the same test passed and confirmed the primary account - was persisted as `quota_exhausted`. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_classify_sse_quota_failure_and_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Non-streaming Responses HTTP 402 and SSE quota failure fallback are restored, - but streaming quota fallback and no-fallback exhausted-account error - aggregation remain incomplete. -- Chat 402 quota fallback remains incomplete. **Closed for multi-account HTTP - 402 fallback in Slice 25; no-fallback aggregation remains incomplete.** -- Auth/cloudflare recovery, implicit resume, and stream audit still need - migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 25: Chat 402 Quota Exhausted Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Added Chat Completions coverage proving a primary account that returns - upstream HTTP `402` is marked `quota_exhausted` and excluded before retrying - the next restored runtime-pool account. -- Reused the runtime status migration added for Responses quota fallback, so - Chat and Responses persist and update the in-memory account pool consistently. -- Kept this slice scoped to multi-account fallback; old no-fallback Chat quota - error aggregation remains open. - -TDD evidence: - -- Before implementation, the new Chat 402 fallback test failed with HTTP `502`. -- After implementation, the same test passed and confirmed the primary account - was persisted as `quota_exhausted`. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_mark_quota_exhausted_after_402_and_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Multi-account Chat and non-streaming Responses quota fallback paths are - restored for HTTP 402, but no-fallback exhausted-account error aggregation is - still incomplete. Chat HTTP 402 no-fallback aggregation was closed in Slice - 26. -- Streaming quota fallback, auth/cloudflare recovery, implicit resume, and - stream audit still need migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 26: Chat 402 No-Fallback Quota Error - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old Chat no-fallback quota behavior for a single account that - returns upstream HTTP `402`. -- Added `ChatDispatchError::QuotaExhausted` so dispatch can preserve the number - of quota-exhausted accounts and the last upstream error body instead of - collapsing the result to generic `NoActiveAccount`. -- Updated the Chat dispatch acquire loop to return the quota-exhausted aggregate - once all retried accounts have been excluded by quota exhaustion. -- Mapped the new Chat error to an OpenAI-compatible HTTP `402 Payment Required` - response with the old `All accounts exhausted (N quota-exhausted)` wording - and `upstream_error` code. - -TDD evidence: - -- Before implementation, the migrated test failed with HTTP `503` instead of - `402`. -- After implementation, the same test passed and confirmed the account was - persisted as `quota_exhausted`. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_return_quota_error_when_402_fallback_is_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Chat HTTP 402 no-fallback aggregation is restored, but analogous Responses - no-fallback quota aggregation remains incomplete. **Closed for Responses HTTP - 402 in Slice 27; streaming/SSE no-fallback aggregation remains incomplete.** -- Streaming quota fallback, auth/cloudflare recovery, implicit resume, and - stream audit still need migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 27: Responses 402 No-Fallback Quota Error - -Status: implemented and targeted verification passed. - -Changes: - -- Added non-streaming Responses coverage for a single account that returns - upstream HTTP `402` with no fallback account available. -- Added `ResponseDispatchError::QuotaExhausted`, mirroring the Chat aggregate - error, to preserve the quota-exhausted account count and last upstream error - body. -- Updated the Responses dispatch acquire loop to return the quota aggregate - after all candidates are excluded by HTTP 402 or quota-classified SSE - failures. -- Mapped the new Responses error to HTTP `402 Payment Required` with the old - `All accounts exhausted (N quota-exhausted)` message and `upstream_error` - code. - -TDD evidence: - -- Before implementation, the migrated Responses no-fallback test failed with - HTTP `503` instead of `402`. -- After implementation, the same test passed and confirmed the account was - persisted as `quota_exhausted`. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_quota_error_when_402_fallback_is_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Chat and non-streaming Responses HTTP 402 no-fallback aggregation are - restored, but streaming quota fallback and SSE no-fallback aggregation remain - incomplete. -- Chat HTTP 429 no-fallback aggregation remains incomplete. **Closed in Slice - 28; analogous Responses 429 no-fallback aggregation remains incomplete.** -- Auth/cloudflare recovery, implicit resume, and stream audit still need - migration. -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 28: Chat 429 No-Fallback Rate-Limit Error - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old Chat no-fallback rate-limit behavior for a single account - that returns upstream HTTP `429`. -- Added `ChatDispatchError::RateLimited` so Chat dispatch can preserve the count - of rate-limited accounts and the last upstream error body instead of - collapsing the result to generic `NoActiveAccount`. -- Updated the Chat dispatch loop to return the rate-limited aggregate after all - candidates are excluded by HTTP 429 cooldown. -- Mapped the new Chat error to an OpenAI-compatible HTTP `429 Too Many - Requests` response with the old `All accounts exhausted (N rate-limited)` - wording and `upstream_error` code. -- The test also asserts the account's persisted quota cooldown state is updated. - -TDD evidence: - -- Before implementation, the migrated test failed with HTTP `503` instead of - `429`. -- After implementation, the same test passed and confirmed the account was - marked quota-limited with a cooldown timestamp. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_return_rate_limit_error_when_429_fallback_is_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Chat HTTP 429 no-fallback aggregation is restored, but analogous Responses - HTTP 429 no-fallback aggregation remains incomplete. **Closed in Slice 29.** -- Streaming quota/rate-limit fallback, auth/cloudflare recovery, implicit - resume, and stream audit still need migration. **The buffered HTTP SSE - `stream: true` fallback/usage path is partially restored in Slice 30; live - chunk-by-chunk streaming, WebSocket stream parity, and stream audit still - remain.** -- Token refresh still lacks the old per-account timer, in-flight, retry, and - crash-recovery behavior. -- Old quota edge-case tests still need migration: inactive account route - assertions, quota persistence failure behavior, and quota warning edge cases - beyond the current cached-warning test. - -### 2026-06-18 Slice 29: Responses 429 No-Fallback Rate-Limit Error - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated the old Responses no-fallback rate-limit behavior for a single - account that returns upstream HTTP `429`. -- Added `ResponseDispatchError::RateLimited`, mirroring the Chat aggregate - error restored in Slice 28. -- Updated `ResponseDispatchService::complete()` to count HTTP 429 accounts, - preserve the last upstream error body, and return the rate-limit aggregate - after all candidates are excluded by cooldown. -- Mapped the new Responses error to an OpenAI-compatible HTTP `429 Too Many - Requests` response with the old `All accounts exhausted (N rate-limited)` - wording and `upstream_error` code. -- The regression test also asserts the account is persisted as quota-limited - with a cooldown timestamp. - -TDD evidence: - -- Before implementation, - `responses_should_return_rate_limit_error_when_429_fallback_is_exhausted` - failed with HTTP `503` instead of `429`. -- After implementation, the same test passed and confirmed the persisted - cooldown state. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_rate_limit_error_when_429_fallback_is_exhausted` -- `codegraph sync .` - -Remaining after this slice: - -- Chat and Responses HTTP 429 no-fallback aggregation are restored for - non-streaming paths. -- Buffered HTTP SSE `stream: true` fallback and usage persistence still needed - restoration. **Partially closed in Slice 30.** -- Auth/cloudflare recovery, implicit resume, live stream audit, WebSocket stream - parity, and token-refresh old parity still need migration. - -### 2026-06-18 Slice 30: Buffered Responses Stream Fallback And Usage - -Status: implemented and targeted verification passed. - -Changes: - -- Added a `ResponseDispatchService::stream()` path for OpenAI - `/v1/responses` requests with `stream: true`. -- The stream path now returns `text/event-stream` instead of collecting Codex - SSE into a JSON Responses object. -- Restored the HTTP SSE fallback behaviors covered by the migrated tests: - - completed SSE usage is persisted to `account_usage`; - - upstream HTTP `429` marks the first account quota-limited, excludes it, and - retries the next account; - - transient upstream HTTP 5xx responses retry the same account before - falling back; - - upstream HTTP `402` marks the first account `QuotaExhausted`, excludes it, - and retries the next account. -- The stream path also classifies upstream SSE `response.failed` quota failures - for fallback using the same collector already restored for non-streaming - Responses. -- Added server-side SSE error encoding for dispatch errors on `stream: true`. - -Scope note: - -- This is a buffered HTTP SSE compatibility path: the proxy returns the Codex - SSE body with the correct OpenAI streaming content type after the upstream - response has been read. The old live chunk-by-chunk forwarding, - premature-close handling, heartbeat behavior, tuple stream reconversion, - WebSocket streaming, and stream audit still need separate migration. - -TDD evidence: - -- The first RED run showed three new stream tests failing because the handler - returned JSON instead of `text/event-stream`; the 5xx test was then tightened - because it had passed by only checking the JSON body. -- The corrected RED run failed all four stream tests on the missing - `text/event-stream` response type. -- After implementation, all four tests passed: - `responses_stream_should_proxy_sse_and_record_usage`, - `responses_stream_should_fallback_to_next_account_after_rate_limit`, - `responses_stream_should_retry_same_account_after_5xx_before_fallback`, and - `responses_stream_should_mark_quota_exhausted_after_402_and_fallback`. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Buffered HTTP SSE `stream: true` fallback and usage persistence are partially - restored, but true live streaming and stream audit are still incomplete. -- WebSocket transport/pool streaming parity remains incomplete. -- Stream no-fallback edge cases, auth/cloudflare recovery, implicit resume, - and token-refresh old parity still need migration. **HTTP 401/token-invalid - account recovery is partially restored in Slice 31; Cloudflare/path-block - recovery remains incomplete.** - -### 2026-06-18 Slice 31: Auth Failure Account Recovery - -Status: implemented and targeted verification passed. - -Changes: - -- Restored account-level recovery for upstream HTTP `401` authentication - failures: - - Chat Completions marks the failed account `expired`, excludes it, and - retries the next account. - - non-streaming Responses marks the failed account `expired`, excludes it, - and retries the next account. - - buffered Responses `stream: true` marks the failed account `expired`, - excludes it, and retries the next account. -- Restored non-streaming Responses recovery for upstream SSE - `response.failed` auth/token failures such as `token_invalid`. -- Persisted status changes through `RuntimeAccountPoolService::set_status()`, - so SQLite and the in-memory pool stay consistent. - -TDD evidence: - -- Before implementation, `cargo test ... 401` failed as expected: - Chat and non-streaming Responses returned HTTP `502` instead of falling back, - and the stream case did not produce the fallback account usage row. -- Before implementation, `cargo test ... sse_failed_event` failed with HTTP - `502` instead of falling back from the token-invalid SSE failure. -- After implementation, both targeted filters passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream 401` -- `cargo test -p codex-proxy-server --test openai_chat_upstream sse_failed_event` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- HTTP 401 and token-invalid SSE account recovery are restored for the covered - Chat/Responses paths. -- Cloudflare/path-block recovery, auth no-fallback aggregate/error parity, - live stream audit, WebSocket stream parity, implicit resume, and token-refresh - old parity still need migration. **Auth no-fallback aggregate/error parity is - restored for the covered Chat/Responses paths in Slice 32.** - -### 2026-06-18 Slice 32: Auth No-Fallback Expired Aggregation - -Status: implemented and targeted verification passed. - -Changes: - -- Added expired-account aggregate errors to Chat and Responses dispatch: - `ChatDispatchError::Expired` and `ResponseDispatchError::Expired`. -- Dispatch now counts HTTP `401`/token-invalid accounts, preserves the last - upstream error body, and returns an expired aggregate after all candidates are - excluded. -- Mapped non-streaming Chat and Responses expired aggregates to HTTP `401 - Unauthorized` with `All accounts exhausted (N expired)` wording and - `upstream_error` code. -- Mapped Responses `stream: true` expired aggregates to a `response.failed` - SSE event with authentication error semantics. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream auth_error_when_401` - failed for all three new tests: - - Chat and non-streaming Responses returned HTTP `503` instead of `401`. - - Responses `stream: true` returned a no-active stream error instead of the - expired aggregate. -- After implementation, the same filter passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream auth_error_when_401` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- HTTP 401/token-invalid fallback and no-fallback aggregation are restored for - the covered Chat/Responses paths. -- Cloudflare/path-block recovery, banned/deactivated account status edge cases, - live stream audit, WebSocket stream parity, implicit resume, and token-refresh - old parity still need migration. **HTTP 401 deactivated/banned status - classification is restored in Slice 33.** - -### 2026-06-18 Slice 33: Deactivated Auth Marks Banned - -Status: implemented and targeted verification passed. - -Changes: - -- Added auth failure account-status classification for upstream HTTP `401` - bodies and SSE auth failures. -- Auth failures containing `account_deactivated`, `account deactivated`, - `account has been deactivated`, `deactivated`, or `banned` now set - `AccountStatus::Banned`; other token auth failures still set - `AccountStatus::Expired`. -- Covered both Chat Completions and non-streaming Responses HTTP `401` - deactivation bodies. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream deactivated` - failed because both accounts were persisted as `expired` instead of `banned`. -- After implementation, the same filter passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream deactivated` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- HTTP 401 token-invalid/deactivated fallback and no-fallback status handling - are restored for the covered Chat/Responses paths. -- Cloudflare/path-block recovery, live stream audit, WebSocket stream parity, - implicit resume, and token-refresh old parity still need migration. - -### 2026-06-18 Slice 34: HTTP 403 Banned Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Restored account-level fallback for upstream HTTP `403` bodies that signal a - banned or deactivated account. -- Chat Completions, non-streaming Responses, and buffered Responses - `stream: true` now mark the failed account `banned`, exclude it, and retry - the next eligible account. -- HTTP `403` bodies without a banned/deactivated signal still use the normal - upstream-error path; Cloudflare challenge/path-block recovery remains a - separate migration gap. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream after_403` - failed because Chat and non-streaming Responses returned HTTP `502` instead - of falling back, and the buffered stream response did not contain the fallback - account response id. -- After implementation, the same filter passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream after_403` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- HTTP 401 token-invalid/deactivated and HTTP 403 banned/deactivated account - fallback are restored for the covered Chat/Responses paths. -- Cloudflare challenge/path-block recovery, live stream audit, WebSocket stream - parity, implicit resume, and token-refresh old parity still need migration. - -### 2026-06-18 Slice 35: Cloudflare Challenge And Path-Block Recovery - -Status: implemented and targeted verification passed. - -Changes: - -- Added `core::accounts::cloudflare::CloudflarePathBlockTracker` for pure - per-account path-block counting, including the old 3-hit disable threshold - and 1-hour stale counter window. -- Extended `AccountStore` and `SqliteAccountStore` with - `set_cloudflare_cooldown_until()` so runtime can persist Cloudflare - cooldowns without reaching around the port boundary. -- Added a shared runtime Cloudflare recovery helper used by Chat Completions, - non-streaming Responses, and buffered Responses `stream: true`: - - reads stored `chatgpt.com` cookies into upstream Codex requests; - - clears the failing account's cookies after Cloudflare challenge/path-block; - - sets `cloudflare_cooldown_until` after HTTP `403` Cloudflare challenge; - - counts HTTP `404` empty-body path-blocks and disables the account after - three recent hits; - - excludes the failed account and retries the next eligible account. -- Added Cloudflare aggregate dispatch errors and mapped them to HTTP `502` - JSON errors for non-streaming requests and `response.failed` SSE events for - buffered streaming requests. -- Updated the exact architecture source whitelist for - `crates/core/src/accounts/cloudflare.rs`. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` - failed 7/7 newly added tests: fallback cases returned HTTP `502`, cooldowns - were not written, cookies were not cleared, path-block errors were generic, - and the 3-hit disabled transition did not occur. -- After implementation, the same filter passed 7/7. -- The first post-implementation architecture run failed because - `crates/core/src/accounts/cloudflare.rs` was not yet listed in the exact - architecture shape; `docs/architecture.md` and the architecture test - whitelist were updated, then the architecture suite passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` - (RED before implementation; failed 7/7 as expected) -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` -- `cargo fmt --all` -- `cargo test -p codex-proxy-core cloudflare` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` (failed once on the new source whitelist) -- `cargo test --test architecture` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` -- `cargo test -p codex-proxy-core cloudflare` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Cloudflare challenge/path-block recovery is restored for the covered - non-streaming Chat, non-streaming Responses, and buffered Responses - `stream: true` paths. -- The old live chunk-by-chunk stream behavior, WebSocket stream parity, stream - audit, implicit resume, and token-refresh old parity still need migration. - -### 2026-06-18 Slice 36: Responses Model-Unsupported Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Restored Responses account fallback for model-plan incompatibility signals: - - upstream HTTP `400` bodies with `model_not_supported`, - `model_not_available`, `not supported`, or `not available`; - - upstream SSE `response.failed` events with the same model unsupported - signals. -- Non-streaming Responses and buffered Responses `stream: true` now exclude the - failed account and retry the next account once without changing account - status. -- Added model-unsupported aggregate errors for exhausted fallback: - non-streaming requests map to HTTP `400`, and buffered streaming requests map - to a `response.failed` SSE event. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` - failed 4/4 newly added tests: HTTP model unsupported returned `502`, SSE - model unsupported did not retry the next account, buffered stream did not - contain the fallback response id, and exhausted fallback returned a generic - stream error. -- After implementation, the same filter passed 4/4. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` - (RED before implementation; failed 4/4 as expected) -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Responses model-unsupported fallback is restored for the covered - non-streaming and buffered `stream: true` paths. -- Chat model-unsupported fallback, implicit resume/history stripping, live - stream audit, WebSocket stream parity, and token-refresh old parity still - need migration. - -### 2026-06-18 Slice 37: Responses History Recovery - -Status: implemented and targeted verification passed. - -Changes: - -- Added `turnState` / `turn_state` parsing to OpenAI Responses requests and - propagation to the Codex `x-codex-turn-state` upstream header. -- Restored one-shot Responses request history recovery for upstream SSE - `response.failed` signals: - - `previous_response_not_found`; - - messages containing `Previous response ... not found`; - - messages containing `No tool output found for function call`. -- On recovery, runtime clears `previous_response_id`, `turn_state`, - `turn_metadata`, and non-explicit `prompt_cache_key`, then retries the request - once instead of switching accounts or changing account status. -- Covered non-streaming Responses and buffered Responses `stream: true`. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` - failed 3/3 newly added tests because only one upstream request was sent. -- After implementation and fixing the same-account WireMock fixtures to use the - old `up_to_n_times(1)` pattern, the same filter passed 3/3. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` - (RED before implementation; failed 3/3 as expected) -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` -- `cargo test -p codex-proxy-core --test protocol openai_response_request_should_translate_to_codex_request` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Remaining after this slice: - -- Responses history recovery is restored for the covered non-streaming and - buffered `stream: true` SSE failure paths. -- HTTP upstream history errors, invalid encrypted reasoning replay recovery, - Chat model-unsupported fallback, live stream audit, WebSocket stream parity, - and token-refresh old parity still need migration. - -### 2026-06-18 Slice 38: Chat Model Fallback And Invalid Reasoning Recovery - -Status: implemented and targeted verification passed. - -Changes: - -- Restored Chat Completions account fallback for upstream HTTP model-plan - incompatibility signals such as `model_not_supported`, - `model_not_available`, `not supported`, and `not available`. -- Chat model-unsupported fallback now excludes the failed account, retries one - alternate account once, preserves the failed account status, and maps - exhausted fallback to OpenAI-style HTTP `400`. -- Extended Responses history recovery to recognize invalid encrypted reasoning - replay signals: - - upstream HTTP error bodies containing `invalid_encrypted_content`; - - equivalent `invalid` + `encrypted` + `content` text; - - upstream SSE `response.failed` events carrying the same signals. -- Invalid encrypted reasoning replay now bypasses generic same-account 5xx - retry, clears `previous_response_id`, `turn_state`, `turn_metadata`, and - non-explicit `prompt_cache_key`, then retries the stripped request once. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` - failed the 2 newly added Chat tests: fallback returned `502` instead of - retrying, and exhausted fallback returned `502` instead of HTTP `400`. -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream invalid_encrypted_reasoning` - failed 3/3 newly added tests: SSE paths sent only one upstream request, and - the HTTP 503 path retried without stripping `x-codex-turn-state`. -- After implementation, both filters passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` - (RED before implementation; failed the 2 new Chat tests as expected) -- `cargo test -p codex-proxy-server --test openai_chat_upstream invalid_encrypted_reasoning` - (RED before implementation; failed 3/3 as expected) -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` -- `cargo test -p codex-proxy-server --test openai_chat_upstream invalid_encrypted_reasoning` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo fmt --all -- --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 14 changed files; index up to date with 274 files, 3,733 nodes, and - 10,666 edges before this documentation-only update) -- `cargo test --test architecture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Chat HTTP model-unsupported fallback is restored for the covered - non-streaming Chat path. -- Responses invalid encrypted reasoning replay recovery is restored for covered - HTTP error, non-streaming SSE failure, and buffered `stream: true` SSE failure - paths. -- Live stream audit, WebSocket stream parity, implicit resume/reasoning replay - cache parity, and token-refresh old parity still need migration. - -### 2026-06-19 Slice 39: Token Refresh Lease Coordination - -Status: implemented and targeted verification passed. - -Changes: - -- Implemented `SqliteRefreshLeaseStore` behavior on top of the existing - `account_refresh_leases` table: - - acquire creates a lease when absent; - - acquire is blocked while another owner holds an unexpired lease; - - acquire succeeds after expiry or for the same owner; - - release only succeeds for the current owner. -- Added the refresh lease store to runtime repository assembly and - `BackgroundTaskStores`. -- Wired the background token refresh task to use refresh leases before invoking - the upstream refresher. -- Token refresh scans now skip a due account when another owner holds a live - lease, and do not call the refresher for that account. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test refresh_leases` failed to compile - because `SqliteRefreshLeaseStore` had no `try_acquire` or `release` methods. -- Before implementation, - `cargo test -p codex-proxy-runtime --test token_refresh` failed to compile - because the lease store methods and `TokenRefreshTask::with_refresh_lease_store` - did not exist. -- After implementation, both filters passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test refresh_leases` - (RED before implementation; missing lease API as expected) -- `cargo test -p codex-proxy-runtime --test token_refresh` - (RED before implementation; missing lease API/task wiring as expected) -- `cargo test -p codex-proxy-adapters --test refresh_leases` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-adapters --test refresh_leases` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Token refresh now uses the SQLite refresh lease table for cross-process - single-owner coordination on scanned accounts. -- Token refresh still lacks the old per-account timer map, explicit in-flight - tracking, scheduled `refreshing` crash recovery, exponential retry loop, and - delayed recovery scheduling. -- Live stream audit, WebSocket stream parity, and implicit resume/reasoning - replay cache parity still need migration. - -### 2026-06-19 Slice 40: Token Refresh Refreshing Recovery - -Status: implemented and targeted verification passed. - -Changes: - -- Token refresh scans now distinguish refresh candidates before taking a - refresh lease, so non-due accounts are skipped without being marked - `refreshing`. -- Due active accounts are persisted as `refreshing` before invoking the - upstream token refresher, matching the old scheduled refresh state transition. -- Accounts found persisted as `refreshing` are treated as restart/crash - recovery candidates and refreshed immediately even when their access token is - not inside the normal pre-expiry margin. -- Transport failures during a refresh attempt now restore the persisted account - status to `active` instead of leaving the account stuck in `refreshing`. -- Exposed a read-only core `RefreshScheduler::should_refresh_account_at` helper - so runtime can reuse the domain refresh policy before it mutates persisted - state. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-runtime --test token_refresh` failed the 3 newly - added tests: - - a due active account was still observed as `Active` when the refresher ran; - - a persisted `Refreshing` account was skipped with `summary.refreshed = 0`; - - a transport-failure attempt was still observed as `Active` instead of - `Refreshing`. -- After implementation, the same test target passed 5/5. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test token_refresh` - (RED before implementation; failed 3/5 as expected) -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Token refresh now covers SQLite lease coordination, refresh-before-call - `refreshing` persistence, restart/crash recovery from persisted `refreshing`, - and active restoration after a transport failure. -- Token refresh still lacks the old per-account timer map, explicit in-flight - tracking, exponential retry loop, and delayed recovery scheduling. -- Live stream audit, WebSocket stream parity, and implicit resume/reasoning - replay cache parity still need migration. - -### 2026-06-19 Slice 41: Token Refresh Retry Confirmation - -Status: implemented and targeted verification passed. - -Changes: - -- Added token-refresh retry delays with production defaults matching the old - scheduler's five-attempt exponential retry shape: - `5s`, `15s`, `45s`, and `135s` between attempts, capped at `300s`. -- Added a configurable `TokenRefreshTask::with_retry_delays(...)` hook so - tests can run the retry loop with zero delays without pausing Tokio's global - clock. -- Transport failures now retry before the account is restored to `active` and - counted as failed. -- A transient transport failure followed by a successful refresh now persists - the refreshed token and reports the account as refreshed. -- Permanent refresh failures represented by terminal account statuses are now - confirmed twice before the final status is persisted, matching the old - invalid-grant/permanent-failure confirmation behavior. -- Accounts without a refresh token still skip the retry loop and are marked - `expired` immediately. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-runtime --test token_refresh` first failed because - the new tests needed a missing `TokenRefreshTask::with_retry_delays(...)` - hook. -- Before adding the retry implementation, the behavior expectations were also - missing: transport failure made only one refresher call, transient success - after a retry was not reached, and invalid-grant was persisted after one hit - instead of two. -- After implementation, the same test target passed 7/7. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test token_refresh` - (RED before implementation; missing retry-delay hook as expected) -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Token refresh now covers SQLite lease coordination, refresh-before-call - `refreshing` persistence, restart/crash recovery from persisted `refreshing`, - active restoration after retry exhaustion, transient retry success, and - two-hit permanent failure confirmation. -- Token refresh still lacks the old per-account timer map, explicit in-flight - tracking, and delayed recovery scheduling after retry exhaustion. -- Live stream audit, WebSocket stream parity, and implicit resume/reasoning - replay cache parity still need migration. - -### 2026-06-19 Slice 42: Token Refresh In-Flight Guard - -Status: implemented and targeted verification passed. - -Changes: - -- Added process-local in-flight tracking to `TokenRefreshTask`. -- Concurrent scans for the same account now skip the duplicate in-process - refresh attempt instead of treating the persisted `refreshing` status as a - crash recovery signal while the first refresh is still running. -- The in-flight guard is acquired before the SQLite refresh lease, so duplicate - scans from the same task owner cannot acquire and then release another - in-process refresh attempt's lease. -- The in-flight marker is released on lease-skip, lease-error, refresh success, - refresh failure, and status-update paths. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_skip_duplicate_in_flight_refresh` - failed because the second concurrent scan blocked inside the refresher instead - of returning a skipped summary. -- After implementation, the filtered test passed, and the full token-refresh - test target passed 8/8. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_skip_duplicate_in_flight_refresh` - (RED before implementation; duplicate refresh waited in the refresher) -- `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_skip_duplicate_in_flight_refresh` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Token refresh now covers SQLite lease coordination, process-local in-flight - protection, refresh-before-call `refreshing` persistence, restart/crash - recovery from persisted `refreshing`, retry exhaustion restoration, - transient retry success, and two-hit permanent failure confirmation. -- Token refresh still lacks the old per-account timer map and delayed recovery - scheduling after retry exhaustion. -- Live stream audit, WebSocket stream parity, and implicit resume/reasoning - replay cache parity still need migration. - -### 2026-06-19 Slice 43: Token Refresh Delayed Recovery - -Status: implemented and targeted verification passed. - -Changes: - -- Added process-local delayed recovery tracking for token refresh retry - exhaustion. -- After all transport retry attempts are exhausted, the task restores the - account to `active`, records a 10-minute recovery window, and skips scans for - that account until the window expires. -- Once the recovery window expires, the next scan clears the delay and attempts - refresh again. -- Successful refreshes, terminal status updates, and missing-refresh-token - expiry clear any pending delayed recovery entry for that account. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_delay_recovery_after_retry_exhaustion` - failed because a scan 5 minutes after retry exhaustion did not skip the - account. -- After implementation, the filtered test passed, and the full token-refresh - test target passed 9/9. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_delay_recovery_after_retry_exhaustion` - (RED before implementation; recovery-window skip missing) -- `cargo test -p codex-proxy-runtime --test token_refresh token_refresh_task_should_delay_recovery_after_retry_exhaustion` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Token refresh now covers SQLite lease coordination, process-local in-flight - protection, refresh-before-call `refreshing` persistence, restart/crash - recovery from persisted `refreshing`, retry exhaustion delayed recovery, - transient retry success, and two-hit permanent failure confirmation. -- Token refresh still lacks the old per-account timer map; the current - implementation remains scan-driven rather than scheduling one timer per - account at `exp - margin`. -- Live stream audit, WebSocket stream parity, and implicit resume/reasoning - replay cache parity still need migration. - -### 2026-06-19 Slice 44: Responses Stream No-Fallback Coverage - -Status: migrated tests added; existing implementation passed. - -Changes: - -- Added crate-local server tests for Responses `stream: true` no-fallback - exhausted-account aggregation: - - HTTP 429 rate-limit exhaustion returns `text/event-stream` with - `response.failed`, `rate_limit_exceeded`, and the upstream error message; - - HTTP 402 quota exhaustion returns `text/event-stream` with - `response.failed`, the quota-exhausted aggregate message, and the upstream - error message. -- The tests also verify the persisted account side effects: - - HTTP 429 sets quota cooldown state; - - HTTP 402 persists `quota_exhausted`. - -TDD / coverage evidence: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_return_` - passed after adding the migrated tests, showing the behavior was already - restored and the gap was missing crate-local coverage rather than missing - production code. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_return_` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_return_` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Responses stream no-fallback HTTP 429 and HTTP 402 aggregate error coverage is - now migrated. -- True live chunk-by-chunk stream proxying, live stream usage audit, and - WebSocket stream parity still need migration. -- Token refresh still lacks the old per-account timer map. - -### 2026-06-19 Slice 45: Admin Quota Edge-Case Coverage - -Status: migrated tests added; existing implementation passed. - -Changes: - -- Added crate-local server tests for explicit admin quota edge cases from the - old admin account quota suite: - - inactive accounts return HTTP `409` with code `40901` and do not call the - upstream usage endpoint; - - quota persistence failures after a successful upstream usage fetch return - HTTP `500` with code `50001`; - - quota warnings require an admin session cookie; - - invalid cached quota JSON and below-threshold quota snapshots are ignored - without producing warnings or an `updatedAt` timestamp. -- Existing happy-path and upstream-failure quota route coverage remains in the - same crate-local test target. - -TDD / coverage evidence: - -- The first new edge-case run exposed a bad test input: the secondary - `used_percent` was `89`, above the configured `80` warning threshold. After - correcting the fixture to stay below threshold, the new edge-case tests passed. -- No production code changes were needed for these old quota route semantics; - the gap was missing migrated coverage. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota` - (first run failed because the new below-threshold fixture used an above-threshold - secondary quota value) -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test admin_accounts_routes` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota` -- `cargo fmt --all -- --check` -- `git diff --check` - -Remaining after this slice: - -- Explicit admin quota route happy path, upstream failure shape, inactive-account - rejection, persistence-failure response, quota-warning auth, and quota-warning - invalid/below-threshold edge coverage are migrated. -- Broader old quota/task behavior still needs final parity review, especially - quota refresh task and usage/quota helper edge cases outside the explicit - admin route. -- True live chunk-by-chunk stream proxying, live stream usage audit, WebSocket - stream parity, and token refresh per-account timer parity still need migration. - -### 2026-06-19 Slice 46: Fingerprint Runtime Startup Coverage - -Status: migrated tests added; existing implementation passed. - -Changes: - -- Added runtime task coverage proving `start_fingerprint_update_task(...)` - performs the initial appcast check and writes the `auto_update` fingerprint - row through `FingerprintRepository`. -- Added coverage for matching current appcast version/build: the task checks - appcast but does not persist an `auto_update` row when no update is available. -- Added coverage documenting the current `repository: None` semantics: the task - still checks appcast but has no persistence target. - -TDD / coverage evidence: - -- `cargo test -p codex-proxy-runtime --test tasks fingerprint_update_task` - passed after adding the migrated tests, showing the runtime wrapper already - drove the adapter behavior and the gap was missing crate-local coverage. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test tasks fingerprint_update_task` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks fingerprint_update_task` - -Remaining after this slice: - -- Fingerprint update startup/update behavior from the old updater tests is now - covered at adapter and runtime-wrapper levels. -- True live chunk-by-chunk stream proxying, live stream usage audit, WebSocket - stream parity, and token refresh per-account timer parity still need migration. - -### 2026-06-19 Slice 47: Quota Refresh Request Spacing - -Status: implemented and targeted verification passed. - -Changes: - -- Restored the old quota refresh task's request staggering behavior for - multiple quota-locked accounts. -- Added `QuotaRefreshTask::default_request_spacing()` with the old 3-second - default and `with_request_spacing(...)` for fast tests. -- The refresh loop now sleeps between consecutive quota refresh candidates when - the configured spacing is non-zero. - -TDD evidence: - -- Before implementation, `cargo test -p codex-proxy-runtime --test quota_refresh` - failed to compile because `default_request_spacing()` and - `with_request_spacing(...)` did not exist. -- The first implementation still failed the stagger behavior test because it - used `Filter::size_hint()` to detect a remaining candidate; the lower bound - was `0`, so the second request was sent immediately. Switching to - `peekable()` made the test pass. - -Verification run so far: - -- `cargo test -p codex-proxy-runtime --test quota_refresh` - (RED: missing request spacing API) -- `cargo test -p codex-proxy-runtime --test quota_refresh` - (failed once: second request was not delayed) -- `cargo test -p codex-proxy-runtime --test quota_refresh` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test quota_refresh` - -Remaining after this slice: - -- The quota refresh task now covers the old scan interval shape, per-account - minimum refresh window, active quota-locked filtering, usage fetch/persist - path, and inter-account request spacing. -- Token refresh still lacks old per-account timer parity. -- True live chunk-by-chunk stream proxying, live stream usage audit, and - WebSocket stream parity still need migration. - -### 2026-06-19 Slice 48: Assets Static Router And Server Fallback - -Status: implemented and targeted verification passed. - -Changes: - -- Replaced empty `crates/assets` modules with real static asset behavior: - - cache policy helper for SPA HTML vs fingerprinted `/assets/*` files; - - security headers including `x-content-type-options`, `x-frame-options`, - `referrer-policy`, and CSP; - - content type selection for common frontend artifacts; - - Axum SPA router serving `/`, `/assets/{*path}`, and deep-link fallback. -- Added crate-local assets tests for header policy, index serving, SPA fallback, - and cached asset serving. -- Added `codex-proxy-assets` as a server dependency and wired the assets router - as the final fallback behind the OpenAI/admin API routes. -- Added server integration coverage proving `/` and `/assets/*` serve frontend - files while `/api/admin/settings` still resolves to the admin API handler. -- Removed one redundant `serde_json::json!` clone in the Responses stream error - mapping after the final workspace clippy gate exposed it. - -TDD evidence: - -- Before implementation, `cargo test -p codex-proxy-assets` failed because the - assets crate had no `cache_control_for_path(...)` or `spa_router(...)`; it - also exposed a missing `tokio` test dependency. -- After implementing the assets crate, its tests passed. -- Before server wiring, `cargo test -p codex-proxy-server --test assets_routes` - failed because `router_with_assets(...)` did not exist. -- After adding the server dependency and fallback wiring, the server assets - integration test passed. - -Verification run so far: - -- `cargo test -p codex-proxy-assets` (RED: missing API/test dependency) -- `cargo test -p codex-proxy-assets` -- `cargo test -p codex-proxy-server --test assets_routes` - (RED: missing `router_with_assets(...)`) -- `cargo test -p codex-proxy-server --test assets_routes` -- `codegraph sync .` -- `cargo test -p codex-proxy-assets` -- `cargo test -p codex-proxy-server --test assets_routes` -- `cargo fmt --all` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo test -p codex-proxy-runtime --test quota_refresh` -- `cargo test -p codex-proxy-assets` -- `cargo test -p codex-proxy-server --test assets_routes` -- `cargo test -p codex-proxy-server --test admin_settings_routes` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - (failed once on an existing redundant `json!` clone in - `crates/server/src/openai_api/responses.rs`) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `cargo test -p codex-proxy-server --test assets_routes` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo fmt --all -- --check` -- `git diff --check` - -Additional source/empty-folder audit in this slice: - -- `rg 'fn ... {}'` over `crates`, `src`, and `tests` found no empty source - functions beyond the architecture placeholder test's own marker string. -- Placeholder scan for `todo!`, `unimplemented!`, `not wired yet`, - `后续承载`, `placeholder`, and `stub` found only architecture guard/doc - references in Rust sources. -- Empty-directory scan found only `web/node_modules/.vite-temp`, an ignored - frontend dependency cache directory, not a migration leftover. - -Remaining after this slice: - -- `assets` is no longer an empty scaffold and is wired into the server router. -- True live chunk-by-chunk stream proxying, live stream usage audit, WebSocket - stream parity, and token refresh per-account timer parity still need migration. - -### 2026-06-19 Slice 49: Token Refresh Per-Account Timers - -Status: implemented and targeted verification passed. - -Changes: - -- Restored an account-level token refresh timer map in - `crates/runtime/src/tasks/token_refresh.rs`, replacing the scan-only loop for - normal background scheduling. -- The scheduler now computes the planned refresh instant from - `access_token_expires_at`/JWT exp and stores one timer per account. -- Already-due active or persisted `refreshing` accounts refresh immediately - during scheduling, matching the old `schedule_one`/crash-recovery behavior - instead of leaving a zero-delay stale timer behind. -- Timer callbacks use the planned trigger timestamp when asking the core - refresh policy whether an account is due. This preserves deterministic tests - and avoids skipping refresh if wall-clock time moves backward after a timer - was scheduled. -- Successful scheduled refreshes immediately schedule the next per-account - timer from the newly persisted token expiry, restoring the old - `schedule_next_refresh` behavior. -- Existing in-flight, SQLite lease, retry, permanent-failure confirmation, and - delayed-recovery logic remains shared by scan-triggered and timer-triggered - refreshes. - -TDD evidence: - -- Before implementation, `cargo test -p codex-proxy-runtime --test token_refresh timer -- --nocapture` - failed: - - `token_refresh_task_should_fire_per_account_timer_at_refresh_time` timed out - because the timer callback recomputed due-ness from real `Utc::now()` rather - than the planned trigger timestamp. - - `token_refresh_task_should_reschedule_next_timer_after_scheduled_refresh` - failed with `left: 0, right: 1`, proving a successful scheduled refresh did - not leave the next account-level timer installed. -- The first implementation attempt exposed a `Send` future compile error from - the recursive shape `timer -> refresh -> schedule next -> timer`; boxing the - timer scheduling future keeps the recursion out of the concrete async type. -- A second run exposed a JWT-vs-stored-expiry precision issue in the new - sub-second timer test. The scheduler now keeps stored - `access_token_expires_at` as the primary expiry source and only writes the new - JWT exp into the refreshed account snapshot before scheduling the next timer. - -Verification run so far: - -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test token_refresh timer -- --nocapture` - (RED: timed-out due timer and missing next timer) -- `cargo test -p codex-proxy-runtime --test token_refresh timer -- --nocapture` - (compile failed once on mutable `updated`, then once on recursive async - `Send`, then once on JWT/stored-expiry precision) -- `cargo test -p codex-proxy-runtime --test token_refresh timer -- --nocapture` -- `cargo test -p codex-proxy-runtime --test token_refresh -- --nocapture` -- `cargo fmt --all` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - (failed once on `large_enum_variant` for `TokenRefreshOutcome::Refreshed(Account)`) -- `cargo fmt --all && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo test -p codex-proxy-runtime --test quota_refresh` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo fmt --all -- --check` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 2 changed files; index up to date with 271 files, 3,213 nodes, and - 8,855 edges) - -Remaining after this slice: - -- Token refresh now covers the old per-account timer map, immediate due refresh, - scheduled callback due-time semantics, next-refresh scheduling, process-local - in-flight guard, SQLite lease coordination, retry, delayed recovery, and - permanent-failure confirmation behavior. -- True live chunk-by-chunk stream proxying, live stream usage audit, and - WebSocket stream parity still need migration. - -### 2026-06-19 Slice 50: Responses Stream Done Termination - -Status: implemented and targeted verification passed. - -Changes: - -- All Responses `stream: true` bodies emitted by the server now append the - OpenAI compatible `data: [DONE]` SSE terminator when the body does not - already end with one. -- This covers buffered successful upstream SSE bodies and stream errors - produced by the proxy before a live upstream stream exists, such as no active - account, exhausted fallback, invalid upstream response, and other - `ResponseDispatchError` mappings. -- The change does not make the buffered HTTP SSE path a true live - chunk-by-chunk proxy, but it closes a client-visible stream lifecycle gap: - clients can now terminate cleanly after both buffered success and generated - error streams. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_responses_routes generated_stream_errors -- --nocapture` - failed because the route body ended after `event: response.failed` and did - not include `data: [DONE]\n\n`. -- After the first fix, generated stream errors passed. -- A second RED test, - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_proxy_sse_and_record_usage -- --nocapture`, - proved successful buffered upstream SSE bodies also lacked `[DONE]`. -- Moving the terminator normalization into `event_stream_response(...)` made - both generated error streams and successful buffered streams terminate - consistently without duplicating an existing terminator. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_responses_routes generated_stream_errors -- --nocapture` - (RED: missing `[DONE]` terminator) -- `cargo test -p codex-proxy-server --test openai_responses_routes generated_stream_errors -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_responses_routes` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_proxy_sse_and_record_usage -- --nocapture` - (RED: successful buffered stream missing `[DONE]` terminator) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_proxy_sse_and_record_usage -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_responses_routes generated_stream_errors -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync . && codegraph status .` - (synced 2 changed files; index up to date with 271 files, 3,215 nodes, and - 8,879 edges) - -Remaining after this slice: - -- Responses `stream: true` success and generated error bodies now terminate - cleanly. -- True live chunk-by-chunk stream proxying, live stream usage audit, and - WebSocket stream parity still need migration. - -### 2026-06-19 Slice 51: WebSocket Transport Decision And Audit Snapshots - -Status: implemented and targeted verification passed. - -Changes: - -- Restored the old three-state Responses transport decision in - `core::serving::responses`: - - default Responses requests without history are `WebSocketPreferred`; - - requests with `previous_response_id` are `WebSocketRequired`; - - `force_http_sse` forces `HttpSse` and allows fallback. -- Added `http_sse_fallback_allowed(...)` so the no-fallback history path is a - first-class core policy instead of an implicit runtime/server detail. -- Added core coverage proving `use_websocket` and `force_http_sse` remain - internal routing flags and are not serialized into upstream Codex JSON. -- Restored pure WebSocket payload audit snapshots for Responses - `response.create` payloads, including ordered top-level key reporting and - redaction for instructions, input, previous response ID, prompt cache key, - client metadata, and tool definitions. -- Expanded opening audit snapshots to include a request line and redacted - ordered headers while preserving the existing `header_order` compatibility - field. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-core --test protocol codex_responses_transport -- --nocapture` - failed to compile because `CodexTransport`, `transport_for_request(...)`, and - `http_sse_fallback_allowed(...)` did not exist. -- After adding the transport policy, the four transport/serialization tests - passed and the full core protocol test suite passed. -- Before payload audit implementation, - `cargo test -p codex-proxy-core --test protocol codex_websocket_payload -- --nocapture` - failed to compile because `websocket_payload_audit_snapshot(...)` did not - exist. -- After implementing the snapshot builder/redaction, the payload audit test - passed. -- Before opening audit expansion, - `cargo test -p codex-proxy-adapters --test codex websocket_connection_opening_audit -- --nocapture` - failed to compile because `OpeningAuditSnapshot` had no `request_line` or - `headers` fields. -- After expanding the snapshot type and adapter builder, the opening audit test - and existing WebSocket adapter tests passed. - -Verification run so far: - -- `cargo test -p codex-proxy-core --test protocol codex_responses_transport -- --nocapture` - (RED: missing transport policy API) -- `cargo test -p codex-proxy-core --test protocol codex_responses_transport -- --nocapture` -- `cargo test -p codex-proxy-core --test protocol` -- `cargo test -p codex-proxy-core --test protocol codex_websocket_payload -- --nocapture` - (RED: missing payload audit API) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_payload -- --nocapture` -- `cargo test -p codex-proxy-core --test protocol` -- `cargo test -p codex-proxy-adapters --test codex websocket_connection_opening_audit -- --nocapture` - (RED: missing opening audit fields) -- `cargo test -p codex-proxy-adapters --test codex websocket_connection_opening_audit -- --nocapture` -- `cargo test -p codex-proxy-adapters --test codex websocket_` -- `cargo test -p codex-proxy-core --test protocol` -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` -- `cargo test -p codex-proxy-adapters --test codex` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync . && codegraph status .` - (synced 6 changed files; index up to date with 271 files, 3,242 nodes, and - 8,977 edges) - -Remaining after this slice: - -- WebSocket transport selection and audit snapshot primitives are no longer - empty shells. -- Real `tokio-tungstenite` connection/opening bytes, permessage-deflate, - ping/pong, pooled live sockets, live stream proxying, live stream audit, and - WebSocket serving parity still need migration. - -### 2026-06-19 Slice 52: WebSocket Core Codec Diagnostics - -Status: implemented and targeted verification passed. - -Changes: - -- Migrated more of the old WebSocket codec's pure event semantics into - `crates/core/src/protocol/codex/websocket.rs`: - - public WebSocket event to SSE frame conversion for public JSON events; - - `response.metadata` turn-state extraction from case-insensitive - `x-codex-turn-state` headers, including array-valued headers; - - terminal event detection for `response.completed`, `response.failed`, and - `error`; - - upstream WebSocket error-frame classification to HTTP-style status codes - for rate limit, quota, auth, forbidden, bad request, overload, and - WebSocket connection-limit codes; - - retry-after extraction from wrapped WebSocket `error.headers` values; - - `response.completed` shape validation against the old typed usage/end-turn - expectations; - - basic official stream-event shape diagnostics; - - missing-required-field diagnostics for `response.created`, - `response.output_text.delta`, `response.output_item.*`, and message output - items. -- Kept this slice in `core` as pure JSON/protocol logic. It does not introduce - socket IO, TLS, deflate, pooling, filesystem audit writes, or adapter - dependencies. -- Added crate-local protocol tests for the migrated semantics instead of - relying on the deleted root `tests/codex_gateway/websocket.rs` coverage. - -TDD evidence: - -- Before the first implementation batch, - `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - failed to compile because the new metadata, terminal-event, error - classification, wrapped retry-after, and completed-response parse APIs did - not exist. -- After the first implementation batch, the same test command found a real - implementation bug: the `response.completed` usage field was not mapped onto - the private deserialization struct, so the invalid-usage test failed. After - fixing the serde rename, the filtered test passed 8/8. -- Before the second diagnostics batch, the same filtered command failed to - compile because the event-shape and output-item diagnostic APIs did not exist. -- After implementing those diagnostics, the filtered command passed 12/12. - -Verification run so far: - -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (RED: missing first codec API batch) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (GREEN after serde rename fix: 8/8 filtered tests passed) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (RED: missing second diagnostics API batch) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (GREEN: 12/12 filtered tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` - (36/36 tests passed) -- `codegraph sync .` - (synced 2 changed files) -- `codegraph status .` - (index up to date with 271 files, 3,289 nodes, and 9,059 edges) - -Remaining after this slice: - -- WebSocket codec helpers are materially less skeletal, but they are not yet - wired into a live WebSocket response path in the new crate split. -- The remaining old codec diagnostics still need migration, especially - incomplete-response handling, custom/function/tool/local-shell/web-search/ - image-generation/compaction item validators, and reasoning summary index - checks. -- Real `tokio-tungstenite` connection/opening bytes, permessage-deflate, - ping/pong, pooled live sockets, live stream proxying, live stream audit, and - WebSocket serving parity still need migration. - -### 2026-06-19 Slice 53: WebSocket Core Item Validators - -Status: implemented and verification passed for the current batch. - -Changes: - -- Migrated the next old WebSocket codec diagnostics batch into - `crates/core/src/protocol/codex/websocket.rs`: - - `response.incomplete` reason extraction; - - missing `response` detection for `response.completed`; - - official required-field checks for custom-tool, reasoning-summary, and - reasoning-text delta events; - - output-item metadata validation; - - agent-message, reasoning, function-call, function-call-output, - custom-tool-call, custom-tool-call-output, tool-search-call, - tool-search-output, local-shell-call, web-search-call, - image-generation-call, compaction, and reasoning-summary-part validators. -- Added protocol tests covering each migrated validator category with concrete - old-style bad-frame examples. -- Kept all logic in `core` as pure `serde_json::Value` inspection, so adapter - and runtime wiring can reuse it without bringing IO or transport dependencies - into the protocol crate. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - failed to compile because the new validator APIs did not exist. -- After implementation, the same filtered command passed 19/19 WebSocket tests. - -Verification run so far: - -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (RED: missing second validator batch) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (GREEN: 19/19 filtered tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` - (43/43 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync .` - (synced 2 changed files) -- `codegraph status .` - (index up to date with 271 files, 3,324 nodes, and 9,144 edges) - -Remaining after this slice: - -- Most of the old codec's pure bad-frame diagnostics are now represented in - `core`, but they are not yet wired into live WebSocket response forwarding. -- The old WebSocket request/response loop, real `tokio-tungstenite` - connection/opening bytes, permessage-deflate, ping/pong handling, pooled live - sockets, audit artifact IO, live stream proxying, and WebSocket serving parity - still need migration. - -### 2026-06-19 Slice 54: WebSocket Opening Request Descriptor - -Status: implemented and verification passed for the current batch. - -Changes: - -- Added adapter-level Responses WebSocket endpoint construction: - - `https://...` backend bases map to `wss://.../codex/responses`; - - `http://...` backend bases map to `ws://.../codex/responses`; - - existing backend base paths are preserved. -- Added `CodexWebSocketConnection::responses(...)` to build the standard - opening descriptor around business headers: - `Host`, `Connection`, `Upgrade`, `Sec-WebSocket-Version`, - `Sec-WebSocket-Key`, caller-provided business headers, then - `sec-websocket-extensions`. -- Added `CodexWebSocketRequest` and - `CodexWebSocketConnection::responses_create_request(...)` so adapter code can - hold the opening descriptor and first `response.create` text frame together. -- Exposed `websocket_response_create_payload(...)` and - `websocket_response_create_payload_text(...)` from `core` so the adapter does - not duplicate payload construction. -- Added adapter tests for endpoint conversion, opening header ordering, - redaction through the existing opening audit snapshot, and response-create - payload text generation. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - failed to compile because `responses_websocket_endpoint(...)` and - `CodexWebSocketConnection::responses(...)` did not exist. -- After implementing the opening descriptor, the filtered adapter WebSocket - tests passed 5/5. -- Before adding the payload descriptor implementation, the same filtered command - failed to compile because - `CodexWebSocketConnection::responses_create_request(...)` did not exist. -- After exposing core payload text construction and adding the request - descriptor, the filtered adapter WebSocket tests passed 6/6. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (RED: missing endpoint/opening descriptor API) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (GREEN: 5/5 filtered tests passed) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (RED: missing response-create request descriptor API) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (GREEN: 6/6 filtered tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` - (43/43 tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (23/23 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync .` - (synced 3 changed files) -- `codegraph status .` - (index up to date with 271 files, 3,337 nodes, and 9,176 edges) - -Remaining after this slice: - -- The adapter can now describe the opening request and first text frame, but it - still does not open a network WebSocket, drive ping/pong, decompress frames, - pool live sockets, or forward events into the runtime/server response path. - -### 2026-06-19 Slice 55: Minimal Live WebSocket Exchange - -Status: implemented and verification passed for the current batch. - -Changes: - -- Added `futures` and `tokio-tungstenite` to the adapters crate using existing - workspace dependency versions. -- Added `execute_response_create_request(...)` for a prepared Responses - WebSocket request: - - builds a tungstenite opening request from the ordered descriptor; - - opens the WebSocket; - - sends the first `response.create` text frame; - - collects public text events into SSE frames until a terminal event; - - extracts completed usage from the collected SSE body; - - captures metadata turn-state frames without forwarding them into SSE. -- Added `CodexWebSocketExchange` and `CodexWebSocketExchangeError`. -- Restored the old response-failed error behavior for this one-shot adapter - path: - - classified WebSocket `response.failed` / `error` frames through the core - classifier; - - surfaced HTTP-style status codes; - - extracted retry-after from wrapped headers or official rate-limit error - messages. -- Added adapter tests with a local WebSocket server for: - - successful `response.completed` collection into SSE and usage extraction; - - `response.failed` rate-limit classification into an upstream error. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request -- --nocapture` - failed to compile because `futures`, `tokio_tungstenite`, and - `execute_response_create_request(...)` were missing. -- After adding the live one-shot exchange, the successful exchange test passed. -- Before adding upstream-error handling, - `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_surface_response_failed -- --nocapture` - failed to compile because `CodexWebSocketExchangeError::Upstream` did not - exist. -- After wiring core error classification and retry-after extraction, the - response-failed test passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request -- --nocapture` - (RED: missing live dependency/function) -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request -- --nocapture` - (GREEN: success path passed) -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_surface_response_failed -- --nocapture` - (RED: missing upstream error variant) -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_surface_response_failed -- --nocapture` - (GREEN: response-failed error path passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` - (43/43 tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (25/25 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync .` - (synced 2 changed files) -- `codegraph status .` - (index up to date with 271 files, 3,356 nodes, and 9,224 edges) - -Remaining after this slice: - -- A minimal adapter-level live WebSocket exchange exists, but it is not wired - into `CodexBackendClient`, runtime dispatch, or server routes. -- The current live adapter path is one-shot and buffered; it does not yet expose - a streaming body, pooled live sockets, ping/pong recovery, permessage-deflate - handling, audit artifact IO, or runtime fallback integration. - -### 2026-06-19 Slice 56: Codex Backend WebSocket Required Path - -Status: implemented and verification passed for the current batch. - -Changes: - -- Wired `CodexBackendClient::create_response(...)` through the core Responses - transport policy: - - `HttpSse` keeps the existing HTTP/SSE POST path; - - `WebSocketRequired` uses the adapter live WebSocket exchange; - - `WebSocketPreferred` attempts WebSocket and falls back to HTTP/SSE when the - core policy allows fallback. -- Mapped WebSocket `Upstream` errors back into the existing - `CodexClientError::Upstream` shape so runtime fallback/recovery code can keep - matching status code and retry-after semantics. -- Added WebSocket request encoding and generic WebSocket transport errors to - `CodexClientError`. -- Added a client-level adapter test proving a `CodexResponsesRequest` with - `previous_response_id` opens a local WebSocket server, sends - `response.create` with `previous_response_id`, collects `response.completed` - into SSE, and extracts usage. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_use_websocket_when_previous_response_id_is_present -- --nocapture` - failed because the client sent an HTTP POST to the local WebSocket server. The - server rejected the request with `Protocol(WrongHttpMethod)` and the client - returned a reqwest incomplete-message error. -- After wiring the transport policy and WebSocket path, the same test passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_use_websocket_when_previous_response_id_is_present -- --nocapture` - (RED: client still used HTTP POST) -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_use_websocket_when_previous_response_id_is_present -- --nocapture` - (GREEN) -- `cargo test -p codex-proxy-adapters --test codex` - (26/26 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream previous_response -- --nocapture` - (3/3 filtered tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (43/43 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (54/54 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo fmt --all` -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync .` - (synced 2 changed files) -- `codegraph status .` - (index up to date with 271 files, 3,365 nodes, and 9,276 edges) - -Remaining after this slice: - -- Non-streaming `previous_response_id` requests can now take the required - WebSocket path in the adapter client, but the runtime/server surface is still - buffered and not a live streaming proxy. -- WebSocketPreferred fallback is wired, but full old parity still needs - streaming body forwarding, validator integration in the live loop, - permessage-deflate, ping/pong recovery, pooled live sockets, audit artifact - IO, and the broader old WebSocket behavior-test migration. - -### 2026-06-19 Slice 57: WebSocket Live Invalid-Event Filtering - -Status: implemented and verification passed for the current batch. - -Changes: - -- Wired the core WebSocket bad-frame validators into the live event-to-SSE path. -- `websocket_event_to_sse_frame(...)` now skips invalid public stream events - before encoding an SSE frame, matching the old transport behavior that - ignored malformed/non-forwardable WebSocket events instead of forwarding them - to clients. -- The skip decision reuses the migrated core diagnostics for metadata events, - missing `response` objects, missing delta payloads, output item required - fields, reasoning summary indexes, and the old agent/function/tool/local - shell/web/image/compaction validator categories. -- Added an adapter live WebSocket regression test proving an invalid - `response.output_text.delta` frame is dropped while a following - `response.completed` terminal frame still completes the exchange. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_skip_invalid_stream_events -- --nocapture` - failed because the invalid delta frame was forwarded into the buffered SSE - body. -- After wiring the core skip decision into event-to-SSE conversion, the same - test passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_skip_invalid_stream_events -- --nocapture` - (RED: invalid delta frame was forwarded) -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_skip_invalid_stream_events -- --nocapture` - (GREEN) -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` - (43/43 tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (27/27 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (54/54 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync .` - (synced 2 changed files) -- `codegraph status .` - (index up to date with 271 files, 3,367 nodes, and 9,295 edges) - -Remaining after this slice: - -- The live WebSocket path now avoids forwarding the migrated invalid-event - categories, but it remains buffered and one-shot rather than a live streaming - body proxy. -- Full old parity still needs streaming body forwarding, permessage-deflate, - ping/pong recovery, pooled live sockets, audit artifact IO, and broader old - WebSocket behavior test migration. - -### 2026-06-19 Slice 58: WebSocket Live Control Frames And Handshake Metadata - -Status: implemented and verification passed for the current batch. - -Changes: - -- Extended the minimal live WebSocket exchange to preserve handshake response - metadata: - - `x-codex-turn-state`; - - `set-cookie`; - - retry/rate-limit style headers. -- Mapped those handshake values through `CodexBackendClient::create_response(...)` - so required WebSocket requests expose the same metadata shape as HTTP/SSE - responses. -- Added live handling for `response.incomplete`, returning a typed - `IncompleteResponse { reason }` error. -- Added live validation for malformed `response.completed` payloads using the - core completed-response parser. -- Changed terminal handling so a skipped invalid terminal frame, such as - `response.completed` without `response`, does not end the exchange - successfully. -- Matched the old behavior for unclassified success-status `error` frames by - ignoring them until a real terminal frame or close-before-terminal error. -- Added adapter tests for handshake metadata, `response.incomplete`, malformed - `response.completed`, missing-response completed frames, success-status - `error` frames, and client-level WebSocket metadata propagation. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_ -- --nocapture` - failed to compile because `CodexWebSocketExchange` did not expose - `set_cookie_headers` / `rate_limit_headers`, and - `CodexWebSocketExchangeError` did not expose `IncompleteResponse` / - `InvalidCompletedResponse`. -- After implementing the live-loop and metadata changes, the same filtered - command passed 8/8 tests. -- The first full clippy run found the new `accept_hdr_async` test callback - matched the existing large-error lint pattern. The test was annotated with a - local `#[expect(clippy::result_large_err)]` and clippy then passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_ -- --nocapture` - (RED: missing exchange fields and error variants) -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_ -- --nocapture` - (GREEN: 8/8 filtered tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-adapters --test codex` - (32/32 tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (43/43 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream previous_response -- --nocapture` - (3/3 filtered tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (54/54 tests passed) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - (first run failed on the test callback large-error lint; rerun passed after a - local `#[expect]`) -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_use_websocket_when_previous_response_id_is_present -- --nocapture` - (client-level WebSocket metadata propagation passed) -- `cargo test -p codex-proxy-adapters --test codex` - (32/32 tests passed after the client-level assertion update) -- `cargo fmt --all -- --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 5 changed files; index up to date with 272 files, 3,656 nodes, and - 10,555 edges before this documentation-only update) -- `codegraph sync . && codegraph status .` - (synced 9 changed files; index up to date with 272 files, 3,652 nodes, and - 10,554 edges before this documentation-only update) -- `codegraph sync . && codegraph status .` - (index up to date with 271 files, 3,378 nodes, and 9,334 edges) - -Remaining after this slice: - -- The one-shot live exchange now restores more old control-frame and metadata - behavior, but it is still buffered rather than a live streaming body proxy. -- Full old parity still needs streaming body forwarding, permessage-deflate, - ping/pong recovery, pooled live sockets, audit artifact IO, and broader old - WebSocket behavior test migration. - -### 2026-06-19 Slice 59: WebSocket Ping Coverage And Audit Artifact IO - -Status: implemented and verification passed for the current batch. - -Changes: - -- Migrated old server-ping coverage into `crates/adapters/tests/codex.rs`: - the local WebSocket server sends a ping before `response.completed`, waits for - the client pong, then completes the response. The test passed with the current - `tokio-tungstenite` read loop, so no extra ping production code was needed - for this one-shot path. -- Restored structured WebSocket audit artifact data in core: - - `WebSocketAuditArtifact`; - - `WebSocketAuditErrorSnapshot`; - - structured `WebSocketParityDiff` / `WebSocketParityDifference`; - - `websocket_audit_artifact_from_attempt(...)`; - - `websocket_parity_diff(...)`. -- Added explicit adapter IO for audit artifacts through - `write_websocket_audit_artifact_for_dir(...)`, with no file writes unless a - caller supplies a non-empty directory. -- Added environment-gated client wiring through `CODEX_PROXY_WS_AUDIT_DIR` so - WebSocket attempts can emit the redacted opening/payload artifact when - explicitly enabled. -- Kept artifact construction pure in `core`; filesystem writes stay in the - adapter layer. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-core --test protocol codex_websocket_audit -- --nocapture` - failed to compile because the artifact/diff types and builders did not exist. -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_audit_artifact_should_require_explicit_directory -- --nocapture` - failed to compile because the core artifact types and adapter write function - did not exist. -- After implementation, the core audit artifact, core parity diff, and adapter - explicit-write tests passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_execute_response_create_request_should_reply_to_server_ping_before_terminal -- --nocapture` - (server ping coverage passed) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_audit -- --nocapture` - (RED: missing artifact/diff APIs) -- `cargo test -p codex-proxy-adapters --test codex websocket_audit_artifact_should_require_explicit_directory -- --nocapture` - (RED: missing artifact types/write function) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_audit -- --nocapture` - (GREEN: artifact test passed) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_parity -- --nocapture` - (GREEN: parity diff test passed) -- `cargo test -p codex-proxy-adapters --test codex websocket_audit_artifact_should_require_explicit_directory -- --nocapture` - (GREEN) -- `cargo test -p codex-proxy-core --test protocol codex_websocket_ -- --nocapture` - (21/21 filtered WebSocket tests passed) -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_use_websocket_when_previous_response_id_is_present -- --nocapture` - (client WebSocket path passed with audit wiring present) -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol` - (45/45 tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (34/34 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (54/54 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync . && codegraph status .` - (index up to date with 271 files, 3,404 nodes, and 9,433 edges) - -Remaining after this slice: - -- The one-shot live WebSocket path now has server-ping response coverage and - environment-gated audit artifact IO. -- It is still buffered rather than a live streaming body proxy. -- Full old parity still needs streaming body forwarding, live - permessage-deflate integration, pooled socket reuse/keepalive, and broader - old WebSocket behavior test migration. - -### 2026-06-19 Slice 60: WebSocket Deflate Frame Rewriter - -Status: implemented and verification passed for the current batch. - -Changes: - -- Added `flate2` to `codex-proxy-adapters`. -- Expanded `crates/adapters/src/codex/websocket/deflate.rs` beyond extension - detection: - - raw permessage-deflate payload inflation; - - WebSocket server frame parsing; - - compressed RSV1 data-frame rewriting into a normal uncompressed server - frame; - - uncompressed/partial/non-data frame no-op handling. -- Added adapter tests proving compressed server text frames are rewritten with - RSV1 cleared and original JSON payload restored, while uncompressed frames are - left unchanged. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_deflate -- --nocapture` - failed to compile because `flate2` was not an adapters dependency and - `rewrite_permessage_deflate_server_frame(...)` did not exist. -- The first implementation attempt failed the compressed-frame test because a - single `Decompress::decompress_vec` call produced an empty payload for the - test fixture. The root cause was incomplete decoder driving, so the inflater - was changed to `DeflateDecoder::read_to_end(...)`. -- The test fixture then used `DeflateEncoder` to produce a real raw deflate - payload, and the filtered deflate tests passed 2/2. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_deflate -- --nocapture` - (RED: missing dependency/API) -- `cargo test -p codex-proxy-adapters --test codex websocket_deflate -- --nocapture` - (intermediate failure: empty inflated payload; fixed by full decoder read and - a real raw-deflate test fixture) -- `cargo test -p codex-proxy-adapters --test codex websocket_deflate -- --nocapture` - (GREEN: 2/2 filtered tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-adapters --test codex` - (36/36 tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (45/45 tests passed) -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (54/54 tests passed) -- `cargo fmt --all -- --check` -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `find crates tests src -path '*/target' -prune -o -type f -empty -print | sort` - (no empty files reported) -- `rg -n "fn main\(\) \{\}|not wired yet|后续承载|todo!\(|unimplemented!\(|TODO\b|panic!\(\"TODO|stub|placeholder" crates src tests docs/architecture-audit.md` - (matches were limited to this audit document and the architecture - placeholder guard/test names) -- `codegraph sync . && codegraph status .` - (index up to date with 271 files, 3,422 nodes, and 9,482 edges) - -Remaining after this slice: - -- Deflate frame-level rewrite logic is no longer a one-line shell, but it is - not yet inserted into the live `tokio-tungstenite` connection path. Because - `tokio-tungstenite` rejects RSV1 frames before yielding messages, live - integration still needs a TLS-aware stream insertion point or a custom - connector. -- The WebSocket path is still buffered rather than a live streaming body proxy. -- Full old parity still needs streaming body forwarding, live - permessage-deflate integration, pooled socket reuse/keepalive, and broader - old WebSocket behavior test migration. - -### 2026-06-19 Slice 61: Live HTTP SSE Body Forwarding - -Status: implemented and targeted verification passed for this batch. - -Changes: - -- Added a live HTTP SSE response path to `CodexBackendClient`: - - `CodexBackendSseStream`; - - `CodexBackendStreamingResponse`; - - `CodexBackendClient::create_response_stream(...)`. -- Kept non-2xx upstream responses on the existing typed `CodexClientError::Upstream` - path, including capped error body reads and retry-after extraction, so HTTP - `429`/`402`/`401`/`403`/`5xx` fallback remains before any downstream bytes are - sent. -- Changed `ResponseDispatchService::stream(...)` from a buffered `String` result - to `ResponseDispatchStream`, a runtime-owned byte stream that does not expose - axum types. -- Added first-event SSE prefetch in runtime before the live stream is returned. - This preserves existing fallback/recovery when the first upstream event is a - recoverable `response.failed` while allowing normal delta streams to be - forwarded before upstream completion. -- Moved stream usage/session-affinity finalization to stream end: - - token usage is extracted from the observed SSE body and recorded after the - upstream stream ends; - - session affinity is recorded from `response.completed`; - - the runtime account-pool slot is held until the stream ends instead of being - released immediately after response headers; - - `[DONE]` is appended for live streams when the upstream body omits it. -- Wired the server Responses handler to return `Body::from_stream(...)` for - successful streaming Responses while keeping generated SSE error responses on - the existing string path. -- Added a server integration regression test with a raw chunked upstream that - writes the first SSE event, keeps the connection open, and only later sends - `response.completed`. The test proves the proxy returns the HTTP response and - first SSE chunk before upstream completion. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_forward_first_chunk_before_upstream_completes -- --nocapture` - failed as expected with: - `stream response should be returned before upstream completes: Elapsed(())`. -- After implementing the live stream path, the same test passed. -- The existing `responses_stream_should...` batch stayed green, including SSE - failure fallback, 429/402/401/403 fallback, 5xx retry, history recovery, usage - persistence, and generated stream error termination coverage. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_forward_first_chunk_before_upstream_completes -- --nocapture` - (RED: timeout because the old implementation waited for upstream completion) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_forward_first_chunk_before_upstream_completes -- --nocapture` - (GREEN: 1/1 filtered test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should -- --nocapture` - (16/16 filtered stream tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (55/55 tests passed) -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `codegraph sync . && codegraph status .` - (synced 4 changed files; index up to date with 271 files, 3,456 nodes, and - 9,738 edges) - -Remaining after this slice: - -- HTTP SSE `stream: true` now has a live chunk-by-chunk success path plus - first-event fallback preservation, but once normal downstream bytes have been - sent, later upstream `response.failed` events cannot be retried on another - account without violating HTTP streaming semantics. Late-failure stream audit - behavior still needs explicit policy and tests. -- WebSocket Responses are still buffered in the one-shot exchange path. -- Full WebSocket parity still needs live WebSocket body forwarding, live - permessage-deflate integration, pooled socket reuse/keepalive, and broader old - behavior-test migration. - -### 2026-06-19 Slice 62: WebSocket Pool Reuse And Error Discard - -Status: implemented and adapter verification passed for this batch. - -Changes: - -- Replaced the WebSocket pool policy-only struct with a real async pool state: - - `CodexWebSocketPoolKey` keyed by normalized base URL, account id, and - conversation id; - - idle/busy slots guarded by `tokio::sync::Mutex`; - - max-per-account admission checks; - - max-age recycling for idle connections; - - `acquire`, `put`, `discard`, and explicit `gc_sweep`. -- Added pooled connection metadata and a concrete `CodexWsStream` type for - `tokio-tungstenite` WebSocket streams. -- Added `CodexBackendClient::with_websocket_pool(...)` and optional pool key - derivation from request/context data. Pooling is only used when both an - upstream account id and a conversation key are available; otherwise the - existing one-shot WebSocket path is preserved. -- Split the WebSocket execution path so fresh and reused connections share the - same response collector: - - successful terminal responses return the socket to the pool; - - upstream error frames, transport errors, and invalid terminal behavior - discard the pool slot instead of returning a bad socket; - - bypass remains available when a key is already busy or account capacity is - exhausted. -- Migrated adapter tests proving: - - two same-account/same-conversation Responses requests reuse one accepted - upstream WebSocket connection; - - a pooled upstream error discards the connection, so the next request opens a - fresh upstream WebSocket and succeeds. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - failed because `CodexBackendClient::with_websocket_pool(...)` did not exist. -- The first implementation attempt exposed a test setup false positive: the - requests did not pass `account_id`, so the pool was correctly bypassed. The - tests were corrected to pass the same account id, proving actual pool usage. -- After implementation and test correction, the filtered pooled WebSocket tests - passed 2/2. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - (RED: missing `with_websocket_pool` API) -- `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - (intermediate failure: test omitted account id and bypassed the pool; fixed) -- `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - (GREEN: 2/2 filtered tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (38/38 tests passed) -- `cargo fmt --all` -- `cargo check --workspace --all-targets` -- `cargo test -p codex-proxy-core --test protocol` - (45/45 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (55/55 tests passed) -- `cargo test --test architecture` - (22/22 tests passed) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - (first run failed on a `needless_collect` in pool GC and too-many-arguments in - the live SSE finalizer helpers; both were refactored, and the rerun passed) -- `cargo test -p codex-proxy-adapters --test codex` - (38/38 tests passed after the clippy refactor) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (55/55 tests passed after the clippy refactor) -- `cargo test --test architecture` - (22/22 tests passed after the clippy refactor) -- `cargo fmt --all -- --check` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (final sync after lint cleanup synced 2 changed files; index up to date with - 271 files, 3,493 nodes, and 9,871 edges) - -Remaining after this slice: - -- WebSocket pooling now reuses successful same-conversation sockets and discards - errored sockets, but it does not yet have the old background maintenance task, - ping probe/timeout keepalive, liveness timeout, or shutdown/evict-account API. -- WebSocket Responses are still a buffered one-shot response collector from the - server/runtime perspective; live WebSocket body forwarding still needs a - streaming API shape. -- Live permessage-deflate integration is still not inserted into the - `tokio-tungstenite` stream path. - -### 2026-06-19 Slice 63: WebSocket Pool Lifecycle And Runtime Wiring - -Status: implemented and verified for this batch. - -Changes: - -- Restored the adapter pool lifecycle surface from the old WebSocket pool: - - added `CodexWebSocketPoolConfig` with `enabled`, `max_age`, - `max_per_account`, `maintenance_interval`, `ping_interval`, - `ping_timeout`, and `liveness_timeout`; - - preserved the existing `CodexWebSocketPool::new(max_per_account, max_age)` - test-friendly constructor without background maintenance; - - added `with_config(...)`, `with_default_max_age()`, and `with_limits(...)`; - - added `evict_account(...)`, `shutdown()`, and - `maintain_idle_connections()`; - - kept `gc_sweep()` as the public maintenance entry point while expanding it - beyond age-only cleanup. -- Added real idle lifecycle state: - - pool state now tracks `shutting_down`; - - slots include `Checking` so a socket under keepalive probe cannot be reused - concurrently; - - pooled connections track `last_activity_at` and `last_ping_at`. -- Restored idle maintenance behavior: - - max-age and liveness-timeout cleanup close idle sockets; - - ping keepalive sends a WebSocket `Ping`, waits up to `ping_timeout` for an - upstream response, keeps the socket on success, and closes/discards it on - timeout, close, or transport error; - - optional background maintenance starts when a Tokio runtime is available and - `maintenance_interval` is configured. -- Wired platform `ws_pool` config into runtime Codex client construction: - - `codex_proxy_runtime::upstream::codex_backend_client(...)` now accepts - `&WebSocketPoolConfig`; - - `Services::with_installation_id(...)` passes `config.ws_pool`; - - enabled configs inject a shared `CodexWebSocketPool` into - `CodexBackendClient`. - -TDD evidence: - -- Before implementation, - `cargo test -p codex-proxy-adapters --test codex websocket_pool -- --nocapture` - still only ran the old policy test, proving the lifecycle tests were not yet - covered by the existing filter. -- After adding lifecycle tests, the correct RED command - `cargo test -p codex-proxy-adapters --test codex websocket_pool -- --nocapture` - failed at compile time because `CodexWebSocketPoolConfig` and - `CodexWebSocketPool::with_config(...)` did not exist. -- After the initial adapter implementation, - `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - passed the manual keepalive/evict/shutdown/liveness tests but exposed a - background-maintenance test design issue: a 1 ms ping interval let the - background task repeatedly move the socket into `Checking`. The test was - narrowed to one expected background probe by using a long post-probe - `ping_interval`, then passed. -- Runtime wiring RED: - `cargo test -p codex-proxy-runtime --test upstream codex_backend_client_should_apply_configured_websocket_pool -- --nocapture` - failed because `codex_backend_client(...)` accepted only `base_url` and - `fingerprint`, with no `ws_pool` config argument. -- Runtime wiring GREEN: the same runtime upstream test passed after mapping - platform `WebSocketPoolConfig` to adapter `CodexWebSocketPoolConfig` and - injecting the pool. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test codex websocket_pool -- --nocapture` - (RED: missing pool config/constructor API) -- `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - (GREEN: 7/7 filtered pooled WebSocket tests passed) -- `cargo test -p codex-proxy-runtime --test upstream codex_backend_client_should_apply_configured_websocket_pool -- --nocapture` - (RED: runtime backend client constructor did not accept `ws_pool` config) -- `cargo test -p codex-proxy-runtime --test upstream -- --nocapture` - (GREEN: 1/1 runtime upstream wiring test passed) -- `cargo test -p codex-proxy-adapters --test codex` - (43/43 tests passed) -- `cargo check --workspace --all-targets` - (first run showed one warning caused by a temporarily removed test attribute; - fixed by restoring `#[tokio::test]`) -- `cargo check --workspace --all-targets` - (rerun passed without warnings) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (55/55 tests passed) -- `cargo test --test architecture` - (22/22 tests passed) -- `codegraph sync .` - (synced 6 changed files; 1 added and 5 modified; 496 nodes in 671 ms) -- `codegraph status .` - (index up to date with 272 files, 3,535 nodes, and 10,058 edges) - -Remaining after this slice: - -- Adapter-level WebSocket pool reuse, error discard, keepalive maintenance, - liveness cleanup, shutdown/evict-account behavior, and runtime config - injection are restored. -- WebSocket Responses are still buffered through the one-shot exchange from the - server/runtime perspective; live WebSocket body forwarding still needs a - streaming API shape. -- Live permessage-deflate rewriting exists as tested frame helpers, but it is - still not inserted into the `tokio-tungstenite` stream path. -- Broader old WebSocket behavior-test migration remains incomplete, including - server/runtime dispatch parity for live WebSocket routes. - -### 2026-06-19 Slice 64: Server Responses WebSocketRequired Path - -Status: implemented and verified for this batch. - -Changes: - -- Removed the unconditional `force_http_sse = true` from OpenAI Responses - translation for non-streaming requests with `previous_response_id`. - Non-streaming history-continuation requests now reach - `CodexTransport::WebSocketRequired` instead of being forced through HTTP SSE. -- Kept current live streaming behavior intentionally conservative: - `stream: true` requests with `previous_response_id` still force HTTP SSE until - live WebSocket streaming is implemented. -- After history recovery strips stale `previous_response_id` state, the runtime - now also clears `use_websocket` and sets `force_http_sse = true` so the retry - matches a normal no-history OpenAI Responses request. -- Migrated server/runtime tests that previously used HTTP-only mocks for - non-streaming `previous_response_id`: - - session-affinity account preference now uses a captured WebSocket opening - and asserts `authorization`, `chatgpt-account-id`, and payload - `previous_response_id`; - - previous-response-not-found, unanswered-function-call, and invalid encrypted - reasoning recovery now use a first WebSocket `response.failed` frame followed - by an HTTP SSE retry after history is stripped; - - added an end-to-end server test proving two same-history non-streaming - `/v1/responses` requests use WebSocket and reuse the configured runtime - WebSocket pool. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_with_previous_response_id_should_use_websocket_and_configured_pool -- --nocapture` - failed with `Protocol(WrongHttpMethod)`, proving the server path still sent an - HTTP POST to a WebSocket upstream for `previous_response_id`. -- GREEN: - the same test passed after changing the translator to leave - `force_http_sse=false` for non-streaming history-continuation requests. -- Full server test RED after the first fix exposed five old HTTP mock tests that - still assumed non-streaming `previous_response_id` used HTTP. Those tests were - migrated to WebSocket/hybrid upstream fixtures, and the full server test then - passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_with_previous_response_id_should_use_websocket_and_configured_pool -- --nocapture` - (RED: WebSocket upstream received HTTP POST / wrong method) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_with_previous_response_id_should_use_websocket_and_configured_pool -- --nocapture` - (GREEN: targeted server WebSocketRequired path passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (intermediate RED: five previous-response tests still used HTTP-only mocks) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_strip_history_after -- --nocapture` - (4/4 migrated non-streaming history recovery tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream previous_response -- --nocapture` - (4/4 previous-response filtered tests passed after fixing the affinity fixture - timestamp) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (56/56 tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (46/46 tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (43/43 tests passed) -- `cargo test -p codex-proxy-runtime --test upstream -- --nocapture` - (1/1 test passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - (first run flagged `accept_hdr_async` test callback large-error types; fixed - with local `#[expect(clippy::result_large_err)]` annotations and reran green) -- `cargo test --test architecture` - (22/22 tests passed) -- `git diff --check` -- `codegraph sync .` - (synced 4 changed files; 515 nodes in 449 ms) -- `codegraph status .` - (index up to date with 272 files, 3,550 nodes, and 10,120 edges) - -Remaining after this slice: - -- Non-streaming OpenAI Responses with `previous_response_id` now exercise the - server/runtime WebSocketRequired path and configured WebSocket pool. -- Streaming `previous_response_id` requests and live permessage-deflate were - still open at the end of Slice 64; both are addressed in Slice 65 below. -- Broader old WebSocket behavior-test migration remains incomplete beyond the - migrated previous-response server/runtime coverage above. - -### 2026-06-19 Slice 65: Live WebSocket Streaming And Deflate Integration - -Status: implemented and verified for this batch. - -Changes: - -- Enabled OpenAI Responses translation to keep `force_http_sse=false` for all - requests with `previous_response_id`, including `stream: true`, so streaming - history-continuation requests now reach `CodexTransport::WebSocketRequired`. -- Added live WebSocket SSE byte-stream forwarding in the adapter path used by - `CodexBackendClient::create_response_stream(...)`. The stream converts public - WebSocket events to SSE bytes before terminal completion, returns pooled - sockets after successful terminal events, and discards sockets on transport or - upstream errors. -- Replaced the live WebSocket opening path with the migrated original-handshake - flow: the adapter writes the ordered upgrade request, reads the opening - response and any preloaded frame bytes, validates `Sec-WebSocket-Accept`, wraps - the stream in a `PerMessageDeflateStream`, and then hands it to - `tokio-tungstenite`. -- Inserted tested live permessage-deflate support into both aggregated and - streaming WebSocket paths. Server RSV1 text/binary frames are inflated before - tungstenite parses them, while the existing pure frame rewriter tests remain - in place. -- Restored streaming history recovery for WebSocket-first failures that surface - while prefetching the first SSE chunk. `previous_response_not_found` and - `invalid_encrypted_content` now strip history and retry over HTTP SSE before - any downstream bytes are returned. -- Migrated server streaming history-recovery tests from HTTP-only mocks to a - first WebSocket `response.failed` frame followed by HTTP SSE recovery, and - added coverage that a streaming `previous_response_id` response forwards a - WebSocket chunk before upstream completion. -- Added live adapter coverage for negotiated permessage-deflate by using a raw - local WebSocket upstream that replies with - `Sec-WebSocket-Extensions: permessage-deflate` and an RSV1 compressed - `response.completed` frame. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_with_previous_response_id_should_forward_websocket_chunks_before_completion -- --nocapture` - failed with `Protocol(WrongHttpMethod)`, proving streaming - `previous_response_id` still went to HTTP instead of WebSocket. -- GREEN: - the same test passed after enabling WebSocketRequired routing for streaming - `previous_response_id` and adding adapter live WebSocket byte streaming. -- RED: - `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_decode_live_permessage_deflate_websocket_frame -- --nocapture` - failed with `Protocol(NonZeroReservedBits)`, proving live RSV1 frames still - reached tungstenite without decompression. -- GREEN: - the same test passed after inserting `PerMessageDeflateStream` into the live - connection path. -- Full server test then exposed two streaming history-recovery tests that still - assumed HTTP-first behavior. They were migrated to WS-first recovery fixtures, - and the runtime prefetch error branch was fixed to strip history and retry - before returning downstream bytes. - -Verification run so far: - -- `cargo test -p codex-proxy-core --test protocol openai_streaming_response_with_previous_response_should_require_websocket -- --nocapture` - (1/1 filtered test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_with_previous_response_id_should_forward_websocket_chunks_before_completion -- --nocapture` - (RED then GREEN; final filtered test passed) -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_decode_live_permessage_deflate_websocket_frame -- --nocapture` - (RED then GREEN; final filtered test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_strip_history_after -- --nocapture` - (2/2 streaming WS-first history recovery tests passed after the prefetch - recovery fix) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (26/26 filtered WebSocket tests passed) -- `cargo test -p codex-proxy-adapters --test codex` - (44/44 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (57/57 tests passed) -- `cargo test -p codex-proxy-runtime --test upstream` - (1/1 test passed) -- `cargo test -p codex-proxy-core --test protocol` - (46/46 tests passed) -- `cargo check --workspace --all-targets` -- `cargo fmt --all -- --check` -- `cargo test --test architecture` - (22/22 tests passed) -- `git diff --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - (first run flagged one new `accept_hdr_async` test helper large-error - callback; fixed with a local - `#[expect(clippy::result_large_err)]` annotation and reran green) -- `codegraph sync . && codegraph status .` - (synced 9 changed files; index up to date with 272 files, 3,595 nodes, and - 10,382 edges before the documentation-only update) - -Remaining after this slice: - -- Live WebSocket streaming body forwarding, streaming `previous_response_id` - WebSocket dispatch, and live permessage-deflate integration are now covered by - adapter/server/core tests. -- Remaining old WebSocket behavior-test migration still needs a focused pass for - parity/audit edge cases not represented by the restored live, pool, deflate, - ping, history-recovery, and previous-response coverage. -- Late-failure stream audit/reporting policy after downstream bytes have already - been sent remains a serving-layer gap. - -### 2026-06-19 Slice 66: Live Stream Late-Failure SSE Reporting - -Status: implemented and verified for this batch. - -Changes: - -- Restored the old serving-layer client reporting policy for live stream - failures after at least one downstream SSE chunk has already been sent. The - runtime no longer forwards late upstream body read errors as Hyper body - errors; it appends a synthetic OpenAI-compatible - `response.failed` event with `code: "stream_disconnected"` and then appends - `data: [DONE]`. -- Covered the clean premature EOF case separately. If the upstream stream ends - without `response.completed`, `response.failed`, or `error`, the runtime now - emits the same `stream_disconnected` failure event before `[DONE]`. -- Preserved terminal-event semantics: if a live stream has already received - `response.completed`, `response.failed`, or `error`, the runtime only appends - `[DONE]` when needed and does not synthesize a second failure. -- Added partial-SSE safety before synthetic failures. If upstream closes while - a frame is incomplete, the runtime first emits the missing blank line so the - synthetic `response.failed` event is parsed as a separate SSE frame. -- Kept retry behavior conservative: once bytes have been sent downstream, the - serving layer does not attempt account fallback or history replay, because - doing so would violate HTTP streaming semantics. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_emit_failed_event -- --nocapture` - failed for both new tests. The abrupt-close case surfaced a body - `UnexpectedEof` error to the downstream reader, and the clean-close case - lacked `event: response.failed`. -- GREEN: - the same filtered command passed after runtime stream finalization converted - late read errors and missing-terminal EOF into SSE - `response.failed(stream_disconnected)` frames followed by `[DONE]`. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_emit_failed_event -- --nocapture` - (RED then GREEN; final filtered tests passed 2/2) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should -- --nocapture` - (18/18 filtered stream tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (59/59 tests passed) -- `cargo test -p codex-proxy-runtime --test upstream -- --nocapture` - (1/1 test passed) -- `cargo test -p codex-proxy-core --test protocol -- --nocapture` - (46/46 tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture -- --nocapture` - (22/22 tests passed) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - -Remaining after this slice: - -- Client-visible late-failure SSE reporting is restored for live stream body - errors and clean premature EOF after downstream bytes have been sent. -- Full persisted stream audit parity remains incomplete. The new crate split - still lacks the old `StreamAudit` / `WebSocketStreamAudit` event-log style - artifacts for all late-success and late-failure stream outcomes. -- Remaining old WebSocket behavior-test migration still needs a focused pass for - parity/audit edge cases not represented by the restored live, pool, deflate, - ping, history-recovery, previous-response, and late-failure coverage. - -### 2026-06-19 Slice 67: Live Stream Event-Log Audit Restoration - -Status: implemented and verified for this batch. - -Changes: - -- Added a runtime write path for admin event logs through - `AdminLogService::record(...)`, reusing the existing core `EventLogService` - policy: when logging is enabled, all events are recorded; when disabled, - error-level events are still retained. -- Injected the shared `AdminLogService` into `ResponseDispatchService` and - carried request audit context into `LiveResponseStreamContext`: request id, - account id, model, route, stream metadata, status code, and latency. -- Restored persisted `v1.response` event-log records for completed live - Responses streams. Successful terminal streams now write an info event with - status `200`, `stream: true`, `completed: true`, and `responseId`. -- Restored persisted `v1.response` event-log records for late failed live - Responses streams. Synthetic `stream_disconnected` failures, upstream - `response.failed` events, and missing-terminal streams now write error/warn - events with failure metadata and the mapped status code. -- Updated the admin logs error mapper for the new append failure variant so - management routes remain exhaustive. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_record_event_log -- --nocapture` - failed for both new tests with `expected a v1.response event log`, proving live - stream completion and late-disconnect paths still did not persist audit - records. -- GREEN: - the same filtered command passed after wiring `AdminLogService` into the live - stream finalizer and writing completed/failed stream events. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_record_event_log -- --nocapture` - (RED then GREEN; final filtered tests passed 2/2) -- `cargo test -p codex-proxy-server --test admin_logs_routes -- --nocapture` - (2/2 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should -- --nocapture` - (20/20 filtered stream tests passed) -- `cargo test -p codex-proxy-runtime --test upstream -- --nocapture` - (1/1 test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (61/61 tests passed) -- `cargo test --test architecture -- --nocapture` - (22/22 tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync . && codegraph status .` - (synced 3 changed files; index up to date with 272 files, 3,623 nodes, and - 10,460 edges before the documentation-only update) - -Remaining after this slice: - -- Persisted live stream event-log audit is restored for completed streams and - late-failure streams at the runtime/server boundary. -- Full old `StreamAudit` / `WebSocketStreamAudit` parity is still broader than - this slice. Body capture policy, rate-limit/header snapshots, WebSocket pool - update snapshots, and every old parity edge-case artifact still need a focused - pass before calling stream audit migration complete. -- Remaining old WebSocket behavior-test migration still needs a focused pass for - parity/audit edge cases not represented by the restored live, pool, deflate, - ping, history-recovery, previous-response, late-failure, and event-log - coverage. - -### 2026-06-19 Slice 68: Stream Audit Metadata And Log Policy Batch - -Status: implemented and verified for this batch. - -Changes: - -- Restored the old admin log state mutation surface for migrated routes: - `PATCH /api/admin/logs/state` now updates `enabled`, `capacity`, and - `captureBody`, rejects zero capacity, and trims persisted `event_logs` to the - configured newest-N capacity without adding a schema migration. -- Restored the old log body-capture policy in `AdminLogService::record(...)`. - Events can attach `body`, `rawBody`, `requestBody`, `responseBody`, and - `upstreamBody`; when `captureBody` is false these keys are removed before - persistence, and when true they are retained. -- Extended live Responses stream event logs with old `StreamAudit` metadata: - completed and failed streams now include normalized `usage`, - `rateLimitHeaders`, and capture-policy-controlled `requestBody` / - `responseBody`. -- Added an actual upstream transport marker to streaming adapter responses. - Runtime stream audit records `transport: "websocket"` only when the adapter - actually used WebSocket, so WebSocketPreferred HTTP fallback cannot be logged - as a WebSocket stream by request-policy inference. -- Restored live WebSocket stream rate-limit update snapshots. Adapter live - forwarding now captures internal `codex.rate_limits` events, converts them - back into the old `x-codex-*` header-pair shape, shares those updates with the - runtime stream finalizer, and merges them into persisted - `rateLimitHeaders`. -- Preserved the existing sensitive-cookie stance: `set-cookie` response headers - are still not added to event metadata. The restored header snapshot is scoped - to rate-limit and Codex review/quota headers. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-server --test admin_logs_routes admin_logs_should_update_state_and_trim_to_capacity -- --nocapture` - failed with HTTP `405`, proving the migrated admin log state update route was - missing. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_record_event_log_after_completed_stream -- --nocapture` - failed because stream metadata lacked `usage` and rate-limit header snapshot - fields. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_preserve_body_metadata_when_capture_body_enabled -- --nocapture` - failed because capture-body-enabled logs still had no `requestBody` / - `responseBody`. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_record_event_log_after_late_disconnect -- --nocapture` - failed because late-failure stream logs lacked `rateLimitHeaders`. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_with_previous_response_id_should_record_websocket_audit_metadata -- --nocapture` - first failed on missing `transport: "websocket"`, then exposed missing live - WebSocket rate-limit update propagation. -- GREEN: - the same targeted tests passed after restoring admin log state mutation, - body-capture policy, actual stream transport propagation, HTTP stream - metadata enrichment, and WebSocket `codex.rate_limits` update sharing. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_logs_routes -- --nocapture` - (3/3 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (63/63 tests passed) -- `cargo test -p codex-proxy-adapters --test codex websocket -- --nocapture` - (27/27 filtered WebSocket tests passed) -- `cargo test -p codex-proxy-core --test protocol websocket -- --nocapture` - (25/25 filtered WebSocket/protocol tests passed) -- `cargo test -p codex-proxy-runtime --test upstream -- --nocapture` - (1/1 test passed) -- `cargo test -p codex-proxy-runtime --test tasks -- --nocapture` - (9/9 tests passed) -- `cargo test --test architecture -- --nocapture` - (22/22 tests passed) -- `cargo check --workspace --all-targets` -- `cargo fmt --all -- --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` - -Remaining after this slice: - -- Stream audit metadata parity is restored for body capture policy, usage, - rate-limit/header snapshots, actual WebSocket transport marking, and live - WebSocket rate-limit update snapshots. -- Remaining old WebSocket behavior-test migration still needs a focused pass for - parity/audit edge cases not represented by the restored live, pool, deflate, - ping, history-recovery, previous-response, late-failure, event-log, and - rate-limit-update coverage. -- Full persisted stream audit parity still needs any old pool-specific artifact - details that are separate from event-log metadata. - -### 2026-06-19 Slice 69: Serving Toy Helper Cleanup Guard - -Status: implemented and verified for this batch. - -Changes: - -- Added an architecture guard that fails if the serving modules keep the - previously audited toy helpers: - `fallback::should_fallback(...)`, `recovery::is_recoverable(...)`, and - `responses::prefers_websocket(...)`. -- The same guard verifies that `quota::quota_reached(...)`, which remains a - useful pure threshold helper, has a non-test production caller. -- Replaced `fallback::should_fallback(...)` with explicit pure status helpers: - `status_code_is_rate_limited`, `status_code_is_quota_exhausted`, and - `status_code_is_transient_upstream`. -- Replaced `recovery::is_recoverable(...)` with the narrower - `status_code_allows_same_account_retry(...)`, matching the runtime's current - same-account retry behavior instead of implying broader recovery semantics. -- Removed `responses::prefers_websocket(...)`; production transport decisions - already use `transport_for_request(...)` and `http_sse_fallback_allowed(...)`. -- Wired runtime dispatch classification and admin quota-warning threshold - matching through the core serving helpers, so these modules are no longer just - architecture-shaped files with uncalled convenience functions. - -TDD evidence: - -- RED: - `cargo test --test architecture serving_modules_should_not_keep_uncalled_toy_helpers -- --nocapture` - failed and listed all four audited helpers: `should_fallback`, - `is_recoverable`, `prefers_websocket`, and uncalled `quota_reached`. -- GREEN: - the same architecture test passed after removing/renaming the toy helpers and - wiring runtime production callers through the remaining pure policy helpers. - -Verification run so far: - -- `cargo test --test architecture serving_modules_should_not_keep_uncalled_toy_helpers -- --nocapture` - (RED then GREEN; final filtered test passed) -- `cargo test -p codex-proxy-server --test admin_accounts_routes quota_warnings -- --nocapture` - (3/3 filtered quota-warning tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (63/63 tests passed) -- `cargo test --test architecture -- --nocapture` - (23/23 tests passed) -- `cargo test -p codex-proxy-core --test protocol -- --nocapture` - (46/46 tests passed) -- `cargo check --workspace --all-targets` -- `cargo fmt --all -- --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` - -Remaining after this slice: - -- The specific audited uncalled serving toy helpers are removed or wired to - production callers, and architecture tests now catch their return. -- Broader serving dispatch parity is still not complete: old fallback/recovery - edge-case behavior should continue to be migrated through runtime/server tests - rather than inferred from helper presence. - -### 2026-06-19 Slice 70: Admin Settings Patch And Test Migration Guards - -Status: implemented and verified for this batch. - -Changes: - -- Migrated the old admin settings PATCH behavior into the crate split: - - `core::admin::settings` now owns the retained settings patch model, - validation, allowed rotation strategies, quota-warning threshold validation, - and pure patch application. - - `platform::config::AppConfig::write_settings_overlay(...)` now writes the - retained runtime settings subset to a local YAML overlay without putting - filesystem IO in `core`. - - `RuntimeSettingsService` now stores a same-process current config snapshot, - applies core settings patches, writes `local.yaml`, and exposes an injected - local-config path for tests. - - `PATCH /api/admin/settings` is restored, admin-session gated, rejects - unknown/invalid fields, persists retained fields to local YAML, and updates - the same-process `GET /api/admin/settings` view. -- Added crate-local tests migrated from the old `tests/admin/settings_route.rs` - coverage for PATCH auth, retained-field persistence, reload through - `AppConfig::load_from_dir(...)`, same-process GET visibility, and - unknown/invalid field rejection. -- Added `tests/architecture/test_migration.rs` with guards that: - - keep root `tests/` limited to architecture tests plus shared fixtures; - - require high-risk baseline suites to have crate-local migration targets; - - fail when exported core service modules have no production callers. -- Wired previously thin core service modules into production paths: - - `AccountService` now supplies account quota/cloudflare availability helpers - used by `AccountPool`; - - `UsageService` now converts normalized Codex token usage into - `AccountUsageDelta` for runtime persistence; - - `SettingsService` is now used by runtime settings updates. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-core --test admin_settings -- --nocapture` - failed before implementation because the retained settings patch types and - full settings fields did not exist. -- RED: - `cargo test -p codex-proxy-server --test admin_settings_routes admin_settings_patch -- --nocapture` - failed before implementation because `AppState` had no local-config-path - constructor and the server test lacked the YAML writer dependency; the route - was also still only registered as `GET`. -- GREEN: - both targets passed after implementing the core/runtime/platform/server - settings path. - -Verification run so far: - -- `cargo test -p codex-proxy-core --test admin_settings -- --nocapture` - (2/2 tests passed) -- `cargo test -p codex-proxy-server --test admin_settings_routes admin_settings_patch -- --nocapture` - (3/3 filtered PATCH tests passed) -- `cargo test --test architecture test_migration -- --nocapture` - (3/3 filtered architecture migration tests passed) -- `cargo test -p codex-proxy-server --test admin_settings_routes -- --nocapture` - (6/6 tests passed) -- `cargo test -p codex-proxy-core --test account_pool -- --nocapture` - (25/25 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (63/63 tests passed) -- `cargo test -p codex-proxy-platform --test config -- --nocapture` - (3/3 tests passed) -- `cargo test --test architecture -- --nocapture` - (26/26 tests passed) -- `cargo fmt --all` -- `cargo check --workspace --all-targets` - -Remaining after this slice: - -- The old admin settings PATCH/local YAML behavior is restored for the retained - settings surface. -- Root behavior test files are now guarded against returning under `tests/`; - high-risk old suites now have crate-local migration-target evidence, but this - does not by itself prove every individual old assertion has a one-to-one - migrated equivalent. -- Generic empty/placeholder detection is stronger for exported core service - modules, but broader non-empty semantic completeness still depends on - behavior tests and focused old-code parity passes. - -### 2026-06-19 Slice 71: Admin Client-Key Assertion Coverage Migration - -Status: crate-local behavior coverage migrated and verified for this batch. - -Changes: - -- Migrated additional old `tests/admin/client_keys_route.rs` assertions into - `crates/server/tests/admin_client_keys_routes.rs`. -- Added crate-local coverage for: - - label update, label clearing, overlong-label rejection, and missing-key - `404`; - - batch delete with found/missing IDs, authorization invalidation, and empty - ID rejection; - - export metadata shape without plaintext, hash, or pepper material; - - import of exported metadata with forced key rotation, one-time plaintext - return, old plaintext rejection, and new plaintext authorization. - -TDD/coverage evidence: - -- The migrated tests passed on the first run, so this slice did not require - production-code changes. That means the behavior had already been restored in - earlier migration work, but the old assertions were not yet present in the - crate-local suite. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_client_keys_routes -- --nocapture` - (7/7 tests passed) -- `cargo fmt --all` -- `cargo fmt --all -- --check` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 1 changed file; index up to date with 274 files, 3,737 nodes, and - 10,716 edges before this documentation-only update) - -Remaining after this slice: - -- The highest-risk old client-key route assertions are now represented in the - server crate-local suite. -- Assertion-level migration is still incomplete for other old suites called out - in this audit, especially the largest WebSocket and serving suites. - -### 2026-06-19 Slice 72: Admin Account Manual Create, Refresh-Only, And CLI Import - -Status: implemented and verified for this batch. - -Changes: - -- Migrated a larger batch of old `tests/admin/accounts/import_export.rs` - assertions into `crates/server/tests/admin_accounts_routes.rs`. -- Strengthened manual account create coverage for: - - ignoring caller-supplied metadata/status and deriving email/account/user/plan - from JWT claims; - - encrypting both access and refresh tokens at rest; - - immediately syncing created/updated active accounts into the runtime account - pool without requiring process restart; - - rejecting missing, invalid, expired, and account-claim-less tokens. -- Restored refresh-token-only manual create: - - `AdminAccountService` now accepts a `TokenRefresher` port; - - production service assembly injects the OpenAI OAuth client as the default - refresher; - - tests can inject a static refresher through an `AppState` constructor; - - refresh-only create stores a rotated refresh token when returned; - - refresh-only create preserves the input refresh token for new accounts when - the exchange omits rotation; - - refresh-only update preserves the existing stored refresh token when the - exchange omits rotation. -- Restored same-account manual update behavior: - - same ChatGPT account/user claims update the existing row rather than - inserting a duplicate; - - access token, email, plan, status, and pool snapshot are updated; - - existing refresh token is preserved when the new request does not provide a - replacement. -- Restored `POST /api/admin/accounts/import-cli`: - - server reads `codexHome/auth.json`; - - runtime parses `access_token` / `refresh_token` from the CLI auth payload and - reuses the same manual-create path; - - caller metadata is ignored in favor of JWT claims; - - imported tokens are encrypted and not returned in the response; - - imported active accounts are synced into the runtime account pool. -- Updated `docs/architecture.md` and the directory-shape whitelist for the new - `crates/server/src/admin_api/accounts/import_cli.rs` handler. -- Added runtime account-pool synchronization for successful admin account - create/import/delete paths. Label/status updates now attempt pool sync - best-effort so metadata writes do not fail when the existing stored token is - intentionally corrupt or undecryptable. -- Added `AccountStore::get_pool_account(...)` and a SQLite implementation that - reads a single pool snapshot by ID. This avoids full-table pool scans during - admin write synchronization and prevents unrelated corrupt token rows from - breaking otherwise valid create/import sync. - -TDD/debugging evidence: - -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - failed before implementation because `AppState` had no injected token - refresher constructor. The missing constructor blocked refresh-only migration - tests exactly where the runtime had no refresh-token-only create path. -- GREEN attempt: - the same command initially reached 22/23 tests and exposed a regression in - `admin_accounts_lifecycle_should_update_and_delete_accounts`: label/status - updates returned `500` because the new pool sync tried to decrypt a deliberately - corrupt token row. -- Root-cause fix: - sync now reads the target pool account by ID, and label/status updates treat - pool sync as best-effort. Manual create/import still fail visibly if their own - valid account cannot be synced. -- GREEN: - `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - passed with 23/23 tests. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - (23/23 tests passed) -- `cargo test -p codex-proxy-adapters --test account_repository -- --nocapture` - (8/8 tests passed) -- `cargo test -p codex-proxy-runtime --test account_pool_restore -- --nocapture` - (1/1 test passed) -- `cargo test --test architecture test_migration -- --nocapture` - (3/3 filtered architecture migration tests passed) -- `cargo check --workspace --all-targets` -- `cargo fmt --all` -- `cargo fmt --all -- --check` -- `cargo test --test architecture -- --nocapture` - (26/26 architecture tests passed after adding `import_cli.rs` to the - architecture whitelist) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 9 changed files; index up to date with 275 files, 3,780 nodes, and - 10,931 edges) - -Current migration counters after this slice: - -- Approximately 358 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 35 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The highest-risk old manual account create/refresh-only/import-cli assertions - from `tests/admin/accounts/import_export.rs` are now represented in the server - crate-local suite. -- Other admin account old assertions are still not fully migrated, especially - OAuth-route edge cases and any import/export cases not covered by this batch. -- The largest remaining unmigrated suites are still WebSocket/serving behavior - suites called out below; this slice should not be read as full migration - completion. - -### 2026-06-19 Slice 73: Admin Account OAuth Route Migration - -Status: implemented and verified for this batch. - -Changes: - -- Migrated a broad batch of old `tests/admin/accounts/oauth.rs` behavior into - `crates/server/tests/admin_accounts_routes.rs`. -- Restored `GET /api/admin/auth/status`: - - requires an admin session cookie; - - reads account metadata without decrypting tokens; - - reports `authenticated`, a sanitized active user, and pool counts by status; - - does not expose access token, refresh token, or token aliases. -- Restored `POST /api/admin/auth/logout`: - - deletes all accounts and associated `account_usage`, `account_cookies`, and - `account_refresh_leases` rows explicitly; - - clears the runtime account pool so old accounts cannot still be scheduled. -- Restored device-code OAuth routes: - - `POST /api/admin/auth/device-login` calls the injected OAuth client and - returns device code, user code, verification URI, expiry, and poll interval; - - `GET /api/admin/auth/device-poll/{device_code}` maps - `authorization_pending` / `slow_down` to a non-importing pending response; - - successful polling imports the returned token pair through the same - admin-account create path used in Slice 72, preserving encryption, claims - derivation, and runtime pool sync while not returning secrets. -- Restored PKCE relay/callback flow: - - `POST /api/admin/auth/code-relay` parses a callback URL, exchanges - code/state through the injected OAuth client, imports the account, and - returns a sanitized success envelope; - - invalid callback URLs return `400`; - - `GET /auth/callback` exchanges code/state, imports the account, and - redirects back to the original admin host with `303 See Other`; - - unsupported legacy callback paths remain unregistered. -- `AdminOAuthService` now owns an injected `OAuthClient` port in addition to the - PKCE session store. -- Runtime service assembly now supports both production OpenAI OAuth clients and - test-injected OAuth clients. The test constructor still builds a full - `AppState`; only the OAuth upstream port is substituted. - -TDD evidence: - -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes admin_auth -- --nocapture` - failed before implementation because `AppState` had no - `with_pool_secret_api_key_hasher_and_oauth_client(...)` constructor. This - showed the runtime had no testable OAuth-client port for the old device/code - relay behaviors. -- GREEN: - the same filtered command passed with 11/11 OAuth tests after implementing the - runtime OAuth client port and server routes. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_auth -- --nocapture` - (11/11 filtered OAuth tests passed) -- `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - (33/33 tests passed) -- `cargo test -p codex-proxy-adapters --test account_repository -- --nocapture` - (8/8 tests passed) -- `cargo test -p codex-proxy-runtime --test account_pool_restore -- --nocapture` - (1/1 test passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture -- --nocapture` - (26/26 architecture tests passed) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 7 changed files; index up to date with 275 files, 3,841 nodes, and - 11,275 edges) - -Current migration counters after this slice: - -- Approximately 368 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 35 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The highest-risk old admin account OAuth route assertions are now represented - in the server crate-local suite. -- Some admin account import/export edge cases outside the Slice 72 and Slice 73 - batches still needed assertion-level comparison with the old baseline. - **Closed for the audited import/export edge cases in Slice 74.** -- The largest remaining unmigrated suites are still WebSocket/serving behavior - suites called out below; this slice should not be read as full migration - completion. - -### 2026-06-19 Slice 74: Admin Import/Export Strictness And Route Assertion Sweep - -Status: implemented and verified for this larger batch. - -Changes: - -- Migrated the remaining audited old `tests/admin/accounts/import_export.rs` - assertions into `crates/server/tests/admin_accounts_routes.rs`: - - `POST /api/admin/accounts/import` now has crate-local coverage for missing - admin session cookies; - - imported tokens are verified encrypted at rest and absent from list - responses; - - non-native external export shapes are rejected without inserting accounts; - - native account objects with unknown fields are rejected instead of silently - importing; - - native containers with unknown fields are rejected instead of silently - importing; - - account export filters by requested IDs and returns only native account token - material for the selected accounts; - - unsupported `format=external` and `format=full` exports are rejected. -- Tightened runtime native account import parsing: - - `parse_account_import_payload(...)` now returns `Result` instead of silently - filtering every shape; - - native top-level envelopes, native account containers, account arrays, and - single account objects use explicit key allow-lists; - - `sourceFormat` is accepted only when it is empty or `native`; - - current native export metadata fields (`addedAt` / `updatedAt`, and snake - case aliases) are accepted so native exports remain re-importable; - - legacy/external fields such as `type`, `legacy`, `credentials`, - `legacyField`, or `legacyContainer` now map to - `No importable accounts found`. -- Folded additional old `tests/admin/client_keys_route.rs` assertions into the - existing crate-local client-key suite: - - unknown client API keys are rejected before any key is created; - - re-enabled keys authorize `/v1/models` again; - - invalid key status values return `400`; - - deleted keys disappear from the list, no longer authorize, and repeated - deletion returns `404`. -- Expanded `crates/server/tests/admin_logs_routes.rs` from 3 to 9 tests using - old `tests/admin/logs_route.rs` behavior: - - log list requires an admin session cookie; - - cursor pagination includes `requestId`, page limit, first item, and - `nextCursor`; - - unsupported level filters return `400`; - - detail lookup returns `404` for missing events; - - log-state PATCH requires an admin session cookie; - - zero log capacity is rejected; - - clearing logs is verified by reading the empty list afterward. -- Expanded `crates/server/tests/admin_models_routes.rs` with old - `tests/admin/models_route.rs` assertions: - - admin model refresh now verifies configured fingerprint headers reach the - mocked Codex model endpoint; - - refreshed backend models are visible through `/v1/models/catalog` using a - stored client API key. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes admin_accounts_import -- --nocapture` - failed with 4/7 passing before implementation. The failures showed unknown - native account/container fields were accepted as `200`, and the non-native - shape error message was still `no importable accounts found` instead of the - old `No importable accounts found` surface. -- GREEN: - the same filtered import command passed with 7/7 after strict native import - parsing and the message restoration. -- Coverage-only migrations: - the added client-key, log, and model assertions passed after test migration, - so they document behavior already restored in earlier slices rather than - requiring new production changes. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_accounts_import -- --nocapture` - (RED first: 4/7 passed, 3 failed; GREEN after implementation: 7/7 passed) -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_accounts_export -- --nocapture` - (4/4 filtered export tests passed) -- `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - (41/41 tests passed) -- `cargo test -p codex-proxy-server --test admin_client_keys_routes -- --nocapture` - (7/7 tests passed) -- `cargo test -p codex-proxy-server --test admin_logs_routes -- --nocapture` - (9/9 tests passed) -- `cargo test -p codex-proxy-server --test admin_models_routes -- --nocapture` - (2/2 tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --tests -- --nocapture` - (136 server tests passed) -- `cargo test -p codex-proxy-runtime --tests -- --nocapture` - (30 runtime tests passed) -- `cargo test --test architecture -- --nocapture` - (26/26 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `codegraph sync . && codegraph status .` - (synced 5 changed files; index up to date with 275 files, 3,863 nodes, and - 11,376 edges) - -Current migration counters after this slice: - -- Approximately 382 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 35 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The audited remaining old admin account import/export assertions are now - represented in the server crate-local suite. -- The client-key route suite now carries the old missing assertions in the - current crate-local file. -- The logs route suite still does not reproduce every historical fixture line - one-for-one, but the old auth, cursor, filter, state, detail, and clear route - behaviors are covered at the crate boundary. -- The admin models route suite now verifies the old fingerprint-header and - catalog-read assertions. - -### 2026-06-19 Slice 75: Admin Session Login, Usage Stats, And API Contract Migration - -Status: implemented and verified for this larger batch. - -Changes: - -- Migrated admin session persistence behavior into - `crates/adapters/tests/admin_sessions.rs` and - `crates/adapters/src/sqlite/admin_sessions.rs`: - - the SQLite store can create a default admin user exactly once; - - the first admin user can be loaded with its password hash; - - sessions are created with a user ID and expiry timestamp; - - existing session validation and cleanup behavior remain in the adapter. -- Restored runtime admin login/session behavior in - `crates/runtime/src/services.rs`: - - `AdminSessionService` now owns default-admin bootstrap, credential - verification, session creation, and TTL assignment; - - admin password hashing/verification is delegated to `platform::identity`; - - username validation is delegated to the pure `core::admin::auth` service. -- Added `POST /api/admin/login` in `crates/server/src/admin_api/session.rs` and - routed it from `crates/server/src/admin_api/router.rs`: - - valid credentials return the standard admin envelope plus `expiresAt`; - - successful login sets the `cpr_admin_session` HTTP-only cookie; - - invalid credentials return the old `40102` admin error surface and do not - set a cookie. -- Restored server startup default-admin bootstrap in - `crates/server/src/main.rs`, guarded by - `tests/architecture/server_startup.rs`. -- Migrated remaining audited usage-stats assertions into - `crates/server/tests/admin_accounts_routes.rs`: - - account usage stats require an admin session cookie; - - cursor pagination preserves account usage ordering and `nextCursor`. -- Added API response contract tests in - `crates/server/tests/admin_api_contract.rs`: - - request IDs stay camelCase; - - page envelopes expose camelCase pagination; - - HTTP status stays outside successful admin envelope bodies; - - error bodies keep `data: null`; - - `AdminError` serializes through the current response shape. -- Added a platform password boundary test in - `crates/platform/tests/admin_password.rs` so admin password hashes do not - accept client API key strings. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-adapters --test admin_sessions -- --nocapture` - failed before implementation because the SQLite admin-session store did not - expose default-admin or session-creation persistence methods. -- RED: - `cargo test -p codex-proxy-server --test admin_session_routes -- --nocapture` - failed before implementation with `404` for `/api/admin/login`. -- RED: - `cargo test --test architecture server_entrypoint_should_ensure_default_admin_before_serving -- --nocapture` - failed before startup wiring because `main.rs` did not bootstrap the default - admin user from config. -- GREEN: - after implementation the adapter, server login, architecture startup, - platform password, usage-stats, and API contract test filters all passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test admin_sessions -- --nocapture` - (2/2 tests passed after RED) -- `cargo test -p codex-proxy-server --test admin_session_routes -- --nocapture` - (2/2 tests passed after RED) -- `cargo test --test architecture server_entrypoint_should_ensure_default_admin_before_serving -- --nocapture` - (1/1 filtered architecture test passed after RED) -- `cargo test -p codex-proxy-platform --test admin_password -- --nocapture` - (1/1 test passed) -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_usage_stats -- --nocapture` - (3/3 filtered usage-stat tests passed) -- `cargo test -p codex-proxy-server --test admin_api_contract -- --nocapture` - (5/5 tests passed) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --tests -- --nocapture` - (145 server tests passed) -- `cargo test -p codex-proxy-adapters --tests -- --nocapture` - (68 adapter tests passed) -- `cargo test -p codex-proxy-platform --tests -- --nocapture` - (21 platform tests passed) -- `cargo test -p codex-proxy-runtime --tests -- --nocapture` - (30 runtime tests passed) -- `cargo test --test architecture -- --nocapture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync .` -- `codegraph status .` - (index up to date with 279 files, 3,919 nodes, and 11,545 edges) - -Current migration counters after this slice: - -- Approximately 395 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 39 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The audited admin-session login/startup bootstrap behavior is represented in - crate-local server, runtime, adapter, platform, and architecture coverage. -- Usage-stats route authorization and cursor pagination are now represented in - the server crate-local suite. -- Old account lifecycle, cookie/quota, and broader serving/WebSocket suites - still need larger migrated coverage batches. -- The largest remaining unmigrated/high-risk area is still the old WebSocket and - serving behavior surface called out below; this slice should not be read as - full migration completion. - -### 2026-06-19 Slice 76: Admin Account Batch Status, Refresh, And Reset Usage Migration - -Status: implemented and verified for this larger batch. - -Changes: - -- Migrated old admin account lifecycle/cookie-quota behavior into - `crates/server/tests/admin_accounts_routes.rs`: - - `POST /api/admin/accounts/batch-status` updates found accounts and reports - missing IDs plus invalid statuses; - - `POST /api/admin/accounts/{account_id}/refresh` refreshes tokens, syncs the - runtime pool, and does not return secret material; - - refresh invalid-grant failures mark the account `expired`; - - `POST /api/admin/accounts/{account_id}/reset-usage` clears persisted local - counters and syncs the runtime pool. -- Added the persisted reset path in `SqliteAccountStore::reset_usage()` so - cumulative/window counters and `last_used_at` are cleared without changing the - retained quota window reset metadata. -- Added `AdminAccountService::batch_update_status()`, - `AdminAccountService::refresh_account()`, and - `AdminAccountService::reset_usage()` in `crates/runtime/src/services.rs`. -- Added server request/response handlers in - `crates/server/src/admin_api/accounts/lifecycle.rs` and routed them from - `crates/server/src/admin_api/router.rs`. -- Added `crates/core/tests/account_pool.rs` coverage for in-memory usage reset - semantics. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes admin_accounts_batch_status -- --nocapture` - failed before implementation with HTTP `405` instead of `200`. -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_refresh_should -- --nocapture` - failed before implementation with HTTP `404` instead of `200`. -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_reset_usage -- --nocapture` - failed before implementation with HTTP `404` instead of `200`. -- GREEN: - after implementation the batch-status, refresh, reset-usage, and core - account-pool reset filters all passed. - -Verification run so far: - -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_accounts_batch_status -- --nocapture` - (1/1 filtered test passed after RED) -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_refresh_should -- --nocapture` - (2/2 filtered tests passed after RED) -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_reset_usage -- --nocapture` - (1/1 filtered test passed after RED) -- `cargo test -p codex-proxy-core --test account_pool reset_usage_should_clear_runtime_counters_and_preserve_window_reset -- --nocapture` - (1/1 filtered test passed) -- `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - (47/47 tests passed) -- `cargo test -p codex-proxy-core --test account_pool -- --nocapture` - (26/26 tests passed) -- `cargo fmt --all` -- `codegraph sync . && codegraph status .` - (index up to date with 279 files, 3,948 nodes, and 11,879 edges) -- `cargo test -p codex-proxy-server --tests -- --nocapture` - (149 server tests passed) -- `cargo test -p codex-proxy-core --tests -- --nocapture` - (100 core tests passed) -- `cargo test -p codex-proxy-adapters --tests -- --nocapture` - (68 adapter tests passed) -- `cargo test -p codex-proxy-runtime --tests -- --nocapture` - (30 runtime tests passed) -- `cargo test --test architecture -- --nocapture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` - -Current migration counters after this slice: - -- Approximately 400 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 39 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The audited batch-status, manual refresh, invalid refresh-token expiry, and - reset-usage behavior from old admin account lifecycle/cookie-quota coverage is - now represented in crate-local server/core suites. -- Remaining old admin cookie/quota behavior still needs a focused pass for the - health-check OAuth-refresh semantics and exact request-field validation. -- The largest remaining unmigrated/high-risk area is still the old serving and - WebSocket behavior surface. - -### 2026-06-19 Slice 77: Admin Health-Check OAuth Refresh Migration - -Status: implemented and verified for the remaining old admin cookie/quota -health-check behavior. - -Changes: - -- Migrated the remaining `tests/admin/accounts/cookies_quota.rs` - health-check assertions into `crates/server/tests/admin_accounts_routes.rs`: - - health-check now skips active accounts that have no refresh token instead - of probing Codex usage and marking them alive; - - health-check uses the configured `TokenRefresher` to determine alive/dead - account status and does not touch the Codex usage backend; - - invalid refresh-token failures persist `expired` status; - - snake_case `stagger_ms` stays rejected by the camelCase API contract. -- Reworked `AdminAccountService::health_check_accounts()` to restore the old - refresh-token probe path with `buffer_unordered(concurrency)` and the existing - stagger delay. -- Added test-local `HealthCheckTokenRefresher` coverage so the server route - proves refresh calls, response sanitization, and persisted status changes. -- Re-ran empty-directory and placeholder scans after the implementation: - - empty directory scan returned no paths outside ignored locations; - - placeholder scan only matched architecture guard tests themselves; - - migration/compatibility keyword scan matched docs, architecture tests, - product compatibility wording, and old-format rejection tests, not new - migration shims in runtime/server source. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-server --test admin_accounts_routes health_check -- --nocapture` - failed 2/3 before implementation: - - `admin_account_health_check_should_skip_account_without_refresh_token` - reported `alive = 1` instead of `0`; - - `admin_accounts_health_check_should_refresh_oauth_without_touching_codex_backend` - reported `alive = 0` instead of `1`. -- The same RED run showed - `admin_accounts_health_check_should_reject_unsupported_stagger_ms_field` - already passed, confirming the route already rejected unknown snake_case - request fields. -- GREEN: - after implementation the health-check filter passed 3/3. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test admin_accounts_routes health_check -- --nocapture` - (3/3 filtered tests passed after RED) -- `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - (49/49 tests passed) -- `cargo test -p codex-proxy-runtime --tests -- --nocapture` - (30 runtime tests passed) -- `cargo test -p codex-proxy-server --tests -- --nocapture` - (151 server tests passed) -- `cargo test --test architecture -- --nocapture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `codegraph sync . && codegraph status .` - (synced 2 changed files; index up to date with 279 files, 3,955 nodes, and - 11,930 edges) -- `find . -path './.git' -prune -o -path './target' -prune -o -path './.codegraph' -prune -o -path './web/node_modules' -prune -o -type d -empty -print` - (no output) -- `rg "not wired yet|后续承载|todo!|unimplemented!|stub|placeholder" -n crates src tests --glob '!target/**'` - (only architecture placeholder guard tests matched) -- `rg "迁移|兼容|compat|legacy|shim|deprecated" -n crates src tests docs/architecture-audit.md docs/architecture.md --glob '!target/**'` - (matches were docs, architecture tests, product compatibility comments, and - old-format rejection tests) - -Current migration counters after this slice: - -- Approximately 402 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 39 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The old admin `cookies_quota.rs` behavior surface audited in this pass is now - represented in crate-local tests. -- The largest remaining unmigrated/high-risk area is still the old serving and - WebSocket behavior surface, especially the historical HTTP SSE/WebSocket - suites. - -### 2026-06-19 Slice 78: Responses Default Streaming Migration - -Status: implemented and verified for this focused HTTP SSE behavior. - -Changes: - -- Migrated the old `tests/codex_serving/responses_http_sse.rs` - `v1_responses_should_default_to_streaming_when_stream_is_omitted` behavior - into current crate-local coverage: - - `OpenAiResponsesRequest` now deserializes omitted `stream` as `true`; - - explicit `"stream": false` remains supported and continues to drive the - non-streaming dispatch path; - - `/v1/responses` with omitted `stream` returns an SSE - `response.failed` plus `data: [DONE]` when no upstream account is - available, matching the streaming error surface. -- Added protocol coverage in `crates/core/tests/protocol.rs`. -- Added route coverage in `crates/server/tests/openai_responses_routes.rs`. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-core --test protocol openai_response_request_should_default_missing_stream_to_true -- --nocapture` - failed because the translated Codex request had `stream == false`. -- RED: - `cargo test -p codex-proxy-server --test openai_responses_routes responses_route_should_default_omitted_stream_to_sse -- --nocapture` - failed with HTTP `503` instead of streaming HTTP `200`. -- GREEN: - after changing the request default, both filtered tests passed. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol openai_response_request_should_default_missing_stream_to_true -- --nocapture` - (1/1 filtered test passed after RED) -- `cargo test -p codex-proxy-server --test openai_responses_routes responses_route_should_default_omitted_stream_to_sse -- --nocapture` - (1/1 filtered test passed after RED) -- `cargo test -p codex-proxy-core --test protocol -- --nocapture` - (47/47 tests passed) -- `cargo test -p codex-proxy-server --test openai_responses_routes -- --nocapture` - (5/5 tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (63/63 tests passed) -- `cargo test --test architecture -- --nocapture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- `cargo test -p codex-proxy-server --tests -- --nocapture` - (152 server tests passed) -- `codegraph sync . && codegraph status .` - (synced 3 changed files; index up to date with 279 files, 3,958 nodes, and - 11,927 edges) - -Current migration counters after this slice: - -- Approximately 404 `#[test]` / `#[tokio::test]` occurrences. -- 15 root Rust test files, all architecture tests. -- 39 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- One old HTTP SSE default-streaming behavior is restored. -- The old Responses HTTP SSE suite still has broader gaps around review/compact - routes, tuple schema reconversion, passive rate-limit caching, cookie path - scoping, and disconnect behavior. -- The old Responses WebSocket and gateway WebSocket suites remain the largest - high-risk migration areas. - -### 2026-06-19 Slice 79: Responses HTTP SSE Review/Compact/Tuple Migration - -Status: implemented and verified as one continuous migration batch from the old -`tests/codex_serving/responses_http_sse.rs` suite. - -Changes: - -- Migrated the old review route behavior: - `/v1/responses/review` now forces `x-openai-subagent=review` into the Codex - upstream request metadata and header path instead of behaving like a plain - `/v1/responses` call. -- Migrated the old compact route behavior: - `/v1/responses/compact` now builds a `CodexCompactRequest`, dispatches to - `/codex/responses/compact`, keeps JSON response semantics, strips - Responses-only fields such as `stream`, `store`, and `prompt_cache_key`, and - uses the same account fallback classifications for rate limit/quota/auth/model - errors. -- Expanded `OpenAiResponsesRequest` back toward the old request surface so - `instructions`, `reasoning`, `tools`, `tool_choice`, - `parallel_tool_calls`, `text.format`, `include`, `client_metadata`, - context fields, and transport hints are parsed and translated. -- Restored tuple schema conversion for Responses HTTP SSE: - - tuple-shaped JSON schema is converted before the Codex upstream request; - - non-streaming completed Responses output is reconverted for the client; - - live streaming SSE `response.output_text.delta`, - `response.output_item.done`, and `response.completed` events are - reconverted event-by-event when a tuple schema is present, without buffering - the whole stream. -- Added crate-local migration coverage in: - - `crates/core/tests/protocol.rs`; - - `crates/server/tests/openai_chat_upstream.rs`. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-core --test protocol openai_` failed at compile - time because `translate_response_to_compact` did not exist. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_review_route_should_force_review_subagent_upstream` - failed with HTTP `502` instead of `200`, proving the review route was not - forcing the upstream subagent. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_compact` - failed because the compact route still used the normal Responses/SSE handler - and returned a non-JSON error body. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream tuple` - showed the upstream schema conversion and non-stream response reconversion - passing, but `responses_stream_should_reconvert_tuple_schema_output_for_client` - failed because live SSE was still forwarding the Codex tuple object form - unchanged. -- GREEN: - after implementing compact dispatch, review forcing, request translation, and - tuple SSE event reconversion, the focused review/compact/tuple tests passed. - -Verification run so far: - -- `cargo fmt --all` -- `cargo test -p codex-proxy-core --test protocol openai_` - (7/7 filtered tests passed after RED) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_review_route_should_force_review_subagent_upstream` - (1/1 filtered test passed after RED) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_compact` - (3/3 filtered tests passed after RED) -- `cargo test -p codex-proxy-server --test openai_chat_upstream tuple` - (3/3 filtered tests passed after RED) -- `cargo test -p codex-proxy-server --tests` - (159 server tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (50/50 protocol tests passed) -- `cargo test --test architecture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- Empty directory scan excluding `.git`, `target`, and `web/node_modules` found - no empty source directories. A raw scan only found - `web/node_modules/.vite-temp`, which is generated dependency-cache state and - was not removed. -- Placeholder-marker scan only matched the architecture guard tests that define - the forbidden markers. -- `codegraph sync . && codegraph status .` - (synced 5 changed files; index up to date with 279 files, 4,011 nodes, and - 12,197 edges) - -Current migration counters after this slice: - -- Approximately 414 `#[test]` / `#[tokio::test]` occurrences. -- 59 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 39 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The review, compact, and tuple-schema portion of the old Responses HTTP SSE - suite is now represented in crate-local tests. -- The next HTTP SSE audit pass should stay on the old suite order and - cross-check passive rate-limit caching, cookie path scoping, disconnect/live - stream audit behavior, and any remaining old fixture/golden gaps before - moving away from this historical suite. -- The old Responses WebSocket and gateway WebSocket suites remain high-risk - migration areas, but they should be handled by following their original test - surfaces rather than by unrelated module-by-module cleanup. - -### 2026-06-19 Slice 80: Responses HTTP SSE Non-Stream Side-Effects Migration - -Status: implemented and verified as the next migration batch from the old -`tests/codex_serving/responses_http_sse.rs` suite, following the old -non-stream success/side-effect path instead of doing directory-driven cleanup. - -Changes: - -- Migrated old HTTP SSE non-stream side effects into crate-local server tests: - - imported account dispatch with `authorization` and `chatgpt-account-id` - headers; - - non-stream account usage persistence and `v1.response` event-log metadata; - - logging-disabled skip behavior; - - successful and failed image-generation usage counters; - - upstream `Set-Cookie` capture for `cf_clearance`; - - cookie replay scoped by `/codex/responses` path; - - passive rate-limit header caching into `accounts.quota_json`, - cooldown/verify flags, and `account_usage` window metadata; - - empty completed response retry exhaustion with three recorded empty - attempts. -- Added adapter-local cookie migration coverage in - `crates/adapters/tests/cookies.rs` for captured `cf_clearance`, path/domain - replay, and ignoring non-capturable `Set-Cookie` headers. -- Restored `SqliteCookieStore::capture_set_cookie()` with the old allowlist and - Domain/Path/Max-Age/Expires parsing semantics. -- Extended the account usage port so runtime can record empty responses and - image request success/failure counters without bypassing `core` boundaries. -- Added account store port methods for quota JSON read/write and rate-limit - window sync, with SQLite implementations ported from the old repository - behavior. -- Updated `ResponseDispatchService::complete` to capture cookies, sync passive - rate-limit state, record enhanced Responses usage, record non-stream event - logs through `AdminLogService`, and retry empty completed responses twice - before returning the upstream-response error. -- Updated runtime cookie replay to request cookies for `/codex/responses` and - `/codex/responses/compact`, preserving old path-scoped cookie ordering. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_use_imported_account_record_usage_cookie_and_event_log` - failed to compile because `SqliteCookieStore::capture_set_cookie()` did not - exist, proving the old cookie capture API had not been migrated. -- RED: - after adding the adapter API, - `responses_should_use_imported_account_record_usage_cookie_and_event_log` - failed because the persisted cookie header was `None` instead of - `Some("cf_clearance=new")`, proving the non-stream runtime path still ignored - upstream `Set-Cookie` side effects. -- GREEN: - after wiring adapter/runtime/core behavior, the imported-account/cookie/log - test and the image usage, failed image attempt, cookie path, passive - rate-limit, empty-response, and logging-disabled tests all passed. - -Verification run so far: - -- `cargo test -p codex-proxy-adapters --test cookies` - (2/2 cookie tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_use_imported_account_record_usage_cookie_and_event_log` - (1/1 filtered test passed after RED) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_image_generation` - (1/1 filtered success-image test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_failed_image_generation_attempt_when_tool_has_no_output` - (1/1 filtered failed-image test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_scope_upstream_cookie_by_codex_response_path` - (1/1 filtered cookie-path test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_passively_cache_rate_limit_headers` - (1/1 filtered passive rate-limit test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_empty_response_attempts` - (1/1 filtered empty-response test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_skip_event_log_when_logging_disabled` - (1/1 filtered logging-disabled test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_` - (34/34 filtered Responses tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (77/77 upstream tests passed after final clippy cleanup) -- `cargo test -p codex-proxy-adapters --tests` - (70 adapter tests passed, including the new cookie tests) -- `cargo test -p codex-proxy-core --tests` - (104 core tests passed) -- `cargo test -p codex-proxy-runtime --tests` - (30 runtime tests passed) -- `cargo test -p codex-proxy-server --tests` - (166 server tests passed) -- `cargo test --test architecture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- Empty directory scan excluding `.git`, `target`, `.codegraph`, and - `web/node_modules` found no empty source directories. -- Placeholder-marker scan only matched the architecture guard tests that define - the forbidden markers. -- `codegraph sync . && codegraph status .` - (synced 4 changed code files before this doc update; index up to date with - 280 files, 4,055 nodes, and 12,310 edges) - -Current migration counters after this slice: - -- Approximately 423 `#[test]` / `#[tokio::test]` occurrences. -- 60 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The old HTTP SSE non-stream side-effect block around imported accounts, - image usage, cookie path scoping, passive rate-limit caching, empty-response - retries, and logging-disabled behavior is now represented in crate-local - tests. -- The next HTTP SSE pass should continue down the same old suite and verify - parity fields/context headers, reasoning include defaults, sanitized - reasoning/compaction input, non-stream text reconstruction, done-item fallback, - passthrough stream logging, and downstream disconnect behavior. -- The old Responses WebSocket and gateway WebSocket suites remain high-risk, - but should be handled only when the migration line reaches those suites. - -### 2026-06-19 Slice 81: Responses HTTP SSE Parity/Reasoning/Stream Disconnect Migration - -Status: implemented and verified as the next contiguous batch from the old -`tests/codex_serving/responses_http_sse.rs` suite. This slice stayed on the -HTTP SSE migration line and did not split work by module. - -Changes: - -- Added a core pure `apply_response_model_options(...)` policy and runtime - wiring so Responses requests now apply: - - parsed model id; - - reasoning effort from the request body, model suffix, or model config; - - default reasoning summary `auto`; - - default `include: ["reasoning.encrypted_content"]` only when the client did - not send a non-empty include list; - - service tier from request body, model suffix, or config; - - upstream service-tier normalization from `fast` to `priority`. -- Added `ModelService::config()` so runtime dispatch can apply the same model - option rules without duplicating model configuration. -- Restored Responses prompt-cache/conversation identity behavior in runtime: - - `complete(...)` and `stream(...)` call `ensure_prompt_cache_key(...)` after - previous-response affinity lookup; - - Codex upstream context now uses `build_conversation_identity(...)` to pass - account-scoped `session_id` and `x-codex-window-id`. -- Restored `/v1/responses` header-context fallbacks for - `x-codex-turn-state`, `x-codex-turn-metadata`, `x-codex-beta-features`, - `x-responsesapi-include-timing-metrics`, `version`, - `x-codex-window-id`, and `x-codex-parent-thread-id`, while preserving body - fields when present. -- Restored normal-route `x-openai-subagent` propagation for valid subagent - headers; the review route still forces `review`. -- Fixed WebSocket pool reuse for previous-response requests after prompt-cache - restoration by keying the adapter pool on `previous_response_id` when the - client did not explicitly provide a prompt cache key. -- Restored downstream disconnect propagation for live HTTP SSE streams: - dropping the downstream body sends a cancellation signal, releases the account - promptly, and drops the upstream SSE body/socket. -- Added crate-local tests for parity fields/context headers, include - preservation, reasoning/compaction sanitization, text-delta reconstruction, - done-item fallback, stream disconnect, and pure model-option defaults. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-core --test protocol codex_responses_model_options_should_apply_suffix_defaults_and_include_reasoning` - failed to compile because `apply_response_model_options(...)` did not exist. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_forward_parity_fields_context_headers_and_account_scoped_identity` - failed with upstream `service_tier` `null` instead of `"priority"`. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should_close_http_sse_upstream_when_client_disconnects` - failed because the upstream socket stayed open after the downstream body was - dropped. -- Regression found during GREEN verification: - `responses_with_previous_response_id_should_use_websocket_and_configured_pool` - accepted two upstream WebSocket connections instead of one after prompt-cache - keys were restored; the adapter pool key now preserves old previous-response - reuse semantics. -- GREEN: - the focused core model-option tests, new server parity/reasoning/sanitization/ - reconstruction/done-item/disconnect tests, and the existing previous-response - WebSocket pool test passed after implementation. - -Verification run for this slice: - -- `cargo test -p codex-proxy-core --test protocol codex_responses_model_options` - (2/2 filtered model-option tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (52/52 protocol tests passed) -- `cargo test -p codex-proxy-core --tests` - (all core tests passed) -- `cargo test -p codex-proxy-runtime --tests` - (all runtime tests passed) -- `cargo test -p codex-proxy-adapters --tests` - (70/70 adapter tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (83/83 upstream tests passed) -- `cargo test -p codex-proxy-server --tests` - (all server tests passed) -- `cargo test --test architecture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- Empty directory scan excluding `.git`, `target`, `.codegraph`, and - `web/node_modules` found no empty source directories. -- Placeholder-marker scan matched only architecture guard tests and expected API - compatibility/alias text; no source placeholder markers were found outside - those guard/compatibility contexts. -- `codegraph sync . && codegraph status .` - (synced 7 changed files before this doc update; index up to date with - 280 files, 4,082 nodes, and 12,430 edges) - -Current migration counters after this slice: - -- Approximately 431 `#[test]` / `#[tokio::test]` occurrences. -- 60 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The old HTTP SSE tail around parity fields, reasoning include defaults, - sanitized reasoning/compaction input, non-stream text reconstruction, - done-item fallback, stream usage/logging coverage, and downstream disconnect - behavior is now substantially represented in crate-local tests. -- The next migration pass should first audit the earlier old HTTP SSE tests for - any assertion-level gaps that were only partially covered, then move to the - old Responses WebSocket and gateway WebSocket suites in their original - behavior order. - -### 2026-06-19 Slice 82: Responses HTTP SSE Early-Block/Stagger Migration - -Status: implemented and verified as the assertion-level pass over the earlier -block of the old `tests/codex_serving/responses_http_sse.rs` suite. This stayed -on the same historical HTTP SSE behavior line. - -Changes: - -- Added crate-local server tests for old early-block HTTP SSE behavior: - invalid JSON rejection without upstream traffic, non-object JSON rejection - without upstream traffic, no-accounts Responses error shape, explicit - `use_websocket: false` HTTP SSE transport, and same-account request - staggering before sending upstream. -- Restored the non-streaming `/v1/responses` no-available-accounts response - body to the old Responses-specific shape with top-level `type: "error"` and - `error.code: "no_available_accounts"`, matching the already-migrated compact - route. -- Restored runtime request-interval staggering: - `RuntimeAccountPoolService` now stores `request_interval_ms`, uses the - `previous_slot_at` metadata returned by the core account pool, and waits in - runtime before Chat/Responses upstream dispatch. The async sleep remains in - `runtime`; `core` still only owns pure scheduling metadata. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_no_available_accounts_error_when_no_accounts_are_available` - failed because the non-streaming Responses path returned the generic - OpenAI-style error body, leaving top-level `type` as `null` instead of - `"error"`. -- GREEN: - after mapping `NoActiveAccount` / `AccountStore` through - `responses_no_available_accounts_response()`, the no-accounts test passed. -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_stagger_same_account_requests_before_sending_upstream` - failed because the second upstream request was sent after about 1.4ms instead - of waiting for the configured request interval. -- GREEN: - after adding runtime request-interval waiting from `previous_slot_at`, the - same stagger test passed. - -Verification run for this slice: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_reject` - (2/2 invalid-json/non-object tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_honor_explicit_http_sse_transport` - (1/1 filtered explicit HTTP SSE test passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_` - (44/44 filtered Responses tests passed) -- `cargo test -p codex-proxy-runtime --tests` - (all runtime tests passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (88/88 upstream tests passed) -- `cargo test -p codex-proxy-server --tests` - (all server tests passed) -- `cargo test --test architecture` - (27/27 architecture tests passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `git diff --check` -- Empty directory scan excluding `.git`, `target`, `.codegraph`, and - `web/node_modules` found no empty source directories. -- Placeholder-marker scan matched only architecture audit text and architecture - guard tests; no source placeholder markers were found outside those contexts. -- `codegraph sync . && codegraph status .` - (synced 3 changed files after implementation; index up to date with - 280 files, 4,089 nodes, and 12,464 edges) - -Current migration counters after this slice: - -- 436 `#[test]` / `#[tokio::test]` occurrences. -- 60 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The old Responses HTTP SSE suite has crate-local coverage for the early - rejection/no-account/explicit-transport/stagger block, the review/compact/ - tuple block, the non-stream side-effect block, and the parity/reasoning/ - disconnect tail. Further work should only revisit this suite for exact - assertion-level gaps found by direct old/new comparison. -- The next migration stage should move to the old Responses WebSocket and - gateway WebSocket suites in their original behavior order, not to unrelated - directory cleanup. - -### 2026-06-19 Slice 83: Responses WebSocket Default/Pool/Metadata Bulk Migration - -Status: implemented and verified as the first large contiguous block from the -old `tests/codex_serving/responses_websocket.rs` suite. - -Changes: - -- Migrated old Responses WebSocket assertions into crate-local server tests for: - default `/v1/responses` WebSocket upstream while still serving downstream SSE, - ignored camelCase `useWebSocket`, first WebSocket stream frame reaching the - downstream client before terminal completion, synthesized - `response.failed(stream_disconnected)` after early upstream WebSocket close, - recorded-conversation WebSocket pool reuse, disabled-pool non-reuse while - keeping the recorded conversation key, and WebSocket `response.metadata` - turn-state persistence for continuation requests. -- Restored the old default transport: Responses requests without - `use_websocket: false` now remain `WebSocketPreferred`; only snake_case - `use_websocket: false` forces HTTP SSE. -- Adjusted HTTP-SSE-specific server tests to opt into `use_websocket: false` - explicitly, so those tests keep covering HTTP behavior after the default - WebSocket route was restored. -- Fixed WebSocket pool key selection so continuation requests use the restored - conversation/prompt-cache key before falling back to `previous_response_id`. - This lets a response recorded in session affinity reuse the original pooled - WebSocket. -- Propagated live WebSocket `response.metadata` turn-state updates from the - adapter stream into runtime stream finalization so completed streaming - responses record the latest turn state in session affinity. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-server --test openai_chat_upstream responses_websocket -- --nocapture` - first failed because the new tests referenced a missing local helper. After - adding the helper, the same filter failed for the intended behavior gaps: - default/no-history Responses requests were still sent as HTTP POST instead of - WebSocket, disabled/default pool cases did not receive WebSocket payloads, and - WebSocket metadata turn state was not present on the continuation request. -- GREEN: - after restoring WebSocketPreferred defaults and adding live turn-state - propagation, four of the five WebSocket-filtered tests passed. The remaining - recorded-conversation pool test still opened a second socket because the - adapter pool key preferred `previous_response_id` over the restored - conversation key. -- GREEN: - after changing pool-key priority to - `prompt_cache_key -> client_conversation_id -> previous_response_id`, the - filtered WebSocket block passed 5/5. -- Regression cleanup: - the full server upstream suite initially exposed HTTP-SSE fixture pollution - after the default transport change. Those HTTP-specific tests were made - explicit with `use_websocket: false`; `previous_response_id` WebSocket tests - were kept on the WebSocketRequired path. - -Verification run for this slice: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_websocket -- --nocapture` - (RED then GREEN; final run 5/5 passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_use_websocket_upstream_by_default_while_serving_sse -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_ignore_camel_case_use_websocket_field -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` - (95/95 passed) -- `cargo test -p codex-proxy-core --test protocol websocket -- --nocapture` - (25/25 filtered WebSocket/protocol tests passed) -- `cargo test -p codex-proxy-core --test protocol` - (52/52 passed) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (26/26 filtered WebSocket adapter tests passed) -- `cargo test -p codex-proxy-adapters --test codex pooled_websocket -- --nocapture` - (7/7 filtered pooled WebSocket tests passed) -- `cargo test -p codex-proxy-runtime --tests` - (all runtime tests passed) -- `cargo fmt --all` -- `cargo fmt --all -- --check` -- `codegraph sync . && codegraph status .` - (synced 5 changed files; index up to date with 280 files, 4,100 nodes, and - 12,495 edges) - -Current migration counters after this slice: - -- 443 `#[test]` / `#[tokio::test]` occurrences. -- 60 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. - -Remaining after this slice: - -- The first seven old Responses WebSocket behaviors are now represented in - crate-local server coverage. -- The next Responses WebSocket block should migrate the implicit resume, - reasoning replay, function-call replay exclusion, SQLite-restored affinity, - cross-window exclusion, replay eviction, restored-full-history retry, - admin-status pool eviction, and fallback/error-account routing cases from the - rest of old `tests/codex_serving/responses_websocket.rs`. -- The old gateway WebSocket suite remains the largest unmigrated pure - adapter/protocol behavior block after the Responses WebSocket suite. - -### 2026-06-19 Slice 84: Responses WebSocket Implicit Resume / Reasoning Replay Bulk Migration - -Status: implemented and verified as the next contiguous block from the old -`tests/codex_serving/responses_websocket.rs` suite. - -Changes: - -- Added pure serving modules for WebSocket implicit resume and reasoning replay: - `crates/core/src/serving/implicit_resume.rs` and - `crates/core/src/serving/reasoning_replay.rs`. -- Extended Responses protocol metadata extraction so completed WebSocket - responses can record replayable reasoning items, function-call IDs, - instructions hash, conversation identity, variant hash, and usage input - tokens. -- Extended session affinity so runtime can match the latest response by - conversation plus request variant, reject cross-window/cross-variant implicit - resume, and distinguish unmatched function-call-output continuations. -- Updated Responses dispatch to: - - apply implicit resume when the client omits `previous_response_id`; - - prepend cached reasoning replay items only when the continuation matches; - - restore the full original request history when implicit resume recovery is - rejected by upstream `previous_response_not_found`; - - evict reasoning replay cache entries after invalid encrypted content; - - record affinity/replay metadata after non-streaming and streaming WebSocket - completions. -- Updated admin account status changes to evict pooled WebSockets for both the - internal account id and the upstream `chatgpt-account-id`, matching the pool - key used by live sockets. - -Migrated tests: - -- `responses_websocket_should_implicitly_resume_full_history_with_reasoning_replay` -- `responses_websocket_should_not_implicitly_resume_unmatched_function_call_output` -- `responses_websocket_should_implicitly_resume_after_sqlite_affinity_restore` -- `responses_websocket_pool_should_be_evicted_after_admin_account_status_cycle` -- `responses_websocket_should_not_implicitly_resume_self_contained_function_call_replay` -- `responses_websocket_should_not_implicitly_resume_across_codex_windows` -- `responses_websocket_should_evict_reasoning_replay_after_invalid_encrypted_content` -- `responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing` - -TDD/debug evidence: - -- The new WebSocket implicit-resume tests initially exposed that the translator - was forwarding the downstream non-streaming `stream: false` flag into the - upstream Codex request. The old service used the OpenAI `stream` flag only for - downstream response shape and still sent Codex Responses requests with the - upstream streaming default. Removing that assignment restored old behavior. -- The admin status-cycle test initially showed pooled sockets were not evicted - because the pool key uses upstream `chatgpt-account-id`, while the admin route - receives the internal account id. Evicting both identifiers closed the gap. - -Verification run for this slice: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream implicitly_resume -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream implicit_resume -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream reasoning_replay -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream affinity_restore -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream status_cycle -- --nocapture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (103/103 passed) -- `cargo test -p codex-proxy-core --tests` -- `cargo test -p codex-proxy-adapters --test codex codex_backend_client_should_close_idle_pooled_websocket_when_account_is_evicted -- --nocapture` -- `cargo test -p codex-proxy-runtime --tests` -- `cargo test -p codex-proxy-adapters --tests` -- `cargo test -p codex-proxy-server --test admin_accounts_routes -- --nocapture` - (49/49 passed) -- `cargo fmt --all` -- `cargo fmt --all -- --check` -- `cargo check --workspace --tests` -- `codegraph sync . && codegraph status .` - (index up to date with 282 files, 4,198 nodes, and 12,743 edges) - -Current migration counters after this slice: - -- 454 `#[test]` / `#[tokio::test]` occurrences. -- 62 Rust files contain tests. -- 14 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. -- Empty source-file scan found no empty files under `tests`, `crates`, or `src`. -- Empty-directory scan found no empty directories outside ignored locations - after excluding `.git`, `target`, `.codegraph`, and `web/node_modules`. - -Remaining after this slice: - -- The old Responses WebSocket default/pool/metadata and implicit - resume/reasoning-replay blocks are now represented in crate-local server - coverage. -- The remaining Responses WebSocket block should continue with fallback and - account-status routing cases from the old suite: previous-response routing, - WebSocket 429 fallback, 401/402/model/path-block no-fallback stream errors, - quota-classified `response.failed`, and any pool-specific audit artifact - edges still not represented. -- The old gateway WebSocket suite remains a high-risk unmigrated adapter/protocol - behavior block after the Responses WebSocket suite. - -### 2026-06-19 Slice 85: Responses WebSocket Fallback / Status Routing Bulk Migration - -Status: implemented and verified for the remaining audited fallback/status -block from old `tests/codex_serving/responses_websocket.rs`. - -Changes: - -- Migrated old Responses WebSocket fallback/status assertions into - `crates/server/tests/openai_chat_upstream.rs` for: - - previous-response-id routing to the recorded account; - - WebSocket 429 fallback for streaming and non-streaming continuations; - - fallback exhaustion after mixed 429 -> 401 errors; - - WebSocket 402 quota exhaustion with no fallback; - - model-unsupported and Cloudflare path-block stream failures with no - fallback; - - WebSocket `response.failed` quota classification and fallback retry; - - rate-limit exhaustion across two fallback accounts. -- Updated WebSocket opening failures in `crates/adapters` so non-101 upgrade - responses preserve upstream HTTP status, retry-after, and body for runtime - fallback classification instead of collapsing into an opaque transport error. -- Kept `WebSocketPreferred` HTTP SSE fallback only for non-upstream transport - failures. Business/status errors from the WebSocket upgrade now reach runtime - fallback logic. -- Kept history recovery retries on WebSocket instead of forcing HTTP SSE after - stripping `previous_response_id`. Older crate-local history recovery tests - were updated to assert WebSocket -> WebSocket retry, matching the old - Responses WebSocket suite. -- Added last-exhausted-account classification for Responses dispatch fallback - exhaustion so mixed failures such as 429 followed by 401 return the final - authentication failure instead of a stale rate-limit aggregate. -- Preserved current aggregate error wording while restoring the old WebSocket - stream fragments required by the baseline for model-unsupported and - Cloudflare path-block no-fallback cases. -- Updated `docs/architecture.md` and the architecture source-tree whitelist for - `core/src/serving/implicit_resume.rs` and - `core/src/serving/reasoning_replay.rs`, which were introduced in Slice 84. - -Migrated tests added in this slice: - -- `responses_websocket_should_route_previous_response_id_to_recorded_account` -- `responses_websocket_non_stream_previous_response_not_found_should_strip_history_and_retry_same_account` -- `responses_websocket_stream_previous_response_not_found_should_strip_history_and_retry_same_account` -- `responses_websocket_non_stream_unanswered_function_call_should_strip_history_and_retry_same_account` -- `responses_websocket_previous_response_id_should_retry_fallback_account_after_429` -- `responses_websocket_non_stream_previous_response_id_should_retry_fallback_account_after_429` -- `responses_websocket_without_history_should_mark_expired_after_fallback_401` -- `responses_websocket_without_history_should_return_rate_limit_stream_error_when_fallback_accounts_exhausted` -- `responses_websocket_response_failed_quota_should_retry_fallback_account` -- `responses_websocket_without_history_should_return_quota_stream_error_when_402_has_no_fallback` -- `responses_websocket_without_history_should_return_model_unsupported_stream_error_when_no_fallback` -- `responses_websocket_with_history_should_return_path_block_stream_error_when_no_fallback` - -TDD/debug evidence: - -- Initial focused run of `responses_websocket` failed for WebSocket upgrade - status handling, history recovery transport selection, pool-reuse test - coverage, and no-fallback stream error fragments. The new tests now exercise - those paths directly. -- The mixed 429 -> 401 case identified a real runtime aggregation bug: fixed - priority returned rate-limit when the last exhausted account failed auth. The - runtime now records the last exhaustion class for Responses complete/stream - dispatch. -- The `strip_history` regression run exposed stale crate-local tests that still - expected WebSocket recovery to fall back to HTTP SSE. Those helpers now model - WebSocket failure followed by WebSocket success, matching the migrated old - Responses WebSocket behavior. -- `cargo test --test architecture` exposed that the Slice 84 core serving files - were not reflected in the architecture whitelist; the architecture document - and test whitelist now match the actual tree. - -Verification run for this slice: - -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_websocket -- --nocapture` - (25/25 passed) -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history -- --nocapture` - (9/9 passed) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (26/26 passed) -- `cargo test -p codex-proxy-adapters --tests` -- `cargo test -p codex-proxy-runtime --tests` -- `cargo test -p codex-proxy-server --test openai_chat_upstream -- --nocapture` - (115/115 passed) -- `cargo check --workspace --tests` -- `cargo fmt --all -- --check` -- `cargo test --test architecture` - (27/27 passed) -- `cargo test --workspace --tests` -- `codegraph sync .` -- `codegraph status .` - (index up to date with 282 files, 4,228 nodes, and 12,874 edges) -- Empty file scan under `tests`, `crates`, and `src` found no empty Rust/TOML/MD - files. -- Empty-directory scan found no empty directories outside ignored `.git`, - `target`, `.codegraph`, and `web/node_modules` paths. - -Current migration counters after this slice: - -- 466 `#[test]` / `#[tokio::test]` occurrences. -- 62 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. -- `crates/server/tests/openai_chat_upstream.rs` now has 115 tests. - -Remaining after this slice: - -- The audited old Responses WebSocket fallback/status block is now represented - in crate-local server coverage alongside the default/pool/metadata and - implicit-resume/reasoning-replay blocks. -- The old gateway WebSocket suite remains a high-risk adapter/protocol behavior - block for the next migration stage, especially pure codec diagnostics, - opening/audit artifacts, transport edge cases, and pool parity. -- Continue auditing old pre-migration behavior against - `f07442a28b0c186bd22b598a53cc4856ab4b2445`; passing current workspace tests - still does not by itself prove all old behavior suites have been migrated. - -### 2026-06-19 Slice 86: Gateway WebSocket Adapter/Protocol Bulk Migration - -Status: implemented and verified for the next audited block from old -`tests/codex_gateway/websocket.rs` and `tests/codex_gateway/websocket/pool.rs`. - -Changes: - -- Migrated old gateway WebSocket pool parity into - `crates/adapters/tests/codex.rs` for: - - bypassing a busy pooled key with one-shot connections; - - bypassing new conversation keys after the per-account pool cap; - - evicting idle connections when maintenance ping times out; - - garbage-collecting expired idle connections. -- Migrated old gateway WebSocket adapter/protocol boundary coverage for: - - opening failure status/body/retry-after preservation; - - binary upstream frames surfacing as explicit WebSocket errors instead of - being silently ignored until close; - - wrapped `error` frames preserving explicit status and `retry-after`; - - `websocket_connection_limit_reached` mapping to retryable 503; - - internal `codex.rate_limits` events updating captured rate-limit headers - without forwarding to downstream SSE; - - `response.metadata` turn-state updates without downstream forwarding; - - live stream partial output followed by close-before-terminal error. -- Added `CodexWebSocketExchangeError::UnexpectedBinaryEvent` and wired both - buffered and live-stream WebSocket paths to emit it for upstream binary - events before a terminal response. -- Kept Ping/Pong and Close handling separate: ping/pong/control frames continue - through tungstenite behavior, close before terminal remains - `ClosedBeforeTerminal`, and binary application data is now a distinct - protocol error. - -Migrated tests added in this slice: - -- `websocket_pool_should_bypass_busy_key_with_one_shot_connections` -- `websocket_pool_should_bypass_new_keys_after_account_cap` -- `websocket_pool_should_evict_idle_connection_when_ping_times_out` -- `websocket_pool_should_gc_expired_idle_connections` -- `websocket_execute_response_create_request_should_preserve_opening_error_status_body_and_retry_after` -- `websocket_execute_response_create_request_should_reject_binary_event` -- `websocket_execute_response_create_request_should_surface_wrapped_error_status_and_retry_after` -- `websocket_execute_response_create_request_should_surface_connection_limit_as_503` -- `websocket_execute_response_create_request_should_capture_internal_metadata_and_rate_limit_events` -- `codex_backend_client_stream_should_reject_binary_websocket_event` -- `codex_backend_client_stream_should_error_when_websocket_closes_before_terminal` - -Verification run for this slice so far: - -- `cargo fmt -p codex-proxy-adapters` -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (37/37 passed) -- `cargo test -p codex-proxy-adapters --tests` - (`crates/adapters/tests/codex.rs`: 55/55 passed) -- `codegraph sync . && codegraph status .` - (index up to date with 282 files, 4,243 nodes, and 12,942 edges) -- `cargo fmt --all -- --check` -- `cargo check --workspace --tests` -- `cargo test --test architecture` - (27/27 passed) -- `cargo test --workspace --tests` - (workspace test suite passed; notable migrated suites: - `crates/adapters/tests/codex.rs` 55/55, - `crates/server/tests/openai_chat_upstream.rs` 115/115) - -Current migration counters after this slice: - -- 477 `#[test]` / `#[tokio::test]` occurrences. -- 62 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. -- `crates/adapters/tests/codex.rs` now has 55 tests, including 37 WebSocket - filtered tests. - -Remaining after this slice: - -- The old gateway WebSocket transport/pool/status/internal-event block is now - represented in crate-local adapter/core coverage instead of root behavior - tests. -- Remaining gateway WebSocket risk is narrower: byte-for-byte capture harness - parity, old audit artifact edge cases not represented by current redacted - artifacts/event logs, and any assertion-level official-client event-shape - cases not already covered by the pure core validators. -- Continue auditing old pre-migration behavior against - `f07442a28b0c186bd22b598a53cc4856ab4b2445`; this slice does not close - serving fallback/recovery, admin import/export, or the remaining - non-WebSocket removed suites. - -### 2026-06-19 Slice 87: Gateway WebSocket Core Event Classification Migration - -Status: implemented and verified for the pure protocol/assertion block from old -`tests/codex_gateway/websocket.rs`. - -Changes: - -- Migrated old gateway WebSocket core assertions into - `crates/core/tests/protocol.rs` for: - - special upstream error code classification: quota, auth, banned, previous - response missing, overload, and usage-not-included; - - success-status `error` frames and unmapped `error` frames being ignored - instead of treated as terminal upstream failures; - - malformed `response.completed` shapes with missing id or incomplete usage; - - optional `null` fields matching missing-field behavior for message, - custom-tool, and context-compaction events; - - optional field type mismatches for tool search, custom tool output, image - generation, and context compaction items; - - nested shape mismatches for local-shell and web-search actions; - - invalid JSON and official event shape mismatches being skipped before SSE - forwarding. -- No production code changes were needed in this slice; the migrated assertions - validated that the current pure core validators already cover these old - gateway behaviors. - -Migrated tests added in this slice: - -- `codex_websocket_error_frame_should_classify_old_gateway_special_codes` -- `codex_websocket_error_frame_should_ignore_success_status_and_unmapped_error` -- `codex_websocket_response_completed_parse_error_should_reject_missing_id_and_incomplete_usage` -- `codex_websocket_optional_null_fields_should_match_missing_field_behavior` -- `codex_websocket_output_item_optional_fields_should_reject_old_gateway_edge_cases` -- `codex_websocket_local_shell_and_web_search_should_reject_nested_shape_mismatches` -- `codex_websocket_event_to_sse_frame_should_skip_invalid_json_and_shape_mismatches` - -Verification run for this slice so far: - -- `cargo fmt -p codex-proxy-core` -- `cargo test -p codex-proxy-core --test protocol -- --nocapture` - (59/59 passed) -- `codegraph sync . && codegraph status .` - (index up to date with 282 files, 4,250 nodes, and 12,957 edges) -- `cargo fmt --all -- --check` -- `cargo check --workspace --tests` -- `cargo test --test architecture` - (27/27 passed) -- `cargo test --workspace --tests` - (workspace test suite passed; notable migrated suites: - `crates/core/tests/protocol.rs` 59/59, - `crates/adapters/tests/codex.rs` 55/55, - `crates/server/tests/openai_chat_upstream.rs` 115/115) -- Empty Rust/TOML/MD file scan under `tests`, `crates`, and `src` found no - empty files. -- Empty-directory scan found no empty directories outside ignored `.git`, - `target`, `.codegraph`, and `web/node_modules` paths. -- Placeholder marker scan only found the architecture guard/test strings that - enforce placeholder detection. - -Current migration counters after this slice: - -- 484 `#[test]` / `#[tokio::test]` occurrences. -- 62 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. -- `crates/core/tests/protocol.rs` now has 59 tests. - -Remaining after this slice: - -- The old gateway WebSocket pure event-shape/status-classification block is now - represented in `core` coverage. -- Remaining gateway WebSocket risk is primarily the old capture harness and - byte-for-byte audit artifact parity, not the core event validator or adapter - transport/status/pool behavior already migrated in Slices 86 and 87. - -### 2026-06-19 Slice 88: Gateway WebSocket Security Chain Header/Payload Migration - -Status: implemented and verified for the old gateway WebSocket -security-chain/header-body parity block. - -Changes: - -- Added adapter-level live WebSocket coverage proving the real - `CodexBackendClient` path forwards security-chain context through the - WebSocket opening and first `response.create` payload: - - `session_id` derives `x-client-request-id`, `prompt_cache_key`, - `session-id`, and `thread-id`; - - client metadata preserves string-only user metadata and drops non-string - values; - - installation id, window id, turn metadata, and parent thread id are added - to `client_metadata`; - - `x-openai-subagent` is promoted from metadata into the opening headers. -- Fixed the WebSocket request header builder so HTTP/SSE-only headers are not - forwarded into WebSocket openings: - - removes `content-type`; - - removes `accept`; - - replaces HTTP `session_id` with WebSocket `session-id` and `thread-id`. - -Migrated test added in this slice: - -- `codex_backend_client_websocket_should_forward_security_chain_headers_and_payload_fields` - -Verification run for this slice so far: - -- `cargo fmt -p codex-proxy-adapters` -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (38/38 passed) -- `cargo test -p codex-proxy-adapters --tests` - (`crates/adapters/tests/codex.rs`: 56/56 passed) -- `codegraph sync . && codegraph status .` - (index up to date with 282 files, 4,252 nodes, and 12,947 edges) -- `cargo fmt --all -- --check` -- `cargo check --workspace --tests` -- `cargo test --test architecture` - (27/27 passed) -- `cargo test --workspace --tests` - (workspace test suite passed; notable migrated suites: - `crates/adapters/tests/codex.rs` 56/56, - `crates/core/tests/protocol.rs` 59/59, - `crates/server/tests/openai_chat_upstream.rs` 115/115) -- Empty Rust/TOML/MD file scan under `tests`, `crates`, and `src` found no - empty files. -- Empty-directory scan found no empty directories outside ignored `.git`, - `target`, `.codegraph`, and `web/node_modules` paths. -- Placeholder marker scan only found the architecture guard/test strings that - enforce placeholder detection. - -Current migration counters after this slice: - -- 485 `#[test]` / `#[tokio::test]` occurrences. -- 62 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. -- `crates/adapters/tests/codex.rs` now has 56 tests, including 38 WebSocket - filtered tests. - -Remaining after this slice: - -- The old gateway WebSocket security-chain header/body block is now represented - in adapter coverage and production WebSocket headers no longer carry HTTP/SSE - body headers. -- Remaining gateway WebSocket risk is now focused on byte-for-byte capture - harness/audit artifact parity and any still-unmapped assertion-level cases - from the historical root suite. - -### 2026-06-19 Slice 89: Gateway WebSocket Capture Harness / Idle Timeout Migration - -Status: implemented and verified for the old gateway WebSocket capture harness, -artifact redaction, ordered first-frame serialization, and silent-upstream -timeout block. - -Changes: - -- Migrated the old gateway WebSocket capture harness assertions into - crate-local core/adapter coverage: - - `CodexWebSocketConnection::opening_request_text()` now exposes the actual - WebSocket opening request text used by the adapter for capture/audit parity. - - Adapter tests assert the raw opening request line, standard WebSocket - headers, business-header order, session/thread headers, turn metadata, and - permessage-deflate offer order. - - Core and adapter tests assert the first `response.create` WebSocket frame - is serialized in the old client field order rather than `serde_json::Map` - key order. - - The audit artifact write test now builds real redacted opening/payload - snapshots and asserts access tokens, account IDs, cookies, user prompt text, - prompt cache keys, and thread metadata are not written. -- Restored the old silent-upstream behavior: - WebSocket receive loops now fail with an explicit 20-second - `ReceiveIdleTimeout` when the upstream accepts the opening and first frame but - sends no events. The timeout is shared by buffered and live streaming - WebSocket response paths. - -TDD/coverage evidence: - -- RED: - `cargo test -p codex-proxy-adapters --test codex capture -- --nocapture` - initially failed because `CodexWebSocketConnection::opening_request_text()` - did not exist. -- RED: - after adding the raw opening API, the same capture filter failed because - `websocket_response_create_payload_text(...)` serialized the first frame in - sorted `serde_json::Map` key order, with `client_metadata` before `type`. -- RED: - `cargo test -p codex-proxy-core --test protocol codex_websocket_response_create_payload_text_should_preserve_old_field_order -- --nocapture` - failed for the same sorted field order. -- RED: - `cargo test -p codex-proxy-adapters --test codex silent -- --nocapture` - failed to compile because the adapter had no `ReceiveIdleTimeout` error - variant. -- GREEN: - the core ordered serializer, adapter raw-opening capture tests, artifact - redaction test, and silent-upstream timeout test now pass. - -Verification run for this slice so far: - -- `cargo test -p codex-proxy-core --test protocol codex_websocket_response_create_payload_text_should_preserve_old_field_order -- --nocapture` -- `cargo test -p codex-proxy-adapters --test codex capture -- --nocapture` -- `cargo test -p codex-proxy-adapters --test codex silent -- --nocapture` -- `cargo test -p codex-proxy-adapters --test codex websocket_audit_artifact_should_require_explicit_directory -- --nocapture` -- `cargo test -p codex-proxy-core --test protocol websocket -- --nocapture` - (33/33 filtered WebSocket/core protocol tests passed) -- `cargo test -p codex-proxy-adapters --test codex websocket_ -- --nocapture` - (41/41 filtered WebSocket adapter tests passed) -- `cargo test -p codex-proxy-adapters --tests` - (`crates/adapters/tests/codex.rs`: 59/59 passed) -- `cargo test -p codex-proxy-core --test protocol -- --nocapture` - (`crates/core/tests/protocol.rs`: 60/60 passed) -- `cargo fmt --all -- --check` -- `cargo check --workspace --tests` -- `cargo test --test architecture` - (27/27 architecture tests passed) -- `cargo test --workspace --tests` - (workspace test suite passed; notable migrated suites: - `crates/adapters/tests/codex.rs` 59/59, - `crates/core/tests/protocol.rs` 60/60, - `crates/server/tests/openai_chat_upstream.rs` 115/115) -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- Empty Rust/TOML/MD file scan under `tests`, `crates`, and `src` found no - empty files. -- Empty-directory scan found no empty directories outside ignored `.git`, - `target`, `.codegraph`, and `web/node_modules` paths. -- Placeholder marker scan only found architecture guard/test strings, - documented API/product compatibility wording, and model alias terminology; no - source placeholder or migration shim introduced by this slice. - -Current migration counters after this slice: - -- 488 `#[test]` / `#[tokio::test]` occurrences. -- 62 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 40 crate-local Rust test files under `crates/*/tests`. -- `crates/adapters/tests/codex.rs` now has 59 tests. - -Remaining after this slice: - -- The old gateway WebSocket byte-for-byte capture harness is now represented by - raw opening text, first-frame order, redacted audit artifact, opening-status, - deflate, ping, binary-frame, idle-timeout, wrapped-error, connection-limit, - metadata, rate-limit, pool, previous-response, history-recovery, and - security-chain coverage. -- Remaining gateway WebSocket risk is now limited to assertion-level parity - gaps found by any later direct old/new comparison; no separate high-risk - capture harness block is currently known. - -## Verification After Current Implementation Pass - -Commands run after Slice 12: - -- `cargo fmt --all -- --check` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo test --test architecture` -- `cargo check --workspace --all-targets` -- `cargo test -p codex-proxy-runtime --test token_refresh` -- `cargo test -p codex-proxy-runtime --test quota_refresh` -- `cargo test -p codex-proxy-adapters --test account_repository account_repository_should_update_quota_json_and_fetched_at` -- `cargo test -p codex-proxy-adapters --test codex websocket_` -- `cargo test --workspace --all-targets` -- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` -- `cargo test -p codex-proxy-adapters --test codex` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-core --test models` -- `cargo test -p codex-proxy-core --test session_affinity` -- `cargo test -p codex-proxy-adapters --test session_affinity` -- `cargo test -p codex-proxy-runtime --test session_affinity` -- `cargo check -p codex-proxy-server --all-targets` -- `cargo test -p codex-proxy-runtime --test account_pool_restore` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_prefer_session_affinity_account_for_previous_response` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_session_affinity_for_completed_response` -- `cargo check --workspace --all-targets` -- `cargo fmt --all -- --check` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-runtime --test tasks session_affinity_cleanup_task_should_delete_only_expired_affinities` -- `cargo test -p codex-proxy-runtime --test tasks start_background_tasks_should_register_migrated_runtime_tasks` -- `cargo test -p codex-proxy-runtime --test tasks` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota_should_fetch_usage_store_quota_and_not_return_secrets` -- `cargo test -p codex-proxy-server --test admin_accounts_routes` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test admin_accounts_routes admin_account_quota_should_return_bad_gateway_when_usage_fetch_fails` -- `cargo test -p codex-proxy-server --test admin_accounts_routes` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo fmt --all -- --check` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_fallback_to_next_account_after_rate_limit` (red check before Slice 18 implementation; failed as expected on `quota_limit_reached`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` (red check before Slice 37 implementation; failed 3/3 as expected because no history recovery retry was sent) -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream strip_history` -- `cargo test -p codex-proxy-core --test protocol openai_response_request_should_translate_to_codex_request` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` (red check before Slice 36 implementation; failed 4/4 as expected on missing model-unsupported fallback/no-fallback behavior) -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream model_unsupported` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` (red check before Slice 35 implementation; failed 7/7 as expected on missing Cloudflare fallback/cooldown/cookie/path-block behavior) -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` -- `cargo fmt --all` -- `cargo test -p codex-proxy-core cloudflare` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` (first post-implementation run failed on the new architecture whitelist entry, then the whitelist/docs were updated) -- `cargo test --test architecture` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream cloudflare` -- `cargo test -p codex-proxy-core cloudflare` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_retry_same_account_after_5xx_before_fallback` (red check before Slice 19 implementation; failed as expected with HTTP `502`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_retry_same_account_after_5xx_before_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_request_count_when_5xx_retries_are_exhausted` (red check before Slice 20 implementation; failed as expected with `request_count = 0`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_record_request_count_when_5xx_retries_are_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-runtime --test account_pool_restore` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_fallback_to_next_account_after_rate_limit` (red check before Slice 21 implementation; failed as expected with secondary token usage `(1, 0, 0)`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_fallback_to_next_account_after_rate_limit` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-adapters --test account_repository` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_dispatch_to_codex_and_return_openai_response` (red check before Slice 22 implementation; failed as expected with Chat usage `(1, 0, 0)`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_dispatch_to_codex_and_return_openai_response` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_mark_quota_exhausted_after_402_and_fallback` (red check before Slice 23 implementation; failed as expected with HTTP `502`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_mark_quota_exhausted_after_402_and_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo test -p codex-proxy-adapters --test account_repository` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_classify_sse_quota_failure_and_fallback` (red check before Slice 24 implementation; failed as expected with HTTP `502`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_classify_sse_quota_failure_and_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_mark_quota_exhausted_after_402_and_fallback` (red check before Slice 25 implementation; failed as expected with HTTP `502`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_mark_quota_exhausted_after_402_and_fallback` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_return_quota_error_when_402_fallback_is_exhausted` (red check before Slice 26 implementation; failed as expected with HTTP `503`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_return_quota_error_when_402_fallback_is_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_quota_error_when_402_fallback_is_exhausted` (red check before Slice 27 implementation; failed as expected with HTTP `503`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_quota_error_when_402_fallback_is_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_return_rate_limit_error_when_429_fallback_is_exhausted` (red check before Slice 28 implementation; failed as expected with HTTP `503`) -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream chat_completions_should_return_rate_limit_error_when_429_fallback_is_exhausted` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_rate_limit_error_when_429_fallback_is_exhausted` (red check before Slice 29 implementation; failed as expected with HTTP `503`) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_should_return_rate_limit_error_when_429_fallback_is_exhausted` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should` (red check before Slice 30 implementation; after tightening the 5xx assertion, all four stream tests failed as expected on missing `text/event-stream`) -- `cargo test -p codex-proxy-server --test openai_chat_upstream responses_stream_should` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream 401` (red check before Slice 31 implementation; failed as expected with HTTP `502` for Chat/Responses fallback and missing stream fallback usage) -- `cargo test -p codex-proxy-server --test openai_chat_upstream sse_failed_event` (red check before Slice 31 implementation; failed as expected with HTTP `502`) -- `cargo test -p codex-proxy-server --test openai_chat_upstream 401` -- `cargo test -p codex-proxy-server --test openai_chat_upstream sse_failed_event` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream auth_error_when_401` (red check before Slice 32 implementation; failed as expected with HTTP `503`/no-active stream error) -- `cargo test -p codex-proxy-server --test openai_chat_upstream auth_error_when_401` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream deactivated` (red check before Slice 33 implementation; failed as expected with persisted status `expired`) -- `cargo test -p codex-proxy-server --test openai_chat_upstream deactivated` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` -- `cargo test -p codex-proxy-server --test openai_chat_upstream after_403` (red check before Slice 34 implementation; failed as expected with HTTP `502` and missing fallback stream response id) -- `cargo test -p codex-proxy-server --test openai_chat_upstream after_403` -- `cargo fmt --all` -- `cargo test -p codex-proxy-server --test openai_chat_upstream` -- `cargo check --workspace --all-targets` -- `cargo test --test architecture` -- `codegraph sync .` - -Result: - -- Commands listed before Slice 7 completed successfully in the previous pass. -- The Slice 7 through Slice 12 targeted commands completed successfully in the - current pass. -- `codegraph sync .` was rerun after the Slice 7 implementation and synced 12 - changed files. -- `codegraph sync .` was rerun after the Slice 8 implementation, syncing 10 - changed files, then rerun after formatting and synced 1 changed file. -- `codegraph sync .` was rerun after the Slice 9 implementation, syncing 5 - changed files, then rerun after formatting and synced 1 changed file. -- `codegraph sync .` was rerun after the Slice 10 implementation, syncing 2 - changed files, then rerun after formatting and synced 1 changed file. -- `codegraph sync .` was rerun after the Slice 11 and Slice 12 implementation - work, then rerun after fixing the `previous_response_id` protocol test and - synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 13 session-affinity cleanup task - work and synced 5 changed files, then rerun after architecture - spec/whitelist documentation updates and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 14 explicit admin quota route - migration and synced 5 changed files. -- `codegraph sync .` was rerun after the Slice 15 quota upstream failure shape - migration and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 16 Responses rate-limit account - fallback migration and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 17 Chat rate-limit account - fallback migration and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 18 persisted quota cooldown - transition and synced 5 changed files. -- `codegraph sync .` was rerun after the Slice 19 Responses 5xx same-account - retry migration and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 20 selected-account request-count - persistence and synced 5 changed files. -- `codegraph sync .` was rerun after the Slice 21 Responses token usage delta - persistence and synced 6 changed files. -- `codegraph sync .` was rerun after the Slice 22 Chat token usage delta - persistence and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 23 Responses HTTP 402 quota - exhausted fallback and synced 5 changed files. -- `codegraph sync .` was rerun after the Slice 24 Responses SSE quota failure - fallback and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 25 Chat HTTP 402 quota fallback - and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 26 Chat HTTP 402 no-fallback - quota error aggregation and synced 3 changed files. -- `codegraph sync .` was rerun after the Slice 27 Responses HTTP 402 - no-fallback quota error aggregation and synced 3 changed files. -- `codegraph sync .` was rerun after the Slice 28 Chat HTTP 429 no-fallback - rate-limit error aggregation and synced 3 changed files. -- `codegraph sync .` was rerun after the Slice 29 Responses HTTP 429 - no-fallback rate-limit error aggregation and synced 3 changed files. -- `codegraph sync .` was rerun after the Slice 30 buffered Responses - `stream: true` fallback and usage work and synced 3 changed files. -- `codegraph sync .` was rerun after the Slice 31 auth failure account - recovery work and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 32 auth no-fallback expired - aggregation work and synced 4 changed files. -- `codegraph sync .` was rerun after the Slice 33 deactivated auth status - classification work and synced 2 changed files. -- `codegraph sync .` was rerun after the Slice 34 HTTP 403 banned fallback - work and synced 2 changed files. -- `codegraph sync .` was rerun after adding the Slice 35 Cloudflare tests and - synced 1 changed file, then rerun after implementation/formatting and synced - 10 changed files. -- `codegraph sync .` was rerun after adding the Slice 36 model-unsupported - tests and synced 1 changed file, then rerun after implementation/formatting - and synced 2 changed files. -- `codegraph sync .` was rerun after adding the Slice 37 history recovery tests - and synced 1 changed file, then rerun after implementation/formatting and - synced 4 changed files. -- `codegraph sync .` was rerun after Slice 39 token refresh lease coordination - implementation and synced 7 changed files. -- `codegraph sync .` was rerun after Slice 40 token refresh refreshing recovery - implementation and synced 3 changed files. -- `codegraph sync .` was rerun after Slice 41 token refresh retry confirmation - implementation and synced 2 changed files. -- `codegraph sync .` was rerun after Slice 42 token refresh in-flight guard - implementation and synced 2 changed files. -- `codegraph sync .` was rerun after Slice 43 token refresh delayed recovery - implementation and synced 2 changed files. -- `codegraph sync .` was rerun after Slice 44 Responses stream no-fallback - coverage and synced 1 changed file. -- `codegraph sync .` was rerun after Slice 45 admin quota edge-case coverage - and synced 1 changed file. -- `codegraph sync .` was rerun before Slice 49 token refresh timer work and - synced the 2 changed WIP files already present at the start of the pass. -- `codegraph sync . && codegraph status .` was rerun after Slice 49 token - refresh timer implementation and reported the index up to date with 271 files, - 3,213 nodes, and 8,855 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 50 Responses - stream done termination and reported the index up to date with 271 files, - 3,215 nodes, and 8,879 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 51 WebSocket - transport decision and audit snapshots and reported the index up to date with - 271 files, 3,242 nodes, and 8,977 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 52 WebSocket - core codec diagnostics and reported the index up to date with 271 files, - 3,289 nodes, and 9,059 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 53 WebSocket - core item validators and reported the index up to date with 271 files, 3,324 - nodes, and 9,144 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 54 WebSocket - opening request descriptor and reported the index up to date with 271 files, - 3,337 nodes, and 9,176 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 55 minimal live - WebSocket exchange and reported the index up to date with 271 files, 3,356 - nodes, and 9,224 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 56 Codex - backend WebSocket required path and reported the index up to date with 271 - files, 3,365 nodes, and 9,276 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 57 WebSocket - live invalid-event filtering and reported the index up to date with 271 - files, 3,367 nodes, and 9,295 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 58 WebSocket - live control-frame and handshake metadata work and reported the index up to - date with 271 files, 3,378 nodes, and 9,334 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 59 WebSocket - ping coverage and audit artifact IO and reported the index up to date with - 271 files, 3,404 nodes, and 9,433 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 60 WebSocket - deflate frame rewriter work and reported the index up to date with 271 files, - 3,422 nodes, and 9,482 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 61 live HTTP - SSE body forwarding and reported the index up to date with 271 files, 3,456 - nodes, and 9,738 edges. -- `codegraph sync . && codegraph status .` was rerun after Slice 62 WebSocket - pool reuse/error-discard work and reported the index up to date with 271 files, - 3,493 nodes, and 9,871 edges after lint cleanup. -- `codegraph sync . && codegraph status .` was rerun during Slice 63 WebSocket - pool lifecycle/runtime wiring work and reported the index up to date with 272 - files, 3,535 nodes, and 10,058 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun during Slice 64 server - WebSocketRequired work and reported the index up to date with 272 files, 3,550 - nodes, and 10,120 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 65 live - WebSocket streaming/deflate work and reported the index up to date with 272 - files, 3,595 nodes, and 10,382 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 66 live stream - late-failure SSE reporting work and reported the index up to date with 272 - files, 3,609 nodes, and 10,418 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 67 live stream - event-log audit work and reported the index up to date with 272 files, 3,623 - nodes, and 10,460 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 68 stream audit - metadata/log-policy work and reported the index up to date with 272 files, - 3,652 nodes, and 10,554 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 69 serving toy - helper cleanup and reported the index up to date with 272 files, 3,656 nodes, - and 10,555 edges before the documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 70 admin - settings/test-migration guard work and reported the index up to date with 274 - files, 3,733 nodes, and 10,666 edges before this documentation-only update. -- `codegraph sync . && codegraph status .` was rerun after Slice 71 admin - client-key assertion coverage migration and reported the index up to date with - 274 files, 3,737 nodes, and 10,716 edges before this documentation-only - update. - -Historical high-risk gap log before the current full-audit pass: - -- Full WebSocket parity still needs a focused pass for byte-for-byte capture - harness/audit artifact edge cases not represented by the restored opening, - live streaming, deflate, pool, ping, binary-frame rejection, opening-status - preservation, wrapped-error status/retry-after handling, connection-limit - mapping, history-recovery, previous-response, event-log, rate-limit-update, - metadata, special error-code classification, optional/null event field - handling, invalid event-shape skipping, security-chain header/body forwarding, - HTTP-header exclusion from WebSocket openings, default/pool/metadata route - behavior, and implicit resume/reasoning-replay coverage. Live WebSocket body - forwarding, streaming `previous_response_id` dispatch, live deflate stream - integration, actual WebSocket stream audit transport marking, internal - `codex.rate_limits` update snapshots, old gateway WebSocket - pool/status/internal-event parity, security-chain parity, pure - event-shape/status-classification parity, and the latest Responses WebSocket - implicit-resume/replay cases are now implemented and covered by tests. -- Serving dispatch/fallback/recovery remains materially reduced, though - non-streaming Chat and Responses now retry the next account after an HTTP 429 - and persist the quota cooldown transition for that fallback path, and - non-streaming Responses now retry the same account for transient upstream - HTTP 5xx responses. HTTP SSE `stream: true` now has a live chunk-by-chunk - success path, first-event SSE failure fallback preservation, completed usage - persistence, HTTP 429/402 fallback, same-account 5xx retry, and - client-visible late-failure `response.failed(stream_disconnected)` reporting. - Persisted live stream event-log audit is restored for completed and - late-failure streams, including body-capture policy, usage, rate-limit - snapshots, and actual WebSocket transport metadata. Full old stream audit - artifacts remain incomplete only for any pool-specific artifact details that - are separate from event-log metadata. -- Selected-account request-count persistence is restored, and persisted token - usage deltas are restored for non-streaming Responses and Chat completions; - buffered HTTP SSE completed usage is restored for `stream: true`; live - streaming usage audit remains incomplete. -- Non-streaming Responses HTTP 402 and SSE quota failure fallback are restored, - and buffered HTTP SSE stream HTTP 402 fallback plus no-fallback aggregation - coverage are restored; buffered Responses stream success/error bodies now - append `[DONE]`; true live chunk-by-chunk stream behavior remains incomplete. -- Multi-account and no-fallback Chat/Responses HTTP 402 quota behavior is - restored for non-streaming paths, and buffered HTTP SSE stream HTTP 402 - fallback is restored; live streaming/SSE no-fallback aggregation remains - incomplete. -- Chat and Responses HTTP 429 no-fallback aggregation is restored for - non-streaming paths; buffered HTTP SSE stream HTTP 429 multi-account fallback - and no-fallback aggregation coverage are restored. -- HTTP 401 and token-invalid SSE account recovery are restored for the covered - Chat/Responses paths, including no-fallback expired aggregation and - deactivated/banned status classification; ordinary HTTP 403 banned fallback - is restored for covered Chat/Responses paths; Cloudflare challenge/path-block - recovery is restored for the covered Chat/Responses HTTP and buffered SSE - paths, but live stream/WebSocket Cloudflare parity remains incomplete. -- Responses model-unsupported fallback is restored for covered non-streaming and - buffered SSE paths, including exhausted fallback error mapping; Chat HTTP - model-unsupported fallback is restored for the covered non-streaming path. -- Responses request-history recovery is restored for covered SSE failure paths - in non-streaming and buffered SSE modes; invalid encrypted reasoning replay - recovery is restored for covered HTTP error, non-streaming SSE failure, - buffered SSE failure, and WebSocket implicit-resume replay paths. Responses - WebSocket implicit resume, reasoning replay, cross-window rejection, - function-call replay exclusion, SQLite-restored affinity, replay eviction, - and admin status-cycle pool eviction are covered by crate-local server tests. -- Token refresh now uses the SQLite refresh lease table during scans, persists - `refreshing` before scheduled refresh calls, recovers persisted `refreshing` - accounts after restart, restores `active` after retry exhaustion, retries - transient transport failures, and confirms permanent failures twice before - persisting terminal statuses. Process-local in-flight protection is also - restored for concurrent scans, retry exhaustion now schedules a process-local - delayed recovery window, and the old per-account timer map/next-refresh - scheduling behavior is restored for background scheduling. -- Quota refresh now covers the old active quota-locked scan, per-account minimum - refresh window, usage fetch/persist path, and inter-account request spacing. -- Fingerprint update startup/update coverage is migrated at adapter and runtime - wrapper levels. -- Admin explicit quota route happy path, upstream failure shape, inactive-account - rejection, persistence-failure response, quota-warning auth, and - invalid/below-threshold quota-warning edge coverage are restored. -- Admin settings `PATCH /api/admin/settings` retained-field update behavior is - restored for the current architecture: pure validation in `core`, local YAML - overlay writing in `platform`, mutable same-process config view in `runtime`, - and HTTP parsing/error mapping in `server`. -- Assets static serving is no longer an empty scaffold: the assets crate now - serves SPA/index and `/assets/*` files with cache/security headers, and the - server router uses it as the final fallback behind API routes. -- Root behavior tests are now guarded against returning under `tests/`, and the - audited high-risk old suites have crate-local migration-target evidence. - Remaining risk is assertion-level parity inside those broad suites, not the - directory placement itself. - -## What Is In Good Shape - -- Root tracked Rust source has no remaining `src/` facade. -- Architecture boundary tests import member crates directly: - - `codex_proxy_core` - - `codex_proxy_runtime` - - `codex_proxy_server` -- Tracked legacy source directories such as `src/codex`, `src/admin`, - `src/runtime`, and `src/platform` are removed. -- Current top-level Rust tests are architecture tests only. Shared fixtures - remain under `tests/fixtures`, which is acceptable as shared test data. -- The current workspace has crate-local tests under `crates/*/tests`. -- `assets` now owns real SPA/static-file routing and header helpers, and server - uses it as the final fallback after OpenAI/admin routes. - -## Historical Blocking Findings - -The findings in this section were the original blockers found early in the -migration. They are retained to show the audit trail, but the current verdict -and the 2026-06-19 full-audit pass above supersede any item that has since been -closed. - -### 1. Server Entrypoint Startup Was Incomplete - -Audit finding before Slice 2: - -- `crates/server/src/main.rs` is `fn main() {}`. - -Baseline: - -- `src/main.rs` loaded configuration, initialized tracing, built state, restored - accounts, started background tasks, served Axum, handled `ctrl_c`, and shut - tasks down. - -Impact: - -- The server binary does not implement the real startup path. -- This alone prevents claiming the runtime/server migration is complete. - -Progress: - -- Slice 2 replaced the empty main with config loading, logging initialization, - SQLite connection, secret/API key material loading, runtime state - construction, Axum serving, and `ctrl_c` graceful shutdown. -- Slice 3 wired the server entrypoint to start the runtime background task - coordinator and shut it down after Axum graceful shutdown returns. - -Remaining: - -- Startup now restores persisted accounts into the runtime pool and restores - session-affinity state. Chat/Responses dispatch uses the restored account - pool; Responses dispatch also uses restored session affinity for - preferred-account selection and records completed response affinities. -- Baseline parity remains incomplete in remaining serving fallback/recovery - branches, quota/account-status WebSocket routing, stream audit artifact edge - cases, and broader behavior-test parity. Responses WebSocket implicit - resume/reasoning replay is now covered in Slice 84. - -### 2. Xtask Commands Were Not Wired - -Audit finding before Slice 2: - -- `crates/xtask/src/build_web.rs` prints - `web build orchestration is not wired yet`. -- `crates/xtask/src/release.rs` prints - `release packaging is not wired yet`. -- `crates/xtask/src/check_architecture.rs` only prints - `cargo test --test architecture`; it does not run it. - -Impact: - -- `xtask` exists as a scaffold, not as the automation crate described in - `docs/architecture.md`. - -Progress: - -- Slice 2 wired: - - `check-architecture` -> `cargo test --test architecture`; - - `build-web` -> `pnpm install --frozen-lockfile` and `pnpm build`; - - `release` -> Rust format/test/clippy gates plus web build. - -Remaining: - -- Keep `release` in the final verification gate after the remaining migration - slices stabilize. - -### 3. Runtime Tasks Were Mostly Empty - -Current after Slice 4: - -- `crates/runtime/src/tasks/token_refresh.rs` contains a real refresh scan, - persistence path, and periodic task loop. -- `crates/runtime/src/tasks/quota_refresh.rs` contains a real quota-locked - account scan, Codex usage fetch, normalized quota persistence path, and - periodic task loop. - -Baseline examples: - -- `src/codex/tasks/token_refresh.rs`: 882 lines. -- `src/codex/tasks/quota_refresh.rs`: 117 lines. -- `src/codex/tasks/cookie_cleanup.rs`: 66 lines. -- `src/admin/tasks/session_cleanup.rs`: 59 lines. - -Impact: - -- Background task behavior has not been fully migrated. -- The current task coordinator cannot be treated as equivalent to the previous - scheduler set. - -Progress: - -- Slice 3 reintroduced runtime task structs for cookie cleanup and admin session - cleanup, plus SQLite cleanup methods and runtime tests. -- Slice 3 also wired the top-level coordinator to start `cookie_cleanup`, - `session_cleanup`, and `model_refresh`, and wired server shutdown to stop the - coordinator. -- Slice 4 implemented token/quota task loops and wired both into the top-level - coordinator. -- Slice 5 wired fingerprint update into the top-level coordinator. -- Slice 13 wired expired session-affinity cleanup into the top-level - coordinator. -- Slice 40 restored token-refresh `refreshing` persistence before refresher - calls, restart/crash recovery from persisted `refreshing`, and active - restoration after transport failure. -- Slice 41 restored token-refresh retry attempts, transient retry success, and - two-hit permanent failure confirmation. -- Slice 42 restored token-refresh process-local in-flight tracking for - duplicate concurrent scans. -- Slice 43 restored token-refresh delayed recovery after retry exhaustion. -- Slice 45 migrated explicit admin quota route edge-case coverage for inactive - accounts, persistence failure, quota-warning auth, and invalid/below-threshold - snapshots. -- Slice 46 migrated runtime-level fingerprint update startup coverage for - initial appcast apply, no-update no-op, and `repository: None` semantics. -- Slice 47 restored quota refresh inter-account request spacing with the old - 3-second default. -- Slice 49 restored token-refresh per-account timer scheduling, immediate due - refresh, planned-trigger-time callbacks, and next-refresh scheduling after a - successful scheduled refresh. - -Remaining: - -- No token-refresh-task-specific gap is currently known after restoring lease - coordination, refreshing recovery, retry/permanent-failure handling, - in-flight protection, delayed recovery, and per-account timer parity. -- No further quota-refresh-task-specific gap was found in this pass after - restoring request spacing; broader quota helper/admin behavior should continue - to be covered through serving/admin tests. - -### 4. WebSocket Implementation Was Reduced To Shells - -Current after Slice 86: - -- `crates/adapters/src/codex/websocket/connect.rs`: now stores endpoint and - ordered opening headers, and can emit an opening audit snapshot with request - line plus redacted sensitive headers. It also builds Responses WebSocket - endpoints, standard opening header descriptors, and a prepared request that - pairs the opening descriptor with the first `response.create` text frame. It - now has a minimal one-shot live exchange that opens the WebSocket, sends the - prepared text frame, buffers public events into SSE until terminal, extracts - usage, skips invalid public stream events through the migrated core - validators, captures handshake turn-state/cookies/rate-limit headers, handles - `response.incomplete` and malformed `response.completed` frames, ignores - success-status `error` frames, responds to server ping frames through the - tungstenite read loop, rejects upstream binary application frames as explicit - protocol errors, preserves non-101 opening status/body/retry-after, captures - internal metadata and `codex.rate_limits` events without downstream forwarding, - forwards security-chain headers/body metadata without leaking HTTP/SSE-only - `content-type`/`accept` headers into the WebSocket opening, emits - environment-gated redacted audit artifacts, and surfaces classified upstream - error frames including wrapped errors and connection-limit frames. -- `crates/adapters/src/codex/websocket/pool.rs`: now owns an async idle/busy - connection pool keyed by base URL, account id, and conversation id. It supports - max-age/max-per-account policy, acquire/put/discard, explicit idle GC, reuse of - successful sockets, one-shot bypass for busy keys or over-cap new keys, - maintenance ping/liveness cleanup, and discard of errored sockets. -- `crates/adapters/src/codex/websocket/deflate.rs`: now detects - permessage-deflate responses and contains tested raw payload inflation plus - compressed server data-frame rewriting. It is not yet inserted into the live - connection path. -- `crates/adapters/src/codex/websocket/opening.rs`: simple audit snapshot helper. - It also provides explicit and environment-gated WebSocket audit artifact - writes. -- `crates/core/src/protocol/codex/websocket.rs`: basic frame wrapper - encode/decode plus payload/opening audit snapshot types and Responses - payload redaction; pure event-to-SSE conversion; metadata turn-state - extraction; terminal-event detection; error-frame classification; wrapped - retry-after parsing; `response.completed` shape validation; and migrated - stream/output-item diagnostics for the old core bad-frame categories. It now - also owns pure WebSocket audit artifact and parity-diff structures plus status - classification for old gateway wrapped errors and connection-limit frames. -- `crates/core/src/serving/responses.rs`: restores the old Responses transport - decision policy for `HttpSse`, `WebSocketPreferred`, and - `WebSocketRequired`. -- `crates/adapters/src/codex/client.rs`: `create_response(...)` now honors the - core transport policy and uses the live WebSocket exchange for required - WebSocket requests, while preserving HTTP/SSE fallback when allowed. - `create_response_stream(...)` now exposes a live SSE byte stream for the - OpenAI `stream: true` path and propagates WebSocket stream protocol errors - through the stream item. - -Baseline: - -- `src/codex/gateway/transport/websocket/codec.rs`: 1,147 lines. -- `src/codex/gateway/transport/websocket/mod.rs`: 896 lines. -- `src/codex/gateway/transport/websocket/pool.rs`: 496 lines. -- `src/codex/gateway/transport/websocket/opening.rs`: 495 lines. -- `src/codex/gateway/transport/websocket/deflate.rs`: 273 lines. -- `src/codex/gateway/transport/websocket/audit.rs`: 229 lines. - -Impact: - -- WebSocket transport, pooling, opening handshake, live forwarding integration, - and most adapter/protocol parity behavior are now migrated into crate-local - coverage. -- Remaining risk is concentrated in old byte-for-byte capture harness and audit - artifact edge cases rather than the previously empty/shell adapter behavior. - -Progress: - -- Slice 6 removed the empty adapter structs and added basic tests/guards for the - new connection and pool policy types. -- Slice 51 restored transport decision and opening/payload audit snapshot - primitives. -- Slice 52 restored a first core-only batch of WebSocket codec diagnostics and - error classification. -- Slice 53 restored the remaining large batch of old pure output-item and - stream bad-frame validators into core. -- Slice 54 restored adapter-level endpoint conversion, standard opening header - descriptor construction, and response-create text-frame preparation. -- Slice 55 restored a minimal adapter-level live WebSocket one-shot exchange and - response-failed error classification. -- Slice 56 wired `CodexBackendClient::create_response(...)` to use that live - WebSocket path for required Responses requests. -- Slice 57 wired the migrated core bad-frame validators into live WebSocket - event forwarding so invalid stream events are dropped before SSE encoding. -- Slice 58 restored live handshake metadata propagation, `response.incomplete` - error mapping, malformed `response.completed` validation, invalid terminal - skip semantics, and success-status `error` frame ignoring. -- Slice 59 migrated server-ping response coverage and restored core/adapters - WebSocket audit artifact construction, diffing, explicit writes, and - environment-gated client wiring. -- Slice 60 restored tested frame-level permessage-deflate inflation/rewrite - helpers in the adapter crate. -- Slice 61 restored live HTTP SSE body forwarding for normal `stream: true` - success paths while preserving first-event `response.failed` fallback/recovery - before downstream bytes are sent. -- Slice 62 restored adapter-level WebSocket pool reuse for successful - same-account/same-conversation requests and discard after upstream/transport - errors. -- Slice 63 restored adapter-level WebSocket pool keepalive maintenance, - liveness timeout cleanup, shutdown/evict-account APIs, and runtime - `ws_pool` config injection into `CodexBackendClient`. -- Slice 64 restored non-streaming server/runtime `previous_response_id` - dispatch through WebSocketRequired, including session-affinity header coverage, - history-recovery retry behavior, and configured pool reuse. -- Slice 65 restored live WebSocket streaming body forwarding, streaming - `previous_response_id` WebSocketRequired dispatch, live permessage-deflate - insertion before tungstenite parsing, and WS-first streaming history recovery - before downstream bytes are returned. -- Slice 66 restored serving-layer late-failure SSE reporting for live streams - after downstream bytes have already been sent. Late body read errors and - premature EOF without a terminal event now append - `response.failed(stream_disconnected)` and `[DONE]` instead of surfacing a - transport error to downstream clients. -- Slice 67 restored persisted `v1.response` event-log audit records for live - stream completion and late-failure outcomes, including request id, account id, - model, route, status, latency, and stream/failure metadata. -- Slice 68 restored stream audit metadata and log policy parity for body capture - cleaning/preservation, admin log state updates and capacity trimming, - completed/failed stream usage metadata, rate-limit header snapshots, actual - WebSocket transport marking, and live WebSocket `codex.rate_limits` update - propagation into persisted event logs. -- Slice 69 removed or replaced the audited serving toy helpers and added an - architecture guard so `should_fallback`, `is_recoverable`, - `prefers_websocket`, or an uncalled `quota_reached` cannot silently return. -- Slice 86 restored the next old gateway WebSocket adapter/protocol batch: - pool busy/over-cap/maintenance GC behavior, opening failure status/body - preservation, binary-frame errors, wrapped-error status/retry-after - propagation, connection-limit 503 mapping, internal metadata/rate-limit event - capture, and stream close-before-terminal errors. -- Slice 87 restored the old gateway WebSocket pure protocol assertion batch: - special error-code classification, success-status/unmapped error skipping, - malformed `response.completed` validation, optional `null` handling, - optional-field type rejection, nested local-shell/web-search validation, and - invalid JSON/event-shape skipping before SSE forwarding. -- Slice 88 restored the old gateway WebSocket security-chain header/payload - block and fixed production WebSocket header construction so HTTP/SSE-only - `content-type`, `accept`, and `session_id` are not sent in the opening. - WebSocket openings now send `session-id` and `thread-id` when session context - is available. - -Remaining: - -- Remaining old WebSocket behavior-test migration still needs a focused pass for - byte-for-byte capture harness and audit artifact edge cases not represented by - the restored live, pool, deflate, ping, binary-frame, opening-status, - wrapped-error, connection-limit, history-recovery, previous-response, - late-failure, event-log, metadata, security-chain, rate-limit-update, and pure - event-shape validator coverage. Full persisted stream audit parity with the - old `StreamAudit` / `WebSocketStreamAudit` artifacts remains open only for - artifact details that are separate from event-log metadata and redacted - WebSocket audit snapshots. - -### 5. Serving Dispatch Logic Required Bulk Migration - -Current symptoms: - -- The previously audited uncalled toy helpers in - `crates/core/src/serving/fallback.rs`, - `crates/core/src/serving/recovery.rs`, and - `crates/core/src/serving/responses.rs` were removed or replaced in Slice 69. - Runtime production code now calls the remaining pure serving policy helpers - for rate-limit/quota/transient status classification, same-account retry - status classification, and quota-warning threshold matching. -- Architecture tests now fail if those exact toy helper names return or if - `quota_reached(...)` loses its non-test production caller. -- `crates/runtime/src/services.rs` dispatch paths now acquire accounts from the - restored runtime account pool and Responses dispatch uses restored - session-affinity state. HTTP SSE `stream: true` now forwards normal success - bodies live, records usage/session affinity at stream end, and converts late - stream disconnects into SSE `response.failed` events. It also writes - persisted `v1.response` event logs for completed and late-failed live streams, - but full old stream-audit artifacts remain materially reduced. -- Responses WebSocket implicit resume and reasoning replay now run through - runtime production paths, record replay/affinity metadata after completions, - restore full history on implicit-resume rejection, and evict replay state after - invalid encrypted content. - -Baseline examples: - -- `src/codex/serving/dispatch/mod.rs`: 2,063 lines. -- `src/codex/serving/responses.rs`: 1,313 lines. -- `src/codex/serving/dispatch/fallback.rs`: 632 lines. -- `src/codex/serving/dispatch/affinity.rs`: 698 lines. -- `src/codex/serving/dispatch/stream.rs`: 638 lines. -- `src/codex/serving/dispatch/reasoning_replay.rs`: 288 lines. - -Impact: - -- Account fallback, quota state transitions, stream audit artifacts, and the - remaining recovery transitions are not fully proven to be preserved. Affinity, - Responses implicit resume, reasoning replay, and the audited Responses - WebSocket fallback/status-routing block now have dedicated migrated server - coverage, but the old gateway WebSocket adapter/protocol suite remains - high-risk. -- The old behavior should not be considered migrated until these paths are - reimplemented in the intended `core`/`runtime`/`adapters` split and covered by - tests. - -### 6. Core Filesystem IO Violation - -Audit finding before Slice 1: - -- `crates/core/src/gateway/installation.rs` imports `std::fs`, `Path`, - `PathBuf`, and uses `dirs::home_dir()`. -- `crates/core/Cargo.toml` depends on `dirs`. - -Architecture rule: - -- `core` must not own filesystem paths, environment reads, or concrete IO. - -Impact: - -- This is a direct architecture violation. -- Installation ID persistence and `~/.codex/installation_id` compatibility - should move to `platform` or `adapters`, with `core` retaining only pure - identity rules if needed. - -Progress: - -- Fixed in Slice 1 by moving file resolution/persistence to - `platform::storage` and keeping only UUID generation/parsing in `core`. -- Added an architecture test guard so this class of `core` IO dependency - regresses visibly. - -### 7. Test Migration Was Incomplete - -Baseline test count: - -- Approximately 471 `#[test]` / `#[tokio::test]` occurrences. -- 66 top-level Rust test files. - -Current test count: - -- 495 `#[test]` / `#[tokio::test]` occurrences. -- 65 Rust files contain tests. -- 15 root Rust test files, all architecture tests. -- 43 crate-local Rust test files under `crates/*/tests`. - -Large removed baseline tests include: - -- `tests/codex_gateway/websocket.rs`: 4,983 lines, 79 tests. -- `tests/codex_serving/responses_websocket.rs`: 2,863 lines, 28 tests. -- `tests/codex_serving/responses_http_sse.rs`: 1,766 lines, 28 tests. -- `tests/codex_serving/upstream_fallback.rs`: 2,068 lines, 27 tests. -- `tests/codex_serving/chat_completions.rs`: 910 lines, 12 tests. -- `tests/admin/accounts/import_export.rs`: 863 lines, 15 tests. -- `tests/admin/accounts/oauth.rs`: 678 lines, 16 tests. -- `tests/admin/client_keys_route.rs`: 637 lines, 7 tests. - -Impact: - -- This was a valid blocker before the full mapping audit. -- The current architecture guard now maps all 46 old root behavior files to - crate-local test files. Remaining risk is assertion-level parity inside those - broad suites, not a missing crate-local migration target. - -### 8. Architecture Tests Did Not Catch Empty Implementations - -Current architecture tests verify: - -- Directory whitelist. -- Root package has no facade target; boundary tests import member crates directly. -- Workspace dependency direction. -- Some forbidden imports and legacy path references. -- Obvious placeholder markers: empty `main`, `not wired yet`, and `后续承载`. -- Server startup owns background task lifecycle and injects the runtime - installation ID. -- Specific serving toy helper regressions closed in Slice 69: - `should_fallback`, `is_recoverable`, `prefers_websocket`, and an uncalled - `quota_reached`. - -They do not currently verify: - -- Full assertion-level parity for every old behavior test. -- Generic semantic completeness beyond the explicit placeholder/empty-shell - markers and full baseline test-suite mapping. - -Impact: - -- This was a valid concern before the full source scan and test-migration - mapping guard. Current scans found no empty source files/directories or empty - function/module shells in `crates src tests`, and architecture tests now - reject root behavior tests outside the approved architecture/fixture shape. - -## Tests And Fixtures Status - -Current root `tests/` after local empty-directory cleanup: - -- `tests/architecture/*`: root-level architecture tests. -- `tests/fixtures/*`: shared fixtures. - -This shape is reasonable if the policy is: - -- crate behavior tests live under `crates/<crate>/tests`; -- root `tests/` keeps only cross-workspace architecture tests and shared - fixtures. - -However, many old behavior tests still need to be migrated into the appropriate -crate-level test suites. - -## Migration Compatibility / Legacy Surface - -Tracked legacy source modules are removed from `src/`, which is aligned with the -architecture goal of not keeping old root modules. - -Remaining concerns: - -- Current Rust source scan does not find `后续承载` or `not wired yet` outside - the architecture placeholder guard itself. -- Some older documents still reference pre-migration paths. Those are not source - code, but they should be marked historical or updated after the migration is - actually complete. -- `serde(alias = ...)` and OpenAI-compatible wording in current source are API - compatibility features, not migration compatibility shims. -- Codex Desktop installation ID lookup is product interoperability with the - upstream client identity model, not a migration compatibility shim. - -## Recommended Completion Plan - -1. Restore the real binary startup path in `crates/server/src/main.rs` and keep - bootstrap/state assembly inside `runtime`. **Startup config/logging/storage, - background-task ownership, installation ID injection, account-pool restore, - and session-affinity restore are done in Slices 2, 3, 7, 8, and 9.** -2. Move installation ID persistence out of `core`; add an architecture test that - forbids `std::fs`, `std::env`, `dirs::`, `Path`, and `PathBuf` in `core/src`. - **Done in Slice 1; startup/request-context injection done in Slice 7.** -3. Re-migrate serving dispatch behavior into the intended split: - - pure decisions in `core/src/serving`; - - IO through `core` ports; - - adapter calls in `adapters`; - - wiring in `runtime`; - - HTTP mapping in `server`. -4. Re-migrate WebSocket transport and pooling: - - protocol frame types and pure parsing in `core`; - - `tokio-tungstenite`, TLS opening, pooling, deflate, and audit artifact IO in - `adapters`. -5. Rebuild background tasks in `runtime/src/tasks`. -6. Either implement `assets` and `xtask` according to `docs/architecture.md`, or - change the architecture spec before treating them as complete. **`xtask` - wiring is done in Slice 2; `assets` static serving and server fallback wiring - are done in Slice 48.** -7. Migrate deleted behavior tests into `crates/*/tests`, prioritizing: - - WebSocket codec/transport/pool tests; - - Responses HTTP SSE and WebSocket tests; - - upstream fallback/recovery tests; - - admin account OAuth/import/export/cookie/quota tests; - - startup/background task tests. -8. Add architecture tests that fail on: - - empty required files; - - `not wired yet` strings; **done for Rust sources in Slice 2** - - empty `main`; **done for Rust sources in Slice 2** - - uncalled placeholder functions in required architecture modules; **done for - the audited serving toy helpers in Slice 69; broader generic detection is - still open** - - root `tests/` behavior suites outside architecture tests. - -## Evidence Commands - -Commands used during this audit include: - -```bash -codegraph sync . -codegraph status . -git diff --shortstat f07442a28b0c186bd22b598a53cc4856ab4b2445..HEAD -- src tests crates -git diff --name-status f07442a28b0c186bd22b598a53cc4856ab4b2445..HEAD -- src tests crates -git diff --numstat f07442a28b0c186bd22b598a53cc4856ab4b2445..HEAD -- <selected paths> -git grep -n "#\\[\\(tokio::test\\|test\\)\\]" f07442a28b0c186bd22b598a53cc4856ab4b2445 -- tests src -rg "#\\[(tokio::test|test)\\]" -n tests crates src --glob '!target/**' -rg "not wired yet|后续承载|TODO|FIXME|todo!|unimplemented!|stub|placeholder|迁移|兼容|compat|legacy|alias|shim|deprecated" -n crates src tests -rg "std::fs|\\bfs::|std::env|env::|dirs::|std::path|PathBuf|Path::" -n crates/core/src crates/core/Cargo.toml -find tests -type d -empty -print -delete -find . -path './.git' -prune -o -path './target' -prune -o -path './.codegraph' -prune -o -type d -empty -print -``` diff --git a/docs/architecture-capability-plan.md b/docs/architecture-capability-plan.md deleted file mode 100644 index b01c21611..000000000 --- a/docs/architecture-capability-plan.md +++ /dev/null @@ -1,245 +0,0 @@ -# 架构重构方案 B(精炼版):能力域架构,精确到文件 - -> 状态:提案 / 待评审 -> 取代:本文档第一版的粗略结构。对比对象:`architecture-layered-plan.md`(方案 A)。 - -## 铁律 - -**每个目录都能用一句话说清,没有任何目录身兼两职。** 尤其:「对接 chatgpt.com 的脏活」只能出现在 `codex/gateway/` 一处。 - -## 顶层三域 + 横切 - -```text -admin/ 后台管理(控制面) ──┐ 消费 codex 能力 -codex/ 代理能力(产品本体) ←─┘ ──┐ 依赖底座 -platform/ 共享内核(存储/加密/身份) ←─────┘ -config/ utils/ 横切,被所有层使用 -app/ 组装三者 -``` - -依赖方向单向无环:`admin → codex → platform`。codex 不知道 admin 存在(纯代理可独立运行),admin 只是套在外面的管理界面。 - -## 完整目录树(精确到文件) - -```text -src/ -├── main.rs -├── lib.rs -│ -├── app/ # 🔧 组装层 -│ ├── mod.rs -│ ├── state.rs # AppState { codex, admin, platform } -│ ├── router.rs # 挂 codex::serving::http + admin::http + platform 中间件 -│ ├── bootstrap.rs # 构建依赖图 -│ └── tasks/ # ⭐ 后台任务协调器(统一启停所有域的任务) -│ ├── mod.rs -│ ├── types.rs # ← scheduler/types.rs (SchedulerHandle / SchedulerError) -│ └── coordinator.rs # ← 新增:tokio::select! 统一启停 -│ -├── codex/ # 🟢 codex 能力域(产品本体) -│ ├── mod.rs -│ │ -│ ├── serving/ # ① 对客户端:服务 /v1 请求 -│ │ ├── mod.rs -│ │ ├── http/ # 薄 axum handlers ← http/v1/ -│ │ │ ├── mod.rs -│ │ │ ├── router.rs # ← http/v1/router.rs -│ │ │ ├── auth.rs # ← http/v1/auth.rs(校验 cpr_ key,调 platform::identity) -│ │ │ ├── errors.rs # ← http/v1/errors.rs(OpenAI 兼容错误体) -│ │ │ ├── chat.rs # ← http/v1/chat.rs -│ │ │ ├── responses.rs # ← http/v1/responses.rs -│ │ │ ├── models.rs # ← http/v1/models.rs -│ │ │ └── diagnostics.rs # ← http/diagnostics.rs(公开诊断路由) -│ │ ├── chat.rs # Chat 用例服务 ← service/chat.rs -│ │ ├── responses.rs # Responses 用例服务 ← service/responses.rs -│ │ ├── diagnostics.rs # 诊断快照逻辑 ← service/diagnostics.rs -│ │ └── dispatch/ # 上游编排 ← codex/upstream/ -│ │ ├── mod.rs # ← codex/upstream/mod.rs(CodexUpstreamService) -│ │ ├── dispatch.rs # ← codex/upstream/dispatch.rs -│ │ ├── fallback.rs # ← codex/upstream/fallback.rs(429/402/403 换号) -│ │ ├── refresh.rs # ← codex/upstream/refresh.rs(401 刷新重试) -│ │ ├── stream.rs # ← codex/upstream/stream.rs(SSE 收集) -│ │ ├── usage.rs # ← codex/upstream/usage.rs(用量记录) -│ │ └── affinity.rs # ← service/session_affinity.rs(sticky 路由) -│ │ -│ ├── accounts/ # ② 账号资产域 ← codex/accounts/ -│ │ ├── mod.rs -│ │ ├── model.rs # ← codex/accounts/model.rs -│ │ ├── pool.rs # ← codex/accounts/pool.rs(调度/槽位) -│ │ ├── lifecycle.rs # ← codex/accounts/lifecycle.rs -│ │ ├── repository.rs # ← codex/accounts/repository.rs -│ │ ├── cf_path_block.rs # ← codex/accounts/cf_path_block.rs -│ │ ├── usage_snapshots.rs # ← codex/accounts/usage_snapshots.rs -│ │ ├── service/ # ← codex/accounts/service/(账号业务操作) -│ │ │ ├── mod.rs -│ │ │ ├── cookies.rs -│ │ │ ├── health.rs -│ │ │ ├── import.rs # 手动/CLI/OAuth 导入 -│ │ │ ├── lifecycle.rs -│ │ │ ├── quota.rs -│ │ │ ├── refresh.rs -│ │ │ └── runtime_pool.rs -│ │ ├── cookies/ # account-scoped cookies ← codex/cookies/ -│ │ │ ├── mod.rs -│ │ │ ├── jar.rs -│ │ │ └── repository.rs -│ │ └── models/ # 模型目录(按 plan)← codex/models/ -│ │ ├── mod.rs -│ │ ├── catalog.rs -│ │ ├── repository.rs -│ │ └── service.rs -│ │ -│ ├── gateway/ # ③ ⭐ 连 chatgpt.com(外部对接细节唯一处) -│ │ ├── mod.rs -│ │ ├── transport/ # ← codex/transport/ -│ │ │ ├── mod.rs -│ │ │ ├── client.rs # Pinned TLS + Desktop 指纹头 -│ │ │ ├── headers.rs -│ │ │ ├── sse.rs -│ │ │ ├── websocket.rs -│ │ │ ├── types.rs -│ │ │ └── usage.rs -│ │ ├── protocol/ # ← codex/protocol/(OpenAI ↔ Codex) -│ │ │ ├── mod.rs -│ │ │ ├── codex_to_openai.rs -│ │ │ ├── openai_to_codex.rs -│ │ │ ├── schema.rs -│ │ │ └── error.rs -│ │ ├── oauth/ # ← codex/oauth/ -│ │ │ ├── mod.rs -│ │ │ ├── client.rs -│ │ │ ├── refresh.rs -│ │ │ ├── token.rs -│ │ │ └── cli_import.rs -│ │ └── fingerprint/ # ← codex/fingerprint/ -│ │ ├── mod.rs -│ │ ├── model.rs -│ │ ├── repository.rs -│ │ └── updater.rs -│ │ -│ ├── logs/ # ④ 事件日志域(serving 产生,admin 读) -│ │ ├── mod.rs # ← logs/mod.rs -│ │ ├── event.rs # ← logs/event.rs -│ │ ├── repository.rs # ← logs/repository.rs -│ │ ├── rotation.rs # ← logs/rotation.rs -│ │ └── service.rs # ← service/log.rs -│ │ -│ ├── usage/ # ⑤ 用量统计域 -│ │ ├── mod.rs -│ │ └── service.rs # ← service/usage.rs -│ │ -│ └── tasks/ # ⑥ codex 域后台调度 ← scheduler/ -│ ├── mod.rs -│ ├── refresh.rs # ← scheduler/refresh.rs(令牌刷新) -│ ├── quota.rs # ← scheduler/quota.rs(配额解锁) -│ └── model.rs # ← scheduler/model.rs(模型刷新) -│ -├── admin/ # 🔵 后台管理域(控制面) -│ ├── mod.rs -│ ├── http/ # /api/admin/* ← http/api/admin/ -│ │ ├── mod.rs # ← http/api/admin/mod.rs -│ │ ├── router.rs # ← http/api/admin/router.rs -│ │ ├── response.rs # ← http/api/admin/response.rs(AdminError envelope) -│ │ ├── auth.rs # ← http/api/admin/auth.rs(login/status/logout) -│ │ ├── api_keys.rs # ← http/api/admin/api_keys.rs -│ │ ├── logs.rs # ← http/api/admin/logs.rs -│ │ ├── models.rs # ← http/api/admin/models.rs(refresh-models) -│ │ ├── settings.rs # ← http/api/admin/settings.rs -│ │ ├── usage.rs # ← http/api/admin/usage.rs -│ │ ├── diagnostics.rs # ← http/api/admin/diagnostics.rs -│ │ └── accounts/ # ⭐ 拆分 http/api/admin/accounts.rs(1697 行) -│ │ ├── mod.rs # 路由注册 + 共享 DTO -│ │ ├── list.rs # accounts / export_accounts -│ │ ├── create.rs # create_account / import_accounts / import_cli_auth -│ │ ├── mutate.rs # refresh / reset_usage / label / status / batch_status -│ │ ├── delete.rs # delete_account / batch_delete -│ │ ├── health.rs # health_check_accounts -│ │ ├── quota.rs # quota_warnings / account_quota -│ │ ├── cookies.rs # get / set / delete cookies -│ │ └── oauth.rs # ← http/api/admin/auth.rs 中 OAuth 流程 -│ │ # device-login / poll / login-start / code-relay / callback -│ ├── auth/ # 管理员认证 + key 管理策略 -│ │ ├── mod.rs -│ │ ├── service.rs # ← service/admin_auth.rs(密码/会话校验) -│ │ └── api_key.rs # ← service/api_key.rs(key CRUD 管理策略) -│ ├── settings.rs # ← service/settings.rs(运行时配置写 local.yaml) -│ └── tasks/ -│ ├── mod.rs -│ └── session_cleanup.rs # ← scheduler/session_cleanup.rs -│ -├── platform/ # 🔌 共享内核(纯数据/加密/身份,无业务策略) -│ ├── mod.rs -│ ├── storage/ # ← storage/ -│ │ ├── mod.rs -│ │ └── db.rs -│ ├── identity/ # ⭐ 跨域身份原语(admin 管 / codex 验,向下收口) -│ │ ├── mod.rs # ← auth/mod.rs -│ │ ├── api_key.rs # ← auth/api_key.rs(类型/哈希/verify) -│ │ ├── api_key_repository.rs # ← auth/api_key_repository.rs -│ │ ├── admin_session.rs # ← auth/admin_session.rs -│ │ └── error.rs # ← auth/error.rs -│ ├── crypto.rs # ← utils/crypto.rs -│ └── http/ # 共享 HTTP 横切 ← http/ -│ ├── mod.rs # ← http/mod.rs -│ ├── middleware.rs # ← http/middleware.rs -│ ├── health.rs # ← http/health.rs -│ └── auth.rs # ← http/auth.rs(从 header 提取 cpr_ / session) -│ -├── config/ # ⚙️ 配置 ← config/(不变) -│ ├── mod.rs -│ ├── loader.rs -│ └── types.rs -│ -└── utils/ # 🛠️ 通用工具 - ├── mod.rs - ├── json.rs - └── pagination.rs -``` - -## 三处需要拆分/拆解的非纯移动 - -大部分是目录搬迁,但有 3 个文件需要真正动刀: - -1. **`http/api/admin/accounts.rs`(1697 行)→ `admin/http/accounts/` 9 文件**。按上面 16 个 handler 归类(list/create/mutate/delete/health/quota/cookies/oauth + mod 路由)。 - -2. **`service/admin_auth.rs` 拆责**。密码登录/会话校验留 `admin/auth/service.rs`;OAuth token 交换 + 账号导入的编排移到 `admin/http/accounts/oauth.rs`,由它调用 `codex::gateway::oauth`(换 token)+ `codex::accounts::service::import`(落库)。 - -3. **api-key 一分为二**。校验原语(哈希/verify/repo)下沉 `platform/identity/`;管理策略(创建/禁用/导入导出)留 `admin/auth/api_key.rs`。这样 `/v1` 校验(codex)和 `/admin` 管理(admin)都向下依赖 platform,杜绝 codex→admin 反向依赖。 - -## 跨域接缝归属 - -| 接缝 | 管理方 | 使用方 | 落点 | -| --- | --- | --- | --- | -| 客户端 API Key (`cpr_`) | admin | codex/serving | `platform/identity/api_key*.rs` | -| Admin Session | admin | admin + platform 中间件 | `platform/identity/admin_session.rs` | -| 账户存储 | admin(增删改) | codex(池调度) | 数据在 `platform/storage`;runtime pool 在 `codex/accounts`;admin 调 `codex::accounts::service` | -| 事件日志 | admin(读/清) | codex/serving(写) | `codex/logs/`,admin→codex 正向读 | - -## 迁移执行顺序(分层提交,每步 `cargo build` 通过才提交) - -1. `platform/`(storage + crypto + identity + http 横切)→ 改引用 → build ✅ → commit -2. `codex/gateway/`(transport/protocol/oauth/fingerprint)→ build ✅ → commit -3. `codex/accounts/`(含 cookies/models 内迁)→ build ✅ → commit -4. `codex/serving/`(v1 http + chat/responses + dispatch + affinity + diagnostics)→ build ✅ → commit -5. `codex/{logs,usage,tasks}/` → build ✅ → commit -6. `admin/`(http 拆分 accounts + auth 拆责 + tasks)→ build ✅ → commit -7. `app/tasks/` 协调器 → build ✅ → commit -8. 更新 `tests/` 引用 → `cargo test` ✅ → commit -9. 删除空目录(旧 codex/、http/、service/、scheduler/、auth/、logs/、storage/)→ 最终验证 → commit - ---- - -## 与方案 A 的最终对比 - -| 维度 | 方案 A:分层(横切) | 方案 B 精炼:能力域(纵切) | -| --- | --- | --- | -| 顶层解开「后台 vs codex」 | ❌ 纠缠下沉进 `core/` | ✅ 顶层即断层线 | -| `codex` 是否还身兼两职 | infra/codex 仅对接(OK),但 core 混业务 | ✅ gateway 仅对接,serving/accounts 仅业务 | -| `core/` god-package 风险 | 高 | 无 core/ | -| 「HTTP 在哪」单一答案 | ✅ 都在 api/ | ❌ 各域 http/ 内(但每域内聚) | -| 改一个特性触达面 | 散在 api+core+tasks+infra | 多数在单域内 | -| 定时任务归属 | 独立 tasks/ 层 | 跟域走,app/ 仅协调 | -| 跨域接缝 | 被 core/ 掩盖 | 显式收口 platform/identity | -| reviewer 学习成本 | 低(教科书) | 中(模块化单体/DDD) | - -**结论**:方案 B 精炼版才真正根治你反复撞的痛点——`codex` 这个词从此只在 `gateway/` 表示「对接」,业务用 `serving/accounts/logs/usage` 领域名表达,后台是 `admin/` 控制面。方案 A 只是把 `codex/` 改名成技术层,纠缠搬进了 `core/`。 diff --git a/docs/architecture-reorganization.md b/docs/architecture-reorganization.md deleted file mode 100644 index 4c5feb71c..000000000 --- a/docs/architecture-reorganization.md +++ /dev/null @@ -1,1119 +0,0 @@ -# Architecture Reorganization Roadmap - -## Purpose - -This document defines the next cleanup track for `codex-proxy-rs`. - -The project direction is still valid: a single Rust service exposing OpenAI-compatible -`/v1/*` APIs backed by imported ChatGPT/Codex accounts, plus admin routes for local -operation. The problem is now structural growth. First-stage implementation optimized -for shipping behavior quickly, so HTTP handlers and `AppState` accumulated too many -responsibilities. - -The reorganization goal is not academic Clean Architecture. The goal is to keep feature -work cheap by making boundaries explicit: - -```text -http -> service -> codex / logs / storage / system auth -``` - -`http` parses requests, enforces route-level auth, and shapes responses. Business flow -orchestration moves into `service`. Codex-specific capability groups under the `codex` -domain: imported upstream accounts, Codex transport, OAuth/token refresh, protocol -translation, Codex model catalog, account-scoped cookies, and fingerprinting. Outside -`codex`, modules represent the backend system itself: app startup, HTTP, admin/client -authentication, logs, storage, config, and shared helpers. - -## Initial Signals - -The cleanup started from these pressure points: - -| File | Lines | Signal | -| --- | ---: | --- | -| `src/http/admin.rs` | 5169 | Admin routes, response DTOs, auth helpers, account import/export, account health, quota, cookies, API keys, model refresh, logs, usage, and helper logic are all in one module. | -| `src/http/v1.rs` | 1998 | Responses, Chat Completions, models, fallback/retry, refresh, usage logging, SSE collection, WebSocket bridging, and auth are mixed. | -| `src/state.rs` | 257 | `AppServices` is still a service locator with many optional dependencies. | -| `tests/admin_accounts_route_test.rs` | 2588 | Admin account scenarios are too large to scan or move safely. | -| `tests/v1_upstream_route_test.rs` | 2115 | Responses HTTP SSE, WebSocket, fallback, error mapping, usage, and logging side effects are mixed. | -| `tests/chat_completions_route_test.rs` | 710 | Manageable, but should eventually align with the v1 split. | -| `tests/admin_client_keys_route_test.rs` | 659 | Manageable, but can share common admin test setup. | - -The main coupling pattern is: - -```text -handler -> AppState -> repo / pool / codex client / refresh / logs / translation -``` - -That is acceptable for early delivery. It becomes expensive once new behavior needs to -touch fallback, account state, usage logs, and protocol conversion at the same time. - -## Current Progress - -As of this cleanup pass: - -- `src/http/api/admin/mod.rs` is the admin module entry, backed by resource-specific files - under `src/http/api/admin/` and a dedicated admin router. -- `src/http/v1/mod.rs` is the v1 module entry, backed by thin route handlers for auth, - chat, responses, models, errors, and router mounting under `src/http/v1/`. -- `src/service/` now contains backend-facing HTTP use-case services: - `AdminAuthService`, `ChatService`, `ResponsesService`, `ApiKeyService`, - `UsageService`, and `LogService`. -- `src/codex/` is now the upstream Codex integration domain. It contains imported - account state and account service orchestration, transport, protocol translation, - OAuth/token refresh, Codex model catalog and refresh orchestration, desktop - fingerprinting, account-scoped cookies, and v1 upstream dispatch/fallback helpers. -- v1 chat and responses handlers delegate orchestration to services. -- admin API key, usage, and logs handlers delegate repository work to services. -- admin login, logout, and status checks delegate business logic to `AdminAuthService`. -- admin OAuth device login/polling and PKCE start/callback/code-relay flows delegate - session acquisition, OAuth token exchange, account import, and duplicate-completion - handling to `AdminAuthService`. -- admin account list/export/status/label/delete/batch/reset-usage/cookies operations - delegate local repository, cookie repository, and account-pool coordination to - `codex::accounts::service::AccountService`. -- admin manual add, CLI import, OAuth token import, bulk account import, single-account - refresh, quota fetch, and health check now share - `codex::accounts::service::AccountService` storage, token refresh, Codex usage - probing, and account-pool synchronization. -- admin model catalog refresh orchestration lives in `codex::models::service::ModelService`. -- `tests/common/` now contains shared integration-test helpers, including generic - response/session helpers plus admin account and v1 upstream route fixtures. -- `tests/admin_accounts_route_test.rs` has been split into list, lifecycle, - import/export, OAuth/auth, and cookies/quota/refresh/health scenario files. -- `tests/v1_upstream_route_test.rs` has been split into Responses HTTP/SSE, - Responses WebSocket, upstream fallback/refresh, and upstream error scenario files. -- `src/app.rs` and `src/state.rs` have been moved into `src/runtime/router.rs` and - `src/runtime/state.rs`; `src/runtime/bootstrap.rs` owns production `AppState` construction. -- `src/config.rs` has been split into `src/config/types.rs` and - `src/config/loader.rs`. -- `src/crypto.rs` and `src/pagination.rs` have been moved into `src/utils/`, and the - duplicated JSON string traversal helper now lives in `src/utils/json.rs`. -- `src/error.rs` has been moved to `src/codex/protocol/error.rs`, matching its only - remaining protocol-translation use. -- service modules now use the target names `admin_auth`, `api_key`, `chat`, - `responses`, `usage`, and `log` instead of the temporary `*_service` filenames. - Codex-specific model refresh orchestration moved to `codex/models/service.rs`. -- `codex/upstream/mod.rs` exposes a `CodexUpstreamService` service dependency facade backed - by explicit `CodexUpstreamDependencies`, so request sending, refresh retry, fallback, - cookie persistence, usage recording, stream audit, and v1 logging no longer require - public helper wrappers that accept `AppState`. -- `codex/upstream/dispatch.rs`, `fallback.rs`, `refresh.rs`, `stream.rs`, and - `usage.rs` now own the obvious helper groups for dispatch response helpers, retry - classification, token refresh retry, SSE response collection, and usage recording. -- `codex/accounts/service/import.rs`, `lifecycle.rs`, `cookies.rs`, `quota.rs`, `health.rs`, - `refresh.rs`, and `runtime_pool.rs` now own account import, lifecycle, cookie, quota, - health-check, single-account refresh, and runtime-pool workflows instead of leaving - them in `service/account/mod.rs`. -- `ChatService` and `ResponsesService` own a cloned `CodexUpstreamService` instance injected from - `AppState` construction, so v1 orchestration no longer passes raw `AppState` through - upstream helper calls. -- `ChatService` and `ResponsesService` now receive only `ModelConfig`, `ModelService`, - and `CodexUpstreamService` as construction dependencies. v1 handlers no longer pass raw - `AppState` into service `handle` calls. -- admin session validation now runs through `AdminAuthService`; the HTTP auth helper only - extracts the admin session cookie and maps failures to the existing admin envelope - shape. -- old `AppState` raw dependency accessors for repositories, OAuth clients, refreshers, - hashers, event logs, the database, and the runtime account pool have been removed. -- runtime account-pool assertions in integration tests now go through narrow - `AccountService` methods instead of `AppState` exposing the pool. -- `AppServices` no longer stores construction-only raw dependencies such as the - database pool, secret box, API key hasher, token refresher, OAuth client, OAuth - sessions, event-log repository, or account pool. Runtime account-pool restore is now - delegated to `AccountService`. -- `AppState` public constructors now feed a private dependency bundle into one assembly - path, so service wiring is centralized instead of repeated across each test/runtime - constructor. -- v1 Responses, upstream fallback/error, and Chat Completions SSE payloads have been - moved out of route tests into `tests/fixtures/*.sse`. -- v1 Responses WebSocket mocked response payloads have been moved into - `tests/fixtures/*.json`. -- `http/api/admin/response.rs` now provides `AdminError`, and the admin route modules use - `Result<impl IntoResponse, AdminError>` for standard admin-envelope failures. -- admin auth handlers now propagate session, login, OAuth, and PKCE failures through - `AdminError`; success paths that must set cookies or redirect still build explicit - `Response` values. -- admin account handlers now use `Result` plus focused error mappers for repository, - import, refresh, health-check, quota, and validation failures. The quota route keeps a - small route-specific error type only to preserve the existing upstream fetch-error - detail under `data`. - -## Deferred Follow-Ups - -The core single-crate layering pass is complete. These items are intentionally left as -follow-up choices because doing them now would either change route contracts or split -already manageable files further than the current risk requires: - -- follow-up test cleanup for remaining medium-sized route files, especially Chat - Completions and admin account import/OAuth scenarios if they grow further. -- moving remaining large inline JSON bodies into `tests/fixtures/` only where they are - response/test data rather than request-shape assertions. -- deciding whether to preserve or redesign the admin quota fetch-error `data.error` - body before moving it fully onto the standard null-data `AdminError` contract. - -## Non-Goals - -- Do not split into a Rust workspace yet. -- Do not create `crates/core`, `crates/server`, `crates/store`, or `crates/upstream` in - this phase. -- Do not rewrite behavior while splitting files. -- Do not change route contracts, JSON envelopes, request IDs, auth semantics, or error - bodies during the mechanical split. -- Do not merge test cleanup with service extraction in the same change. - -The project is still moving quickly. A single crate with clearer internal modules is -the lowest-cost structure right now. - -## Rust Implementation Standards - -All code changes in this cleanup track should follow the repository's existing style -and the Rust best-practice rules below. - -### Ownership And Borrowing - -- Prefer borrowed inputs: use `&str` instead of `String`, `&[T]` instead of `Vec<T>`, - and `&T` instead of `T` when the callee only reads data. -- Clone only when a new owned value is actually needed. Acceptable clones include - `Arc` handles, cheap client handles that share internal pools, immutable snapshots, - and owned values required by a downstream API. -- Avoid cloning large request bodies, `Vec`, `HashMap`, account lists, or JSON values in - loops. Prefer iterators, references, or moving ownership at the boundary where the - value is consumed. -- Small `Copy` types can be passed by value. Large structs and enums should be passed - by reference unless ownership transfer is part of the API contract. -- Use `Cow<'_, str>` or `Cow<'_, [T]>` only when an API genuinely accepts either - borrowed or owned data. - -### Error Handling - -- Production code should return `Result<T, E>` for fallible operations. Do not use - `unwrap()` or `expect()` outside tests unless failure is impossible and the reason is - documented. -- Prefer precise error enums with `thiserror` for service/domain/repository errors. - Reserve `anyhow` for binaries, smoke helpers, or test helpers. -- Use `?` for propagation and `map_err`, `or_else`, or `inspect_err` where conversion, - recovery, or logging is needed. -- Avoid converting meaningful errors into `Option` too early. Preserve enough detail - for service-level classification and HTTP response mapping. -- Service errors should convert into admin envelopes or OpenAI-compatible v1 errors at - the HTTP boundary, not inside repository or domain modules. - -### Options And Control Flow - -- Use `let Some(value) = ... else { ... };` or `let Ok(value) = ... else { ... };` - when early return is the clearest path. -- Use `ok_or_else`, `unwrap_or_else`, and `map_or_else` when constructing fallback values - would allocate or do work. -- Use `match` when every variant matters or when transforming between nested - `Result`/`Option` shapes. - -### Async, Sharing, And Trait Objects - -- Shared runtime state should use `Arc` and explicit synchronization primitives such as - `tokio::sync::Mutex` only where lifecycle is required. -- Keep lock scopes short. Do not hold account-pool or session locks while performing - network requests, database calls, or expensive serialization. -- Prefer generics/static dispatch for local helper abstractions. Use `Arc<dyn Trait>` - only at boundaries that need runtime substitution, such as token refreshers or OAuth - clients in tests and production. -- Ensure async errors and trait objects used across tasks are `Send + Sync + 'static` - where the runtime requires it. - -### Performance Discipline - -- Do not optimize by guessing. Keep code simple first, then measure if a route or - service becomes hot. -- Avoid intermediate `collect()` calls when an iterator can be consumed directly. -- Avoid boxing or heap allocation until a type is large, recursive, or must cross a - trait-object boundary. -- Use `cargo clippy --all-targets --all-features --locked -- -D warnings` as the - authoritative lint gate. Pay special attention to `redundant_clone`, - `clone_on_copy`, `needless_collect`, `manual_ok_or`, and `large_enum_variant`. -- If a clippy lint must be suppressed, use `#[expect(clippy::...)]` with a short reason - instead of a broad `#[allow(...)]`. - -### Comments And Documentation - -- Prefer clear names and small functions over explanatory comments. -- Add `//` comments only for non-obvious reasoning: protocol quirks, safety guarantees, - workaround context, or route-contract constraints. -- Public service/domain types should have doc comments when their purpose or invariants - are not obvious from the type name. -- Do not leave untracked `TODO` comments. Any TODO that remains in code should reference - an issue or a concrete follow-up document. - -### Tests - -- Use descriptive test names that read like behavior, for example - `responses_should_retry_next_account_after_retry_after`. -- Prefer one behavior per test. Integration tests may need multiple assertions for a - route contract, but the scenario should still be narrow. -- Put pure logic tests next to the source module when they do not need HTTP setup. -- Use integration tests for route contracts, SQLite side effects, account-pool behavior, - mocked Codex transport, SSE, and WebSocket flows. -- Use fixture files for large JSON/SSE/WebSocket payloads. Keep snapshots small if - snapshot testing is added later. - -### Type-State Pattern - -Type-state is optional, not a default style. Consider it only when it makes invalid -states unrepresentable without excessive generic complexity, such as a future builder -that must prove required OAuth/session fields before a request can be sent. - -## Target Source Layout - -Target shape inside the existing crate: - -```text -src/ - main.rs - lib.rs - - app/ - mod.rs - router.rs - state.rs - bootstrap.rs - - config/ - mod.rs - types.rs - loader.rs - - utils/ - mod.rs - pagination.rs - json.rs - crypto.rs - - http/ - mod.rs - auth.rs - health.rs - middleware.rs - admin/ - mod.rs - router.rs - response.rs - auth.rs - accounts.rs - api_keys.rs - logs.rs - models.rs - settings.rs - usage.rs - v1/ - mod.rs - router.rs - auth.rs - chat.rs - responses.rs - models.rs - errors.rs - service/ - mod.rs - admin_auth.rs - api_key.rs - chat.rs - responses.rs - usage.rs - log.rs - - auth/ - mod.rs - admin_session.rs - api_key.rs - api_key_repository.rs - - codex/ - mod.rs - accounts/ - mod.rs - model.rs - repository.rs - pool.rs - lifecycle.rs - service/ - mod.rs - import.rs - lifecycle.rs - cookies.rs - quota.rs - health.rs - refresh.rs - runtime_pool.rs - transport/ - mod.rs - client.rs - headers.rs - sse.rs - types.rs - usage.rs - websocket.rs - oauth/ - mod.rs - cli_import.rs - client.rs - refresh.rs - token.rs - protocol/ - mod.rs - openai_to_codex.rs - codex_to_openai.rs - schema.rs - error.rs - models/ - mod.rs - catalog.rs - repository.rs - service.rs - fingerprint/ - mod.rs - model.rs - repository.rs - updater.rs - cookies/ - mod.rs - jar.rs - repository.rs - upstream/ - mod.rs - dispatch.rs - fallback.rs - refresh.rs - stream.rs - usage.rs - - logs/ - storage/ -``` - -This is a target, not a one-commit requirement. The project should stay as one crate -until module responsibilities stabilize. - -### Module Boundary Rules - -- `main.rs` should remain the binary entry point. Startup wiring should move toward - `app/bootstrap.rs`, route assembly toward `app/router.rs`, and runtime state toward - `app/state.rs`. -- `config` owns configuration data, loading, and environment parsing. Runtime business - modules should consume typed config, not environment variables. -- `utils` contains cross-domain primitives only. It can depend on standard crates and - small general-purpose dependencies such as `serde_json`, `base64`, and encryption - crates, but it must not depend on `http`, `service`, `auth`, `codex`, or other domain - modules. -- `utils/pagination.rs` owns cursor encoding, decoding, limit clamping, and the - reusable `Page<T>` shape. -- `utils/json.rs` owns small JSON traversal helpers used by import/parsing code. It - should not contain route-specific DTOs or validation policy. -- `utils/crypto.rs` owns reusable secret encryption primitives such as `SecretBox`, - `CryptoError`, and `CryptoResult`. API-key hashing remains in `auth/api_key.rs`. -- `http` owns route registration, auth extraction, request parsing, and response - mapping. It should not construct repositories, lock account pools, call Codex - clients directly, or perform protocol translation workflows. -- `service` owns backend-facing orchestration for HTTP use cases. It delegates Codex - account, model, upstream retry/fallback, protocol, OAuth, fingerprint, and transport - details into `codex/*`. -- `codex/accounts` owns imported Codex account data shapes, repository contracts, - lifecycle state, and the runtime account pool. It is not a `utils` helper and should - not be named only `accounts`, because this backend can also have its own local admin - users and client API keys. -- `codex/transport` owns direct Codex backend communication: request client, headers, - SSE parsing/encoding, usage extraction, transport types, and WebSocket bridging. -- `codex/oauth` owns Codex/OpenAI account authorization and token refresh mechanics. - System authentication remains in top-level `auth`. -- `codex/protocol` owns OpenAI-compatible request/response translation and - translation-specific errors. Do not recreate a top-level `src/error.rs` unless a - genuinely crate-wide error boundary is introduced. -- `codex/models`, `codex/fingerprint`, and `codex/cookies` own Codex-specific model - catalog, desktop fingerprint, and account-scoped cookie replay. -- `codex/upstream` owns v1 upstream dispatch, fallback, refresh retry, stream - collection, usage recording, and lifecycle logging helpers used by Chat and - Responses services. -- Top-level `logs`, `storage`, `config`, `utils`, `runtime`, `http`, and system `auth` - remain backend-system modules. - -### Completed Structural Increment - -The current low-risk structural increment moved the remaining top-level helper and -application wiring files into the target directories without changing behavior: - -```text -src/app.rs -> src/runtime/router.rs -src/state.rs -> src/runtime/state.rs -src/config.rs -> src/config/{types,loader}.rs -src/crypto.rs -> src/utils/crypto.rs -src/pagination.rs -> src/utils/pagination.rs -src/error.rs -> src/codex/protocol/error.rs -src/service/*_service.rs -> src/service/*.rs -src/service/v1_upstream.rs -> src/codex/upstream/mod.rs -src/service/account_service.rs -> src/codex/accounts/service/mod.rs -src/service/model.rs -> src/codex/models/service.rs -src/service/upstream/* -> src/codex/upstream/* -``` - -### Codex Domain Migration Order - -This structural track makes `codex/` the only top-level home for upstream Codex -concerns. Each step is behavior-preserving and should keep passing the verification -gate: - -1. Move the imported upstream account module into `codex/accounts` and update imports - to `crate::codex::accounts`. -2. Move direct Codex backend files into `codex/transport` and update imports to - `crate::codex::transport`. -3. Move OpenAI/Codex translation into `codex/protocol`. -4. Move Codex OAuth/token refresh/CLI import into `codex/oauth`, leaving system admin - sessions and client API keys in top-level `auth`. -5. Move Codex model catalog, fingerprint, and account-scoped cookies under - `codex/models`, `codex/fingerprint`, and `codex/cookies`. -6. Move remaining Codex-specific orchestration out of top-level `service/account`, - `service/model`, and `service/upstream` into `codex/accounts/service`, - `codex/models/service`, and `codex/upstream`. - -Callers should import cross-domain helpers through: - -```rust -use crate::utils::{ - crypto::{CryptoError, SecretBox}, - pagination::{clamp_limit, decode_cursor, encode_cursor, Page}, -}; -``` - -Integration tests should import public helpers through `codex_proxy_rs::utils::*`. -Do not keep top-level compatibility wrappers for `crypto` or `pagination`; leaving both -paths active would make the target boundary less clear. - -## Phase 1: Split HTTP Files Without Behavior Changes - -### Goal - -Reduce the former `src/http/admin.rs` and `src/http/v1.rs` large files into -resource-focused module directories while keeping handler bodies and helper logic -intact. - -This phase should be mostly mechanical: - -- move related structs/functions into new files; -- keep function names stable where tests or `app.rs` reference them; -- preserve route paths and methods; -- preserve response body shapes; -- preserve existing helper behavior, even when it is not ideally placed yet. - -### Admin Split - -Create: - -```text -src/http/api/admin/ - mod.rs - router.rs - response.rs - auth.rs - accounts.rs - api_keys.rs - logs.rs - models.rs - settings.rs - usage.rs -``` - -Suggested ownership: - -| Module | Initial contents | -| --- | --- | -| `response.rs` | `AdminEnvelope`, `AdminPageEnvelope`, `AdminResponse`, shared admin DTO helpers that are not resource-specific. | -| `auth.rs` | `login`, `auth_status`, `auth_logout`, OAuth PKCE/device routes, admin session cookie helpers. | -| `accounts.rs` | account list/create/import/export/delete/status/label, health check, refresh, reset usage, quota, cookies, account import parsing helpers. | -| `api_keys.rs` | local client key list/create/import/export/delete/status/label/batch helpers. | -| `logs.rs` | `/api/admin/logs` handler and log DTOs. | -| `models.rs` | `/api/admin/refresh-models` handler and model refresh DTOs. | -| `settings.rs` | `/api/admin/settings` handler and settings DTOs. | -| `usage.rs` | `/api/admin/usage-stats` and `/api/admin/usage-stats/summary`. | -| `router.rs` | admin route mounting only. | - -Keep cross-resource helpers private when possible. If a helper is needed by multiple -admin modules during the split, expose it as `pub(super)` and revisit it in Phase 2. - -The admin router should own admin paths: - -```rust -pub fn router() -> Router<AppState> { - Router::new() - .route("/api/admin/login", post(auth::login)) - .route("/api/admin/auth/status", get(auth::auth_status)) - .route("/api/admin/accounts", get(accounts::accounts).post(accounts::create_account)) - .route("/api/admin/api-keys", get(api_keys::api_keys).post(api_keys::create_api_key)) -} -``` - -`src/runtime/router.rs` should then merge admin routes instead of importing every admin handler: - -```rust -Router::new() - .route("/health", get(health)) - .merge(v1::router()) - .merge(admin::router()) -``` - -### V1 Split - -Create: - -```text -src/http/v1/ - mod.rs - router.rs - auth.rs - chat.rs - responses.rs - models.rs - errors.rs - logging.rs - upstream.rs -``` - -Suggested ownership: - -| Module | Initial contents | -| --- | --- | -| `chat.rs` | `/v1/chat/completions` handler and Chat-specific response conversion calls. | -| `responses.rs` | `/v1/responses` handler, SSE collection, non-streaming collection, WebSocket response path. | -| `models.rs` | `/v1/models`, catalog/detail/info/debug model routes. | -| `auth.rs` | client API key route authorization and missing-key response. | -| `errors.rs` | OpenAI-compatible v1 error body helpers such as model-not-found and upstream client errors. | -| `logging.rs` | `CodexRequestLogContext`, Codex upstream response logging, stream metadata helpers. | -| `upstream.rs` | account acquire/release guard, refresh retry, upstream account retry classification, cookie persistence, usage recording. | -| `router.rs` | v1 route mounting only. | - -In Phase 1, `upstream.rs` can still depend on `AppState` directly. The dependency -direction improves in Phase 2 when services become the orchestration boundary. - -### Phase 1 Acceptance - -Run after each small move: - -```bash -cargo fmt --check -cargo test --test admin_accounts_list_test -cargo test --test admin_accounts_mutation_test -cargo test --test admin_accounts_import_export_test -cargo test --test admin_accounts_oauth_test -cargo test --test admin_accounts_cookies_quota_test -cargo test --test admin_client_keys_route_test -cargo test --test v1_responses_http_sse_test -cargo test --test v1_responses_websocket_test -cargo test --test v1_upstream_fallback_test -cargo test --test v1_upstream_errors_test -cargo test --test chat_completions_route_test -``` - -Run before finishing the phase: - -```bash -cargo fmt --check -cargo test -cargo clippy --all-targets --all-features --locked -- -D warnings -``` - -## Phase 2: Introduce Service Layer - -### Goal - -Move business workflow orchestration out of handlers. Handlers should become thin: - -```rust -pub async fn chat_completions( - State(state): State<AppState>, - Extension(request_id): Extension<RequestId>, - headers: HeaderMap, - Json(req): Json<ChatCompletionRequest>, -) -> Response { - match state.services.chat.handle(request_id, headers, req).await { - Ok(resp) => resp.into_response(), - Err(err) => err.into_response(), - } -} -``` - -Do this after Phase 1 so the move is readable and test failures point to business -logic, not file-boundary churn. - -### Initial Services - -Start with v1 because it is the core proxy path: - -| Service | Responsibility | -| --- | --- | -| `ChatService` | Chat Completions flow: auth result input, model resolution, Chat-to-Codex translation, account acquisition, Codex transport, retry/fallback, Chat output conversion, usage/log side effects. | -| `ResponsesService` | Responses flow: model resolution, HTTP SSE/WebSocket transport selection, previous-response affinity, fallback policy, collection/streaming behavior, usage/log side effects. | -| `ModelService` | model catalog reads, cached backend model snapshots, v1 model response shaping. | - -Then move admin orchestration: - -| Service | Responsibility | -| --- | --- | -| `AdminAuthService` | admin login, session lifecycle, OAuth PKCE/device flows, logout/status. | -| `AccountService` | manual import, CLI import, native import/export, status/label/delete, refresh, quota, cookies, runtime pool synchronization. | -| `ApiKeyService` | local client key create/list/update/delete/import/export. | -| `UsageService` | usage list and summary queries. | -| `LogService` | admin log queries and v1 lifecycle log writes if useful. | - -The service layer should depend on repositories and domain modules. HTTP should depend -on services. Domain modules should not depend on HTTP. - -### Phase 2 Acceptance - -Each migrated handler should preserve its current route test. For every service moved, -add or retain focused tests at the cheapest level: - -- pure conversion and classification tests stay near source modules; -- route contract tests stay in `tests/`; -- service tests are useful when the orchestration has many branches and can be tested - without HTTP request boilerplate. - -Do not migrate all handlers at once. Move one vertical flow at a time: - -1. `ModelService` -2. `ResponsesService` -3. `ChatService` -4. `AdminAuthService` -5. `AccountService` -6. `ApiKeyService` - -## Phase 3: Reshape AppState - -### Current Shape - -`AppState` already wraps `Arc<AppServices>`, but `AppServices` still exposes raw, -optional dependencies: - -```rust -pub struct AppServices { - pub config: AppConfig, - pub db: Option<SqlitePool>, - pub event_logs: Option<EventLogRepository>, - pub secret_box: Option<SecretBox>, - pub api_key_hasher: Option<ApiKeyHasher>, - pub token_refresher: Option<Arc<dyn TokenRefresher>>, - pub oauth_client: Option<Arc<dyn OAuthClient>>, - pub oauth_sessions: Arc<Mutex<PkceSessionStore>>, - pub account_pool: Arc<Mutex<AccountPool>>, -} -``` - -This creates two problems: - -- handlers and helpers can reach almost everything; -- every business path has to repeatedly decide what a missing dependency means. - -### Target Shape - -Move toward: - -```rust -pub struct AppState { - pub services: Arc<AppServices>, -} - -pub struct AppServices { - pub config: AppConfig, - pub auth: AdminAuthService, - pub accounts: AccountService, - pub api_keys: ApiKeyService, - pub chat: ChatService, - pub responses: ResponsesService, - pub models: ModelService, - pub usage: UsageService, - pub logs: LogService, -} -``` - -Repositories, Codex clients, account pool handles, OAuth clients, token refreshers, -and hashers should be injected into the service that actually uses them. - -Keep constructors practical. During migration, it is fine to support both old helper -accessors and new service fields. Remove the old accessors only after handlers stop -using them. - -### Dependency Rule - -After this phase: - -- HTTP modules should not construct repositories. -- HTTP modules should not lock the account pool directly. -- HTTP modules should not call Codex clients directly. -- HTTP modules should not update usage or event logs directly. -- Service modules can orchestrate those operations. -- Domain/repository modules should remain HTTP-agnostic. - -## Phase 4: Unify Errors And Response Envelopes - -### Goal - -Make route error handling less repetitive without changing external contracts. - -Targets: - -```text -src/codex/protocol/error.rs -src/http/response.rs -src/http/api/admin/response.rs -src/http/v1/errors.rs -``` - -Admin and v1 should keep different public contracts: - -- admin routes return the admin envelope and lower camelCase body fields; -- v1 routes return OpenAI-compatible error objects and headers. - -The unification is internal: shared error types, shared conversion points, and fewer -ad hoc `(StatusCode, Json<Value>)` helpers in handlers. - -Preferred handler shape: - -```rust -pub async fn handler(...) -> Result<impl IntoResponse, AppError> { - let data = state.services.accounts.list(...).await?; - Ok(AdminEnvelope::ok(data, request_id)) -} -``` - -Do not force one public response format across admin and v1. - -## Test Reorganization - -The test suite should be split for the same reason as the source: current files prove -behavior, but they are too large to maintain. - -Rust integration tests compile each `tests/*.rs` file as a separate crate, so avoid -extreme fragmentation. Prefer 20-40 focused integration test files rather than one -file per endpoint. - -### Target Test Layout - -Recommended shape: - -```text -tests/ - common/ - mod.rs - app.rs - fixtures.rs - http.rs - mock.rs - assertions.rs - fixtures/ - openai_chat_basic.json - codex_response_basic.sse - codex_response_error.sse - websocket_response_completed.json - admin_auth_test.rs - admin_accounts_list_test.rs - admin_accounts_mutation_test.rs - admin_accounts_import_export_test.rs - admin_accounts_oauth_test.rs - admin_accounts_cookies_quota_test.rs - admin_api_keys_test.rs - admin_logs_models_settings_test.rs - admin_usage_test.rs - v1_chat_test.rs - v1_responses_http_sse_test.rs - v1_responses_websocket_test.rs - v1_upstream_fallback_test.rs - v1_upstream_errors_test.rs - v1_models_test.rs - account_pool_test.rs - account_repository_test.rs - codex_client_test.rs - codex_websocket_test.rs - translation_test.rs -``` - -### `tests/common` - -Move shared setup first, before splitting large tests: - -```rust -pub struct TestApp { - pub router: Router, - pub state: AppState, - pub db: SqlitePool, - pub client_key: Option<String>, - pub admin_cookie: Option<String>, -} -``` - -Useful helpers: - -```rust -pub async fn test_app() -> TestApp; -pub async fn test_app_with_config(config: AppConfig) -> TestApp; -pub async fn create_admin_session(app: &TestApp) -> String; -pub async fn create_client_key(app: &TestApp) -> String; -pub async fn import_test_account(app: &TestApp) -> Account; -pub async fn json_get(app: &TestApp, path: &str) -> Response; -pub async fn json_post<T: Serialize>(app: &TestApp, path: &str, body: &T) -> Response; -pub fn assert_status(response: &Response, expected: StatusCode); -``` - -If upstream mocking is repeated, wrap it: - -```rust -pub struct MockCodex { - pub base_url: Url, - pub server: MockServer, -} -``` - -### First Test Splits - -Split `tests/admin_accounts_route_test.rs` first: - -| New file | Scenario group | -| --- | --- | -| `admin_accounts_list_test.rs` | list, status read, labels in list, reset usage visibility. | -| `admin_accounts_mutation_test.rs` | create, delete, batch delete, batch status, label/status updates. | -| `admin_accounts_import_export_test.rs` | manual import, native import/export, CLI import. | -| `admin_accounts_oauth_test.rs` | device login, device poll, PKCE login start, callback/code relay. | -| `admin_accounts_cookies_quota_test.rs` | cookies, health check, refresh, quota, reset usage side effects. | - -Then split `tests/v1_upstream_route_test.rs`: - -| New file | Scenario group | -| --- | --- | -| `v1_responses_http_sse_test.rs` | Responses HTTP SSE setup, non-stream collection, passthrough streaming, terminal SSE failures. | -| `v1_responses_websocket_test.rs` | previous response WebSocket, explicit WebSocket, account affinity, handshake failures. | -| `v1_upstream_fallback_test.rs` | 429/402/403 classification, retry-after, fallback account acquisition, exhausted account behavior. | -| `v1_upstream_errors_test.rs` | upstream error bodies, transport error classification, refresh failure mapping, lifecycle log status. | -| `v1_models_test.rs` | model list/catalog/detail/info/debug routes if not already covered elsewhere. | - -Keep `tests/chat_completions_route_test.rs` until v1 helpers are extracted, then rename or -merge into `v1_chat_test.rs`. - -### Unit Tests Near Source - -Move pure logic tests closer to source modules where practical: - -- translation request mapping; -- Codex-to-OpenAI response conversion; -- model catalog suffix parsing; -- upstream error classification; -- retry-after parsing; -- usage extraction; -- quota normalization; -- header construction. - -Integration tests should prove route contracts and side effects. Unit tests should cover -branchy pure functions without HTTP setup. - -### Fixture Files - -Move large JSON/SSE/WebSocket payloads into `tests/fixtures/`: - -```rust -const BASIC_CHAT: &str = include_str!("fixtures/openai_chat_basic.json"); -const CODEX_ERROR_SSE: &str = include_str!("fixtures/codex_response_error.sse"); -``` - -Do this after test setup extraction and initial file splits. Fixture movement is cleanup, -not a prerequisite for architectural splitting. - -## Recommended Execution Order - -The branch has already completed the large HTTP file split, most initial service -extraction, `tests/common` extraction, the two largest integration test splits, -`AdminAuthService`-backed admin session validation, raw `AppState` dependency accessor -removal, v1 upstream dependency injection, the `app/`, `config/`, `utils/`, and -`translation/error.rs` migrations, and the first account/upstream service submodule -splits. From the current state, continue in this order: - -1. Continue moving remaining account/upstream methods from `mod.rs` into the workflow - submodules when a helper group can move without changing route behavior. -2. Finish moving large inline JSON route payloads into `tests/fixtures/` - where they are test data rather than request-shape assertions. -3. Continue unifying internal error/response conversion, starting with the larger admin - `auth` and `accounts` modules after the smaller Result-style handlers have stayed - green. -4. Revisit medium-sized route tests only when they grow or start duplicating setup. - -The original sequence below is kept as a reference for starting from an unorganized -tree or reviewing why earlier moves were ordered that way. - -### Step 1: Establish Test Common Ground - -Create `tests/common` and move duplicated app/database/session/key setup there. Do not -split test files yet. - -Verification: - -```bash -cargo test --test admin_accounts_list_test -cargo test --test admin_accounts_mutation_test -cargo test --test admin_accounts_import_export_test -cargo test --test admin_accounts_oauth_test -cargo test --test admin_accounts_cookies_quota_test -cargo test --test v1_responses_http_sse_test -cargo test --test v1_responses_websocket_test -cargo test --test v1_upstream_fallback_test -cargo test --test v1_upstream_errors_test -cargo test -``` - -### Step 2: Split Admin Tests - -Mechanically move scenarios out of `admin_accounts_route_test.rs` by scenario group. -Keep assertions unchanged unless a shared helper removes duplication. - -Verification after each new file: - -```bash -cargo test --test admin_accounts_list_test -cargo test --test admin_accounts_mutation_test -cargo test --test admin_accounts_import_export_test -cargo test --test admin_accounts_oauth_test -cargo test --test admin_accounts_cookies_quota_test -``` - -### Step 3: Split V1 Tests - -Mechanically move scenarios out of `v1_upstream_route_test.rs`. - -Verification after each new file: - -```bash -cargo test --test v1_responses_http_sse_test -cargo test --test v1_responses_websocket_test -cargo test --test v1_upstream_fallback_test -cargo test --test v1_upstream_errors_test -``` - -### Step 4: Split Former `src/http/admin.rs` - -Move admin code into `src/http/api/admin/*.rs`, with `src/http/api/admin/mod.rs` as the module -entry. Preserve behavior. - -Verification: - -```bash -cargo test --test admin_auth_test -cargo test --test admin_accounts_list_test -cargo test --test admin_accounts_mutation_test -cargo test --test admin_accounts_import_export_test -cargo test --test admin_accounts_oauth_test -cargo test --test admin_accounts_cookies_quota_test -cargo test --test admin_client_keys_route_test -cargo test --test admin_logs_route_test -cargo test --test admin_models_route_test -cargo test --test admin_settings_route_test -cargo test --test admin_usage_stats_route_test -``` - -### Step 5: Split Former `src/http/v1.rs` - -Move v1 code into `src/http/v1/*.rs`, with `src/http/v1/mod.rs` as the module entry. -Preserve behavior. - -Verification: - -```bash -cargo test --test chat_completions_route_test -cargo test --test v1_responses_http_sse_test -cargo test --test v1_responses_websocket_test -cargo test --test v1_upstream_fallback_test -cargo test --test v1_upstream_errors_test -cargo test --test routes_responses_test -``` - -### Step 6: Extract Services One Flow At A Time - -Start with low-risk service extraction: - -1. `ModelService` -2. `ResponsesService` -3. `ChatService` -4. `AdminAuthService` -5. `AccountService` -6. `ApiKeyService` - -For each service: - -- move orchestration out of the handler; -- keep HTTP request parsing and response wrapping in HTTP; -- keep repository SQL in repository modules; -- keep Codex transport in `codex`; -- keep translation in `translation`; -- keep route tests green. - -### Step 7: Tighten `AppState` - -After services own dependencies, remove direct handler access to raw repositories, -pool locks, token refreshers, and Codex clients. Keep this as a separate phase so -behavior regressions are easier to isolate. - -### Step 8: Response/Error Cleanup - -Unify internal error conversion after service boundaries are stable. Do not change -public admin or v1 response contracts. - -## Guardrails For Each PR - -Each PR or commit should state which kind of change it is: - -```text -mechanical move only -test split only -service extraction -state dependency cleanup -error/response cleanup -``` - -Avoid mixing categories. - -Before merging a mechanical move, use diff review to confirm: - -- no route path changed; -- no HTTP method changed; -- no JSON field changed; -- no status code changed; -- no auth requirement changed; -- no request-id behavior changed; -- no upstream fallback policy changed; -- no database schema changed. - -Minimum verification for mechanical HTTP moves: - -```bash -cargo fmt --check -cargo test --test admin_accounts_list_test -cargo test --test admin_accounts_mutation_test -cargo test --test admin_accounts_import_export_test -cargo test --test admin_accounts_oauth_test -cargo test --test admin_accounts_cookies_quota_test -cargo test --test admin_client_keys_route_test -cargo test --test v1_responses_http_sse_test -cargo test --test v1_responses_websocket_test -cargo test --test v1_upstream_fallback_test -cargo test --test v1_upstream_errors_test -cargo test --test chat_completions_route_test -``` - -Full verification before declaring a phase complete: - -```bash -cargo fmt --check -cargo test -cargo clippy --all-targets --all-features --locked -- -D warnings -``` - -## Decision Record - -- Keep a single crate for now. -- Split source files before changing architecture. -- Split tests before or alongside source file movement so failures are local. -- Extract v1 services before admin services because v1 is the core proxy path. -- Keep admin and v1 public response contracts separate. -- Treat local `cpr_` API key management as a Rust-local admin utility, not TypeScript - local client key parity. -- Do not add proxy pools, non-OpenAI providers, frontend/Electron flows, or workspace - crate boundaries as part of this cleanup. diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 98369409b..000000000 --- a/docs/architecture.md +++ /dev/null @@ -1,891 +0,0 @@ -# 架构规范 - -本文档定义 `codex-proxy-rs` 的正式 Rust workspace 架构。每个 Rust 源文件、目录、 -依赖方向和命名约定在最终完成时必须与本文档完全一致。 - -不允许过渡期别名。不允许兼容性重导出。迁移必须直接收敛到此布局。 - -本文档仅约束 Rust workspace 源码布局。`web/` 前端和 `docs/` 目录有各自的约定, -不属于 Rust 源码白名单。 - -## 目录 - -1. [架构风格](#架构风格) -2. [核心规则](#核心规则) -3. [根 Workspace 布局](#根-workspace-布局) -4. [依赖方向](#依赖方向) -5. [Crate 职责](#crate-职责) -6. [精确 Rust 源码形状](#精确-rust-源码形状) -7. [Cargo 集成测试形状](#cargo-集成测试形状) -8. [端口(Port)放置](#端口port放置) -9. [领域模型归属](#领域模型归属) -10. [WebSocket 架构——硬拆分](#websocket-架构硬拆分) -11. [迁移计划](#迁移计划) -12. [架构测试](#架构测试) -13. [完成标准](#完成标准) - -## 架构风格 - -本应用是一个重组为多 crate workspace 的模块化单体,具有严格的六边形边界。 -每个 crate 有且仅有一个明确定义的职责。 - -- **`core`** 拥有领域模型、用例、协议语义、策略和端口(port trait)。 - 它是最内层——不依赖本 workspace 中的任何其他 crate。 -- **`adapters`** 拥有 `core` 端口的具体实现——SQLx 仓储、Reqwest HTTP 客户端、 - tokio-tungstenite WebSocket 客户端、OAuth 客户端、指纹更新客户端。 -- **`runtime`** 拥有应用组装——`AppState`、依赖注入、后台任务启停、配置映射。 -- **`server`** 拥有 Axum HTTP 边界——路由、处理器、中间件、响应信封、 - SSE 帧封装、错误到 HTTP 的映射。 -- **`platform`** 拥有共享基础设施原语——配置加载、加密、身份哈希、 - SQLite 连接初始化、schema 文件、文件系统路径、日志原语、JSON 和分页帮手。 -- **`assets`** 拥有编译后的前端静态资源服务——SPA 回退、静态缓存、安全头。 -- **`xtask`** 拥有本地自动化——前端构建、架构检查、发布命令。 - -## 核心规则 - -### 依赖规则(不可协商) - -1. `core` 不得依赖任何其他 workspace crate。 -2. `core` 不得依赖 `axum`、`sqlx`、`reqwest`、`tokio-tungstenite`、 - `tungstenite`、`rustls`、`tokio-rustls` 或 `hyper`。 -3. `core` 不得拥有文件系统路径、环境变量读取或具体网络 IO。 -4. `platform` 不得依赖 `core`、`server`、`runtime`、`adapters` 或 `assets`。 -5. `adapters` 不得依赖 `runtime` 或 `server`。 -6. `runtime` 不得依赖 `server`。 -7. `server` 不得包含 SQL 查询、具体上游 Codex IO、账号选择策略或后台任务编排。 -8. `assets` 不得依赖任何 workspace crate。 - -### 结构规则 - -1. [精确 Rust 源码形状](#精确-rust-源码形状) 中列出的每个文件和目录都必须存在。 -2. `crates/*/src/` 下不得存在白名单外的任何 Rust 源文件或目录,根目录不得保留 - `src/`。 -3. 每个 `Cargo.toml` 的 `[dependencies]` 条目必须至少被该 crate `src/` 中的一个 - `use` 语句引用。 -4. 根 package 不提供 library 或 binary target;类似 `crates/server/facade.rs` 的文件被禁止。 -5. 端口 trait 存放在各自领域模块的 `ports.rs` 文件中。 -6. 仓储实现存放在 `adapters/src/sqlite/` 中。 -7. 每条 `TODO` 注释必须引用一个 GitHub issue:`// TODO(#123): ...` - -### Rust 最佳实践(不可协商) - -以下规则适用于每个 crate,由架构测试和 CI 强制执行。 - -- **所有权**:参数优先使用 `&T` 而非 `.clone()`。参数使用 `&str`, - 返回自有数据使用 `String`。`Copy` 类型(≤ 24 字节)按值传递。 -- **错误处理**:可失败操作返回 `Result<T, E>`。测试外禁止 - `unwrap()` 或 `expect()`。库错误类型使用 `thiserror`。用 `?` 传播。 -- **Lint**:`cargo clippy --all-targets --all-features --locked -- -D warnings` - 必须通过。使用 `#[expect(clippy::lint)]` 并附上理由注释。 -- **测试**:每个测试一个断言。测试名称描述行为: - `某操作_在某条件下_应产生某结果()`。生成输出的快照测试使用 `cargo insta`。 - 公共 API 示例使用 doc test。 -- **文档**:`//` 解释**为什么**(设计理由、安全不变量)。`///` 解释**什么** - (公共 API 契约)。在 `core`、`adapters` 和 `platform` 上启用 - `#![deny(missing_docs)]`。 -- **泛型**:性能关键路径优先使用静态分发(泛型)。仅在异构集合需要时使用 - `dyn Trait`。在 API 边界 Box,而非内部。 -- **性能**:绝不在循环中 clone。优先使用迭代器而非手动索引。避免中间的 - `.collect()` 调用。用 `--release` 运行基准测试。 - -## 根 Workspace 布局 - -```text -codex-proxy-rs/ -├── Cargo.toml # [workspace] 含 members = ["crates/*"] -├── Cargo.lock -├── README.md -├── AGENTS.md -├── crates/ -│ ├── core/ -│ │ ├── Cargo.toml -│ │ └── src/ -│ ├── adapters/ -│ │ ├── Cargo.toml -│ │ └── src/ -│ ├── runtime/ -│ │ ├── Cargo.toml -│ │ └── src/ -│ ├── server/ -│ │ ├── Cargo.toml -│ │ └── src/ -│ ├── platform/ -│ │ ├── Cargo.toml -│ │ └── src/ -│ ├── assets/ -│ │ ├── Cargo.toml -│ │ └── src/ -│ └── xtask/ -│ ├── Cargo.toml -│ └── src/ -├── tests/ -├── web/ -└── docs/ -``` - -**根 `Cargo.toml`** 必须声明 `[workspace]`,其中 `members = ["crates/*"]`, -并定义 workspace 级别的依赖、lint 设置和 profile 覆盖。根 package 不提供 -library 或 binary target;根 `tests/` 只承载跨 workspace 架构测试。 - -## 依赖方向 - -```text -server ──► runtime + core + assets + platform -runtime ──► core + adapters + platform -adapters ──► core + platform -core ──► (无 workspace crate 依赖) -assets ──► (无 workspace crate 依赖) -xtask ──► (仅 workspace 工具链) -platform ──► (无 workspace crate 依赖) -``` - -**被禁止的项目 crate 依赖**(由架构测试强制执行): - -| 源 Crate | 被禁止的依赖 | -|---------|------------| -| `core` | `platform`、`server`、`runtime`、`adapters`、`assets` | -| `platform` | `core`、`server`、`runtime`、`adapters`、`assets` | -| `adapters` | `runtime`、`server` | -| `runtime` | `server` | -| `assets` | 所有 workspace crate | - -`runtime` 在构造 `core` 服务前将 `platform::config` 类型映射为 `core` 的配置 DTO。 -这使 `core` 独立于配置文件路径、YAML 解析和环境变量。 - -## Crate 职责 - -### `crates/core` - -拥有领域概念、不变量、用例编排、协议语义和端口。此 crate 零 IO 依赖。 - -**允许:** -- 账号池和调度策略(`accounts/pool.rs`) -- 管理员业务用例(`admin/`) -- 认证/会话领域行为(`auth/`) -- 模型目录规则(`models/`) -- 用量和事件策略(`usage/`、`events/`) -- OpenAI/Codex 协议数据结构和纯转换(`protocol/`) -- WebSocket 消息编解码(单个帧的序列化、反序列化、验证——**非**连接建立) -- 请求分发、回退、重试、配额检查、亲和性、恢复和用量跟踪策略(`serving/`) -- 面向上游的端口 trait(`gateway/ports.rs`) -- 类型化领域和应用错误(`error.rs` 或各模块的 `errors.rs`) - -**禁止:** -- `axum` 提取器、路由、响应、`IntoResponse` 实现 -- `sqlx` 连接池、查询、行、事务、迁移 -- 具体的 `reqwest::Client` 构造或使用 -- 具体的 WebSocket/TLS 连接建立(`tokio_tungstenite`、`tokio-rustls`、`rustls`) -- 文件系统路径构造或环境变量读取 -- 平台配置加载器类型(`platform::config::*`) - -### `crates/adapters` - -拥有 `core` 端口的具体实现。每个实现结构体以其使用的技术命名。 - -**允许:** -- `SqliteAccountStore` —— 账号存储端口的 SQLx 实现 -- `SqliteAdminSessionStore` —— 管理员会话端口的 SQLx 实现 -- `SqliteClientKeyStore` —— 客户端密钥端口的 SQLx 实现 -- `SqliteEventLogStore` —— 事件日志端口的 SQLx 实现 -- `SqliteModelSnapshotStore` —— 模型快照端口的 SQLx 实现 -- `SqliteSessionAffinityStore` —— 会话亲和性的 SQLx 实现 -- `SqliteCookieStore` —— Cookie 端口的 SQLx 实现 -- `SqliteUsageStore` —— 用量跟踪端口的 SQLx 实现 -- `ReqwestCodexClient` —— 上游 Codex HTTP/SSE 的 Reqwest 实现 -- `ReqwestFingerprintClient` —— 指纹更新的 Reqwest 实现 -- `OpenAiOAuthClient` —— OpenAI OAuth 流程的 Reqwest 实现 -- `CodexWebSocketConnection` —— WS 连接建立和消息收发的 tokio-tungstenite 实现 -- `CodexWebSocketPool` —— WS 连接池的 tokio-tungstenite 实现 -- `ReqwestTlsConfig` —— 自定义 CA 配置的 rustls 实现 -- 适配器特定错误从框架错误到 `core` 错误类型的翻译 - -**禁止:** -- 业务策略决策 -- Axum 路由处理器 -- 运行时任务编排 -- 领域不变量 - -### `crates/runtime` - -拥有应用组装。这是 "胶水" 层。 - -**允许:** -- `AppState` 结构体构造 -- 服务构造(用 `adapters` 实现实例化 `core` 服务) -- 仓储和适配器构造 -- 向 `server` 处理器注入依赖 -- 后台任务启动和协调关闭 -- 启动恢复流程(从数据库加载账号到池中) -- `platform::config` → `core` 配置 DTO 映射 - -**禁止:** -- HTTP 请求/响应映射 -- 原始 SQL 查询字符串 -- 具体上游协议的副作用 -- 账号选择、回退、配额、模型或恢复策略决策 - -### `crates/server` - -拥有 HTTP 边界。所有 Axum 类型都在这里。 - -**允许:** -- Axum 路由和路由处理器 -- 提取器和中间件(`request_id`、tracing、CORS、auth) -- 管理员响应信封(`AdminResponse`、`AdminEnvelope`) -- OpenAI 兼容的 HTTP 响应体和错误形状 -- HTTP 状态码和头映射 -- 客户端响应的 SSE 帧封装 -- 路由本地的 OpenAI API 错误/事件帧封装 - -**禁止:** -- SQL 查询 -- 具体上游 Codex IO -- 账号选择、回退、配额、模型或恢复策略 -- 后台任务编排 - -### `crates/platform` - -拥有无领域知识的跨领域基础设施原语。 - -**允许:** -- 配置加载(`config/loader.rs`)和配置 schema 类型(`config/types.rs`) -- 加密原语:AES-GCM 密钥箱(`crypto/secret_box.rs`)、HMAC-SHA256 哈希(`crypto/hash.rs`) -- 身份哈希:管理员密码(Argon2)、客户端 API 密钥(HMAC) -- SQLite 连接初始化(`storage/sqlite.rs`)和 schema 文件(`storage/schema.sql`) -- 文件系统路径帮手(`storage/paths.rs`) -- 日志原语(`logging/`)—— tracing subscriber 初始化、轮转 -- JSON 帮手(`json/`)—— cursor 分页、信封帮手 - -**禁止:** -- Codex/OpenAI 业务用例 -- 管理员业务用例 -- Axum 处理器 -- `core` 端口的实现 -- 领域模型类型 - -### `crates/assets` - -拥有前端静态资源服务。 - -**允许:** -- 根 `/` → `index.html` 响应 -- `/assets/*` 静态文件服务 -- SPA 回退(API 路由后的任何未匹配路径 → `index.html`) -- 静态缓存和安全头 - -### `crates/xtask` - -拥有本地自动化。它是二进制 crate,不是 library。 - -**允许:** -- 前端构建编排(`build_web.rs`) -- 架构检查(`check_architecture.rs`) -- 发布/打包命令(`release.rs`) - -## 精确 Rust 源码形状 - -下面列出的每个文件和目录在最终完成时必须存在。`crates/*/src/` 下 -不得存在其他 Rust 源文件或目录,根目录不得保留 `src/`。 - -### Core - -```text -crates/core/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── error.rs - ├── admin/ - │ ├── mod.rs - │ ├── ports.rs # AdminSessionStore、ClientKeyStore trait - │ ├── auth.rs # 管理员密码验证逻辑 - │ ├── client_keys.rs # API 密钥服务(仅业务逻辑) - │ └── settings.rs # 设置领域逻辑 - ├── accounts/ - │ ├── mod.rs - │ ├── model.rs # Account、AccountStatus、NewAccount - │ ├── ports.rs # AccountStore、CookieStore、UsageStore trait - │ ├── service.rs # 账号管理用例 - │ ├── pool.rs # AccountPool 调度 - │ ├── lifecycle.rs # 状态转换、过期规则 - │ ├── cloudflare.rs # Cloudflare path-block 纯状态追踪 - │ ├── cookies.rs # Cookie 捕获/重放策略 - │ ├── jwt.rs # JWT 过期检查(纯逻辑,无网络) - │ └── usage.rs # 用量累积策略 - ├── auth/ - │ ├── mod.rs - │ ├── ports.rs # OAuthClient trait - │ ├── oauth.rs # OAuth 流程编排(无 HTTP) - │ └── session.rs # 管理员会话领域逻辑 - ├── models/ - │ ├── mod.rs - │ ├── model.rs # Model、ModelPlan、ModelCatalog - │ ├── ports.rs # ModelSnapshotStore trait - │ ├── catalog.rs # 模型目录规则 - │ └── service.rs # 模型服务用例 - ├── events/ - │ ├── mod.rs - │ ├── model.rs # EventLog、EventLevel - │ ├── ports.rs # EventLogStore trait - │ └── service.rs # 日志策略(启用、容量、保留) - ├── usage/ - │ ├── mod.rs - │ ├── model.rs # UsageSnapshot、UsageWindow - │ ├── ports.rs # UsageStore trait - │ └── service.rs # 用量聚合策略 - ├── protocol/ - │ ├── mod.rs - │ ├── openai/ - │ │ ├── mod.rs - │ │ ├── chat.rs # ChatCompletionRequest/Response - │ │ ├── responses.rs # Response API 类型 - │ │ ├── models.rs # Model list 类型 - │ │ └── errors.rs # OpenAI 错误类型 - │ └── codex/ - │ ├── mod.rs - │ ├── chat.rs # Codex 特定 chat 扩展 - │ ├── responses.rs # CodexResponsesRequest - │ ├── events.rs # Codex SSE 事件类型 - │ ├── sse.rs # SSE 事件解析(纯函数,无 IO) - │ ├── websocket.rs # WS 帧类型、payload 编解码 - │ └── schema.rs # JSON schema 验证规则 - ├── gateway/ - │ ├── mod.rs - │ ├── ports.rs # CodexUpstreamClient、FingerprintClient trait - │ ├── fingerprint.rs # 指纹模型和版本规则 - │ ├── conversation.rs # 会话标识派生 - │ └── installation.rs # 安装 ID 生成规则 - └── serving/ - ├── mod.rs - ├── chat.rs # Chat 补全编排 - ├── responses.rs # Response 创建编排 - ├── errors.rs # 服务错误类型 - ├── routing.rs # 账号选择路由策略 - ├── fallback.rs # 回退策略规则 - ├── affinity.rs # 会话亲和性策略 - ├── quota.rs # 配额检查策略 - ├── implicit_resume.rs # Responses 隐式续接策略 - ├── reasoning_replay.rs # Responses reasoning replay 缓存策略 - ├── stream.rs # 流生命周期策略 - ├── recovery.rs # 错误恢复规则 - └── usage.rs # 用量跟踪策略 -``` - -**`core` 的关键设计决策:** - -- `protocol/codex/websocket.rs` 包含 WS 帧类型定义和纯编解码 - (序列化/反序列化单个消息)。它**不**打开连接、不执行 TLS 握手、不调用 - `tokio_tungstenite`。 -- `protocol/codex/sse.rs` 包含 SSE 事件解析,作为 `&[u8]` → `Vec<SseEvent>` 的纯函数。 - 无网络 IO。 -- `serving/` 模块包含策略决策("用哪个账号"、"是否重试"),但将所有 IO - 委托给 `gateway/ports.rs` 中定义的 trait。 -- `core/src/` 中没有任何文件以 `_repository.rs` 结尾。 - 存储实现位于 `adapters` 中。 - -### Adapters - -```text -crates/adapters/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── sqlite/ - │ ├── mod.rs - │ ├── accounts.rs # SqliteAccountStore - │ ├── account_tokens.rs # token 在 DB 中的加解密/轮换 - │ ├── account_usage.rs # SqliteUsageStore - │ ├── refresh_leases.rs # 分布式刷新锁 - │ ├── cookies.rs # SqliteCookieStore - │ ├── events.rs # SqliteEventLogStore - │ ├── models.rs # SqliteModelSnapshotStore - │ ├── session_affinity.rs # SqliteSessionAffinityStore - │ ├── admin_sessions.rs # SqliteAdminSessionStore - │ └── client_keys.rs # SqliteClientKeyStore - ├── codex/ - │ ├── mod.rs - │ ├── client.rs # ReqwestCodexClient(HTTP + SSE) - │ ├── models.rs # 通过 Reqwest 获取模型列表 - │ ├── fingerprint.rs # ReqwestFingerprintClient - │ └── websocket/ - │ ├── mod.rs - │ ├── connect.rs # CodexWebSocketConnection — tokio-tungstenite - │ ├── pool.rs # CodexWebSocketPool - │ ├── deflate.rs # permessage-deflate 处理 - │ └── opening.rs # WS 打开握手(TLS + Upgrade) - └── oauth/ - ├── mod.rs - └── openai.rs # OpenAiOAuthClient(Reqwest) -``` - -**`adapters` 的关键设计决策:** - -- `sqlite/` 模块按**存储什么**命名,而非怎么存储。`SqliteAccountStore` 实现 - `core::accounts::ports::AccountStore`。 -- `codex/websocket/connect.rs` 包装 `tokio_tungstenite::connect_async`, - 返回 `core::protocol::codex::websocket::WsConnection`(trait 或 opaque handle)。 - `core` 永远不会看到 `tokio_tungstenite` 类型。 -- `codex/websocket/opening.rs` 处理原始 TCP/TLS 连接、自定义头顺序和 WebSocket 升级。 - 它产生 `OpeningAuditSnapshot` 用于一致性测试。 -- `oauth/openai.rs` 使用 Reqwest 实现 `core::auth::ports::OAuthClient`。 - -### Runtime - -```text -crates/runtime/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── bootstrap.rs # 启动:加载配置、初始化 DB、恢复池 - ├── state.rs # AppState 构造 - ├── services.rs # 服务工厂函数 - ├── repositories.rs # 仓储工厂函数 - ├── upstream.rs # 适配器工厂函数 - ├── config.rs # platform::config → core 配置 DTO 映射 - └── tasks/ - ├── mod.rs - ├── coordinator.rs # 后台任务生成/关闭 - ├── token_refresh.rs # 定期 token 刷新任务接线 - ├── quota_refresh.rs # 定期配额刷新任务接线 - ├── model_refresh.rs # 定期模型刷新任务接线 - ├── cookie_cleanup.rs # 定期 cookie 清理任务接线 - ├── session_cleanup.rs # 定期会话清理任务接线 - ├── session_affinity_cleanup.rs # 定期会话亲和性清理任务接线 - └── fingerprint_update.rs # 定期指纹更新任务接线 -``` - -**`runtime` 的关键设计决策:** - -- 任务文件仅包含接线:它们从 `core` 接收 `Arc<dyn PortTrait>`,从配置中获取间隔, - 并启动 `tokio::spawn` 循环。它们不包含领域策略。 -- `runtime/src/config.rs` 将 `platform::config::AppConfig` 翻译为 - `core::admin::AdminConfig`、`core::accounts::PoolConfig` 等。 -- `runtime/src/bootstrap.rs` 编排启动序列,但将所有具体 IO - 委托给 `adapters` 工厂。 - -### Server - -```text -crates/server/ -├── Cargo.toml -└── src/ - ├── main.rs # #[tokio::main] 入口 - ├── lib.rs - ├── router.rs # 顶层 Axum Router - ├── error/ - │ ├── mod.rs - │ ├── admin.rs # 管理员错误 → HTTP 响应 - │ └── openai.rs # 领域错误 → OpenAI 错误形状 - ├── middleware/ - │ ├── mod.rs - │ ├── request_id.rs # X-Request-Id 头 + 扩展 - │ ├── trace.rs # HTTP tracing 层 - │ ├── auth.rs # API 密钥 / 会话提取 - │ └── cors.rs # CORS 配置 - ├── admin_api/ - │ ├── mod.rs - │ ├── router.rs # /api/admin/* 路由 - │ ├── response.rs # AdminEnvelope、AdminResponse - │ ├── session.rs # 登录/登出处理器 - │ ├── settings.rs # GET/PATCH /api/admin/settings - │ ├── diagnostics.rs # GET /api/admin/diagnostics - │ ├── models.rs # POST /api/admin/refresh-models - │ ├── usage.rs # GET /api/admin/usage-stats - │ ├── accounts/ - │ │ ├── mod.rs - │ │ ├── list.rs - │ │ ├── create.rs - │ │ ├── import.rs - │ │ ├── import_cli.rs # Codex CLI auth.json 导入 - │ │ ├── export.rs - │ │ ├── lifecycle.rs # status/label/delete/batch - │ │ ├── quota.rs # 配额警告 - │ │ ├── cookies.rs # cookie 管理 - │ │ ├── oauth.rs # OAuth 流程处理器 - │ │ └── health.rs # 健康检查 - │ ├── client_keys/ - │ │ ├── mod.rs - │ │ ├── list.rs - │ │ ├── create.rs - │ │ ├── import.rs - │ │ ├── export.rs - │ │ └── lifecycle.rs # label/status/delete/batch - │ └── logs/ - │ ├── mod.rs - │ ├── query.rs - │ ├── detail.rs - │ └── state.rs - └── openai_api/ - ├── mod.rs - ├── router.rs # /v1/* 路由 - ├── auth.rs # Bearer token 提取 - ├── chat.rs # POST /v1/chat/completions - ├── responses.rs # POST /v1/responses - ├── models.rs # GET /v1/models - ├── diagnostics.rs # GET /debug/* - ├── error.rs # OpenAI 错误 → HTTP 响应 - └── sse.rs # 客户端的 SSE 事件帧封装 -``` - -**`server` 的关键设计决策:** - -- 路由处理器是薄的:提取认证、解析请求体、调用 `core::serving::*` 用例、 - 将响应映射为 HTTP。处理器中没有业务逻辑。 -- `server/src/openai_api/sse.rs` 将 `core::protocol::codex::events::SseEvent` - 帧封装为 HTTP 响应的 `data: {...}\n\n`。它拥有线格式,不拥有事件语义。 -- `server/src/error/openai.rs` 将 `core::serving::errors::ServingError` - 映射为 OpenAI 兼容的 `{"error": {...}}` JSON 响应。 - -### Platform - -```text -crates/platform/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── config/ - │ ├── mod.rs - │ ├── loader.rs # YAML 配置加载(含 local.yaml 覆盖) - │ └── types.rs # AppConfig、ServerConfig 等 - ├── crypto/ - │ ├── mod.rs - │ ├── secret_box.rs # AES-256-GCM 加解密 - │ └── hash.rs # HMAC-SHA256 + 常数时间比较 - ├── identity/ - │ ├── mod.rs - │ ├── admin_password.rs # Argon2 密码哈希/验证 - │ └── client_key.rs # API 密钥生成 + HMAC 哈希 - ├── storage/ - │ ├── mod.rs - │ ├── sqlite.rs # SqlitePool 构造 + WAL pragma - │ ├── schema.sql # 所有表的 DDL - │ └── paths.rs # data/ 目录路径帮手 - ├── logging/ - │ ├── mod.rs - │ └── rotation.rs # tracing-subscriber 初始化 + 文件轮转 - └── json/ - ├── mod.rs - └── pagination.rs # Cursor 编解码 + Page<T> -``` - -**`platform` 的关键设计决策:** - -- `config/types.rs` 定义配置 schema 类型,供 `config/loader.rs` 使用。 - 这些是纯数据,不是领域类型。 -- `crypto/secret_box.rs` 是 `aes_gcm` 上的薄包装,输出 base64 编码密文。 - 它不知道账号或 token。 -- `identity/client_key.rs` 包含基于 HMAC 的 API 密钥哈希器,但**不**包含端口 trait—— - 那在 `core::admin::ports` 中。 -- `storage/schema.sql` 是 DDL 的唯一真实来源。它在连接时被 - `storage/sqlite.rs` 通过 `include_str!` 引入。 - -### Assets - -```text -crates/assets/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── router.rs # / 和 /assets/* 路由 - └── headers.rs # Cache-Control、CSP、安全头 -``` - -### Xtask - -```text -crates/xtask/ -├── Cargo.toml -└── src/ - ├── main.rs # CLI 分发器 - ├── build_web.rs # 构建 web/ 前端 - ├── check_architecture.rs # 运行架构测试 - └── release.rs # 发布打包 -``` - -## 端口(Port)放置 - -端口是 `core` 拥有的 trait。每个端口 trait 在其领域模块中存放在名为 `ports.rs` 的文件中。 - -| 端口 Trait | 位置 | 实现(在 `adapters` 中) | -|---|---|---| -| `AccountStore` | `core/src/accounts/ports.rs` | `SqliteAccountStore` | -| `CookieStore` | `core/src/accounts/ports.rs` | `SqliteCookieStore` | -| `UsageStore` | `core/src/accounts/ports.rs` | `SqliteUsageStore` | -| `AdminSessionStore` | `core/src/admin/ports.rs` | `SqliteAdminSessionStore` | -| `ClientKeyStore` | `core/src/admin/ports.rs` | `SqliteClientKeyStore` | -| `OAuthClient` | `core/src/auth/ports.rs` | `OpenAiOAuthClient` | -| `ModelSnapshotStore` | `core/src/models/ports.rs` | `SqliteModelSnapshotStore` | -| `EventLogStore` | `core/src/events/ports.rs` | `SqliteEventLogStore` | -| `SessionAffinityStore` | `core/src/serving/affinity.rs` | `SqliteSessionAffinityStore` | -| `CodexUpstreamClient` | `core/src/gateway/ports.rs` | `ReqwestCodexClient` | -| `CodexWebSocketTransport` | `core/src/gateway/ports.rs` | `CodexWebSocketConnection` | -| `FingerprintClient` | `core/src/gateway/ports.rs` | `ReqwestFingerprintClient` | - -每个 `ports.rs` 文件仅包含 trait 定义和相关类型别名 -(例如 `AccountStoreResult<T> = Result<T, AccountStoreError>`)。 - -## 领域模型归属 - -领域模型是纯数据结构体,不包含 IO。所有模型类型归 `core` 所有。 - -| 模型 | 位置 | 备注 | -|------|------|------| -| `Account` | `core/src/accounts/model.rs` | 运行时账号表示 | -| `AccountStatus` | `core/src/accounts/model.rs` | 枚举:Active、Expired、Disabled、Banned | -| `StoredAccount` | `adapters/src/sqlite/accounts.rs` | DB 行类型——适配器内部 | -| `EventLog` | `core/src/events/model.rs` | 结构化事件 | -| `EventLevel` | `core/src/events/model.rs` | Info、Warn、Error | -| `Model`、`ModelPlan` | `core/src/models/model.rs` | 模型目录条目 | -| `UsageWindow` | `core/src/usage/model.rs` | 聚合用量窗口 | -| `Fingerprint` | `core/src/gateway/fingerprint.rs` | 设备指纹 | -| `ChatCompletionRequest` | `core/src/protocol/openai/chat.rs` | OpenAI 请求/响应类型 | -| `CodexResponsesRequest` | `core/src/protocol/codex/responses.rs` | Codex 请求类型 | -| `SseEvent` | `core/src/protocol/codex/sse.rs` | 解析后的 SSE 事件 | -| `CodexWebSocketResponse` | `core/src/protocol/codex/websocket.rs` | WS 响应聚合 | -| `OpeningAuditSnapshot` | `core/src/protocol/codex/websocket.rs` | WS 握手审计快照 | -| `PayloadAuditSnapshot` | `core/src/protocol/codex/websocket.rs` | WS payload 审计快照 | -| `WsParityDiff` | `core/src/protocol/codex/websocket.rs` | 一致性差异报告 | - -**规则**:`StoredAccount` 和类似的 DB 行类型是 `adapters` 内部类型。它们 -**不**作为 `core` 端口 trait 的输入或输出暴露。端口 trait 使用方法签名 -中的领域模型类型(`Account`、`EventLog` 等)。适配器在内部将 DB 行 -转换为领域类型。 - -## WebSocket 架构——硬拆分 - -WebSocket 代码是架构上最具挑战性的拆分,因为当前 -`src/codex/gateway/transport/websocket/` 目录混淆了三个不同的关注点: - -### 1. 纯协议编解码 → `core` - -处理 WS 消息格式、验证和单个帧序列化/反序列化的文件。这些无 IO 依赖。 - -- **`core/src/protocol/codex/websocket.rs`**:帧类型定义、 - `CodexWebSocketResponse`、`CodexWebSocketStreamResponse`、`WsMessage<T>`。 - 纯数据类型。 -- `CodexResponsesRequest` ↔ WS 文本帧 JSON 的序列化/反序列化逻辑。纯转换。 - -### 2. 连接管理 → `adapters` - -处理实际 TCP/TLS 连接、WebSocket 握手和消息收发的文件。 -这些依赖 `tokio-tungstenite`、`tokio-rustls`、`rustls`。 - -- **`adapters/src/codex/websocket/connect.rs`**:包装 - `tokio_tungstenite::connect_async`,返回实现 `core` 中定义的 - `WsTransport` trait 的 opaque handle。 -- **`adapters/src/codex/websocket/pool.rs`**:连接池管理、保活、驱逐。 -- **`adapters/src/codex/websocket/opening.rs`**:原始 TLS 连接、 - 自定义头顺序、WebSocket 升级握手、accept key 验证。 - 产生 `OpeningAuditSnapshot`(一个 `core` 类型)用于一致性对比。 -- **`adapters/src/codex/websocket/deflate.rs`**:permessage-deflate - 扩展协商和帧压缩/解压。 - -### 3. 审计快照 → `core` - -审计快照类型是纯数据,用于比较代理的行为与官方 Codex Desktop 客户端。 -它们属于 `core`,因为代表协议级别的一致性关注点。 - -- **`core/src/protocol/codex/websocket.rs`**(或单独的 `audit.rs`): - `OpeningAuditSnapshot`、`PayloadAuditSnapshot`、`WsParityDiff`。 - -### Trait 桥接 - -WebSocket 的 `core` 和 `adapters` 之间的桥接是 `core/src/gateway/ports.rs` 中的一个 trait: - -```rust -// core/src/gateway/ports.rs - -#[async_trait] -pub trait CodexWebSocketTransport: Send + Sync { - async fn connect( - &self, - request: &CodexResponsesRequest, - context: &RequestContext, - ) -> Result<CodexWebSocketStreamResponse, CodexWebSocketError>; - - async fn connect_with_pool( - &self, - request: &CodexResponsesRequest, - context: &RequestContext, - ) -> Result<CodexWebSocketStreamResponse, CodexWebSocketError>; -} -``` - -`adapters` 用 `tokio-tungstenite` 实现此 trait。`core` 调用该 trait 而无需知道具体实现。 - -## 迁移计划 - -迁移分八个阶段进行。每个阶段必须产出**可编译、测试通过的 workspace**, -然后才能开始下一阶段。不允许多个阶段交错进行,不允许过渡期别名。 - -### 阶段 1:Workspace 脚手架 - -1. 在根 `Cargo.toml` 中添加 `[workspace]`,`members = ["crates/*"]`。 -2. 创建 `crates/` 目录及所有七个子目录,每个含 stub `Cargo.toml`。 -3. 在根 `Cargo.toml` 中定义 workspace 级别依赖、lint 设置和 profile 覆盖。 -4. 验证 `cargo check --workspace` 通过(所有 crate 为空)。 - -**验收**:`cargo check --workspace` 编译通过。workspace 成员数量架构测试通过。 - -### 阶段 2:Platform 提取 - -1. 从当前 `src/platform/`、`src/config/` 和 `src/utils/` 创建 `crates/platform/`。 -2. 移动文件:`platform/` 直接映射。`config/` → `platform/src/config/`。 - `utils/` → `platform/src/json/` 和 `platform/src/`(合并小型帮手)。 -3. 更新 `platform` 的 `Cargo.toml` 依赖:仅提取 `serde`、`serde_yaml`、 - `config`、`aes-gcm`、`argon2`、`hmac`、`sha2`、`base64`、`rand`、 - `sqlx`、`tracing`、`tracing-subscriber`、`tracing-appender`、`chrono`。 -4. 删除旧的 `src/platform/`、`src/config/`、`src/utils/`。 -5. 更新所有 `use crate::platform::` → `use platform::`。 -6. 更新所有 `use crate::config::` → `use platform::config::`。 -7. 更新所有 `use crate::utils::` → `use platform::json::`。 - -**验收**:`cargo test -p platform` 通过。所有现有测试通过。 - -### 阶段 3:Core 提取——协议 + 模型 - -1. 创建 `crates/core/`,含空的 `src/`。 -2. 将 `src/codex/gateway/protocol/` 和 `src/codex/serving/http/` - 中的协议类型和请求/响应类型移动到 `core/src/protocol/`。 -3. 将领域模型从 `src/codex/accounts/model.rs`、`src/codex/models/`、 - `src/codex/events/model.rs` 移动到 `core/src/accounts/model.rs`、 - `core/src/models/` 等。 -4. 在 `core/src/*/ports.rs` 中定义端口 trait——从当前内联 trait 定义 - 和仓储签名中提取。 -5. Core 的 `Cargo.toml` 依赖:仅 `serde`、`serde_json`、`chrono`、 - `thiserror`、`async-trait`、`uuid`。**零 IO crate**。 - -**验收**:`cargo check -p core` 通过。Core 零 IO crate 依赖。 - -### 阶段 4:Adapters 提取 - -1. 创建 `crates/adapters/`,含 `src/sqlite/`、`src/codex/`、`src/oauth/`。 -2. 将所有 `*_repository.rs` 文件和 `repository/` 目录移动到 - `adapters/src/sqlite/`。重命名去掉 `_repository` 后缀 - (例如 `accounts.rs` 而非 `account_repository.rs`)。 -3. 移动 `src/codex/gateway/transport/http_client.rs` → - `adapters/src/codex/client.rs`。 -4. 移动 `src/codex/gateway/transport/endpoints.rs` → - `adapters/src/codex/endpoints.rs`。 -5. 移动 `src/codex/gateway/transport/custom_ca.rs` → - `adapters/src/codex/tls.rs`。 -6. 移动 `src/codex/gateway/oauth/client.rs` → - `adapters/src/oauth/openai.rs`。 -7. 移动 `src/codex/gateway/fingerprint/update_checker.rs` → - `adapters/src/codex/fingerprint.rs`。 -8. 拆分 `src/codex/gateway/transport/websocket/`: - - `codec.rs` 协议逻辑 → `core/src/protocol/codex/websocket.rs` - - `mod.rs`、`pool.rs`、`opening.rs`、`deflate.rs` → - `adapters/src/codex/websocket/` -9. 在适配器结构体上实现 `core` 端口 trait。 - -**验收**:`cargo test -p adapters` 通过。架构测试确认 `core` 依赖中无 -`axum`、`sqlx`、`reqwest` 或 `tokio-tungstenite`。 - -### 阶段 5:Core 提取——业务逻辑 - -1. 将剩余 `src/codex/accounts/` 业务逻辑(pool、lifecycle、cookies、 - jwt、usage policy)移动到 `core/src/accounts/`。 -2. 将 `src/codex/serving/dispatch/`(affinity、fallback、routing、 - recovery、stream、usage、implicit_resume、reasoning_replay)移动到 - `core/src/serving/`。 -3. 将 `src/admin/session/service.rs`、`src/admin/client_keys/service.rs`、 - `src/admin/settings.rs` 移动到 `core/src/admin/`。 -4. 将 `src/codex/events/service.rs` 移动到 `core/src/events/service.rs`。 -5. 所有移动的文件必须使用端口 trait,而非具体实现。 - 替换 `AccountRepository` 为 `dyn AccountStore` 等。 - -**验收**:`cargo test -p core` 通过。所有 core 业务逻辑测试通过。 - -### 阶段 6:Server 提取 - -1. 移动 `src/admin/api/` → `crates/server/src/admin_api/`。 -2. 移动 `src/codex/serving/http/` → `crates/server/src/openai_api/`。 -3. 移动 `src/runtime/router.rs` → `crates/server/src/router.rs`。 -4. 移动 `src/platform/http/` 中间件 → `crates/server/src/middleware/`。 -5. 移动 `src/web/` → `crates/assets/`。 -6. 移动 `src/main.rs` → `crates/server/src/main.rs`。 -7. 所有 Axum 类型(`Router`、`IntoResponse`、提取器)必须仅存在于 - `server` 和 `assets` 中。 - -**验收**:`cargo test -p server` 通过。架构测试确认 `core`、`adapters`、 -`runtime`、`platform` 中无 Axum 类型。 - -### 阶段 7:Runtime 清理 - -1. `runtime/src/` 仅保留 `bootstrap.rs`、`state.rs`、`services.rs`、 - `repositories.rs`、`upstream.rs`、`config.rs` 和 `tasks/`。 -2. 移除 `runtime/` 中残留的任何 HTTP、SQL 或领域策略。 -3. `runtime` 通过将 `core` 服务与 `adapters` 实现接线来构造 `AppState`。 - -**验收**:`cargo test -p runtime` 通过。架构测试确认 `runtime` 中无 -SQL 查询字符串或 Axum 处理器。 - -### 阶段 8:架构强制执行 - -1. 编写 `tests/architecture/directory_shape.rs` —— 验证 - [精确 Rust 源码形状](#精确-rust-源码形状) 中的每个文件和目录存在, - 且无额外文件/目录。 -2. 编写 `tests/architecture/dependency_direction.rs` —— 通过 - `cargo metadata` + 解析 `Cargo.toml` 依赖验证 crate 级别依赖规则。 -3. 编写 `tests/architecture/forbidden_imports.rs` —— grep 查找被禁止的 - crate 使用(`core` 中的 `use axum::`、`runtime` 中的 `use sqlx::` 等)。 -4. 运行完整 CI:`cargo fmt --check`、`cargo check --workspace --all-targets`、 - `cargo test --workspace`、`cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`。 - -**验收**:所有架构测试通过。所有 CI 检查通过。 - -## 架构测试 - -架构测试是验证 workspace 符合本文档的集成测试。 -它们作为 `cargo test --workspace` 的一部分运行。 - -### `tests/architecture/directory_shape.rs` - -验证: -- [精确 Rust 源码形状](#精确-rust-源码形状) 中的每个必需文件存在。 -- 每个必需目录存在。 -- `crates/*/src/` 下无额外 Rust 源文件或目录,根目录无 `src/`。 -- `Cargo.toml` workspace 成员匹配 `["crates/core", "crates/adapters", - "crates/runtime", "crates/server", "crates/platform", "crates/assets", - "crates/xtask"]`。 -- 根 package 不提供 library 或 binary target。 - -### `tests/architecture/dependency_direction.rs` - -验证: -- `core/Cargo.toml` 无 workspace crate 依赖。 -- `platform/Cargo.toml` 无 workspace crate 依赖。 -- `adapters/Cargo.toml` 不依赖 `runtime` 或 `server`。 -- `runtime/Cargo.toml` 不依赖 `server`。 -- `assets/Cargo.toml` 无 workspace crate 依赖。 -- `xtask/Cargo.toml` 不依赖 `assets`。 - -### `tests/architecture/forbidden_imports.rs` - -验证: -- `core`、`adapters`、`runtime`、`platform` 中无 `use axum::`。 -- `core`、`server`、`runtime` 中无 `use sqlx::`。 -- `core`、`server`、`runtime` 中无 `use reqwest::`。 -- `core`、`server`、`runtime` 中无 `use tokio_tungstenite::` 或 - `use tungstenite::`。 -- `core` 中无 `use rustls::` 或 `use tokio_rustls::`。 -- `core/src/` 下无名为 `*_repository.rs` 的文件。 -- `core/src/` 下无名为 `repository/` 的目录。 -- `core/src/` 下无名为 `transport/` 的目录。 -- `crates/server/src/` 下无名为 `facade.rs` 的文件。 - -## 完成标准 - -当**所有**以下条件为真时,迁移完成: - -1. Rust workspace 源码树与 [精确 Rust 源码形状](#精确-rust-源码形状) 完全匹配。 -2. 被禁止的名称(架构测试中列出)不存在。 -3. 所有架构测试通过(`cargo test --test architecture`)。 -4. `cargo fmt --check --workspace` 通过。 -5. `cargo check --workspace --all-targets` 通过。 -6. `cargo test --workspace` 通过(所有 473+ 现有测试 + 新的架构测试)。 -7. `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` 通过。 -8. `cargo doc --workspace --no-deps` 无错误生成。 -9. `core`、`adapters` 和 `platform` 上启用了 `#![deny(missing_docs)]`。 -10. 非测试代码中无不安全的 `unwrap()`、`expect()`、`panic!` 或 `todo!`。 diff --git a/docs/backend-naming-audit.md b/docs/backend-naming-audit.md deleted file mode 100644 index 7ea3a033c..000000000 --- a/docs/backend-naming-audit.md +++ /dev/null @@ -1,466 +0,0 @@ -# Backend Naming Audit - -## Verdict - -当前后端命名可以作为第一版完整架构定稿。主边界已经清楚:`runtime` 负责装配,`web` 负责未来 Web 控制台承载,`admin` 负责管理域,`codex` 负责 Codex 业务内核,`platform` 负责跨领域基础设施。 - -命名的艺术不在于词汇华丽,而在于每个名字都能回答三个问题: - -- 这个目录属于哪个领域。 -- 这个文件对外暴露什么责任。 -- 这个名字是否会让后来的人误以为它还承担了别的职责。 - -本轮审计结论:源码目录已经达到“可长期演进”的层级。剩余问题主要是少数兼容性术语和偏大的聚合文件,不影响当前架构定稿。 - -## Naming Principles - -- 路由表面用 `api`、`http`、`web` 表达协议和承载面。 -- 业务能力用领域名表达,例如 `accounts`、`models`、`events`、`gateway`。 -- 基础设施只放在 `platform`,避免业务目录承担系统能力。 -- 测试目录跟随生产目录命名,测试文件表达场景而不是实现细节。 -- 对外兼容词可以保留在 route 和 DTO 中,内部模块优先使用更准确的领域词。 - -## Complete Architecture Tree - -以下目录排除了生成物和本地状态:`.git`、`target`、`data`、`logs`、`designs`。 - -```text -. -├── docs -│ ├── superpowers -│ │ ├── plans -│ │ │ ├── 2026-06-11-codex-proxy-rs.md -│ │ │ ├── 2026-06-12-openai-gpt-codex-parity.md -│ │ │ ├── 2026-06-13-utils-module-migration.md -│ │ │ ├── 2026-06-14-backend-naming-architecture.md -│ │ │ └── 2026-06-14-logging-refactor.md -│ │ └── specs -│ │ ├── 2026-06-12-openai-gpt-codex-parity-design.md -│ │ ├── 2026-06-13-architecture-refactor-design.md -│ │ ├── 2026-06-14-backend-naming-architecture-design.md -│ │ └── 2026-06-14-logging-refactor-design.md -│ ├── ADMIN_INITIALIZATION_FIX.md -│ ├── FINGERPRINT_AUTO_UPDATE.md -│ ├── FINGERPRINT_FIX.md -│ ├── FINGERPRINT_PROPAGATION_FIX.md -│ ├── FINGERPRINT_UPDATE_TEST_REPORT.md -│ ├── IMPLEMENTATION_COMPARISON.md -│ ├── IMPLEMENTATION_COMPLETE.md -│ ├── OPENAI_CODEX_PARITY_AUDIT.md -│ ├── OPENAI_REQUEST_ALIGNMENT.md -│ ├── RELIABILITY_ENHANCEMENTS.md -│ ├── RESPONSE_CHAIN_ALIGNMENT.md -│ ├── api.md -│ ├── architecture-audit.md -│ ├── architecture-capability-plan.md -│ ├── architecture-reorganization.md -│ ├── backend-naming-audit.md -│ ├── code-quality-audit.md -│ ├── database-storage-audit.md -│ ├── dependency-policy.md -│ ├── implementation-status.md -│ ├── optimization-roadmap.md -│ ├── rust-community-review.md -│ ├── scheduler-completion-report.md -│ ├── scheduler-implementation.md -│ ├── scheduler-usage.md -│ └── status-codes.md -├── src -│ ├── admin -│ │ ├── api -│ │ │ ├── accounts -│ │ │ │ ├── cookies.rs -│ │ │ │ ├── create.rs -│ │ │ │ ├── delete.rs -│ │ │ │ ├── export.rs -│ │ │ │ ├── health.rs -│ │ │ │ ├── import.rs -│ │ │ │ ├── lifecycle.rs -│ │ │ │ ├── list.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── oauth.rs -│ │ │ │ └── quota.rs -│ │ │ ├── client_keys -│ │ │ │ ├── create.rs -│ │ │ │ ├── export.rs -│ │ │ │ ├── import.rs -│ │ │ │ ├── lifecycle.rs -│ │ │ │ ├── list.rs -│ │ │ │ └── mod.rs -│ │ │ ├── logs -│ │ │ │ ├── detail.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── query.rs -│ │ │ │ └── state.rs -│ │ │ ├── diagnostics.rs -│ │ │ ├── mod.rs -│ │ │ ├── models.rs -│ │ │ ├── response.rs -│ │ │ ├── router.rs -│ │ │ ├── session.rs -│ │ │ ├── settings.rs -│ │ │ └── usage.rs -│ │ ├── client_keys -│ │ │ ├── mod.rs -│ │ │ └── service.rs -│ │ ├── session -│ │ │ ├── mod.rs -│ │ │ ├── repository.rs -│ │ │ └── service.rs -│ │ ├── tasks -│ │ │ ├── mod.rs -│ │ │ └── session_cleanup.rs -│ │ ├── mod.rs -│ │ └── settings.rs -│ ├── codex -│ │ ├── accounts -│ │ │ ├── cookies -│ │ │ │ ├── jar.rs -│ │ │ │ ├── mod.rs -│ │ │ │ └── repository.rs -│ │ │ ├── repository -│ │ │ │ ├── accounts.rs -│ │ │ │ ├── leases.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── quotas.rs -│ │ │ │ ├── tokens.rs -│ │ │ │ └── usage.rs -│ │ │ ├── service -│ │ │ │ ├── cookies.rs -│ │ │ │ ├── health.rs -│ │ │ │ ├── import.rs -│ │ │ │ ├── lifecycle.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── pool_sync.rs -│ │ │ │ ├── quota.rs -│ │ │ │ └── refresh.rs -│ │ │ ├── cloudflare_challenge.rs -│ │ │ ├── jwt.rs -│ │ │ ├── lifecycle.rs -│ │ │ ├── mod.rs -│ │ │ ├── model.rs -│ │ │ ├── pool.rs -│ │ │ └── usage_snapshots.rs -│ │ ├── events -│ │ │ ├── event.rs -│ │ │ ├── mod.rs -│ │ │ ├── repository.rs -│ │ │ └── service.rs -│ │ ├── gateway -│ │ │ ├── fingerprint -│ │ │ │ ├── mod.rs -│ │ │ │ ├── model.rs -│ │ │ │ ├── repository.rs -│ │ │ │ ├── update_checker.rs -│ │ │ │ └── updater.rs -│ │ │ ├── oauth -│ │ │ │ ├── client.rs -│ │ │ │ ├── codex_cli.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── refresh.rs -│ │ │ │ └── token.rs -│ │ │ ├── protocol -│ │ │ │ ├── codex_to_openai.rs -│ │ │ │ ├── error.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── openai_to_codex.rs -│ │ │ │ └── schema.rs -│ │ │ ├── transport -│ │ │ │ ├── websocket -│ │ │ │ │ ├── codec.rs -│ │ │ │ │ ├── mod.rs -│ │ │ │ │ └── pool.rs -│ │ │ │ ├── headers.rs -│ │ │ │ ├── http_client.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── rate_limits.rs -│ │ │ │ ├── sse.rs -│ │ │ │ ├── types.rs -│ │ │ │ └── usage_events.rs -│ │ │ ├── conversation_identity.rs -│ │ │ ├── installation_id.rs -│ │ │ └── mod.rs -│ │ ├── models -│ │ │ ├── catalog.rs -│ │ │ ├── mod.rs -│ │ │ ├── repository.rs -│ │ │ └── service.rs -│ │ ├── serving -│ │ │ ├── dispatch -│ │ │ │ ├── account_refresh.rs -│ │ │ │ ├── affinity.rs -│ │ │ │ ├── fallback.rs -│ │ │ │ ├── limits.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── routing.rs -│ │ │ │ ├── stream.rs -│ │ │ │ ├── stream_audit.rs -│ │ │ │ └── usage.rs -│ │ │ ├── http -│ │ │ │ ├── auth.rs -│ │ │ │ ├── chat.rs -│ │ │ │ ├── diagnostics.rs -│ │ │ │ ├── errors.rs -│ │ │ │ ├── mod.rs -│ │ │ │ ├── models.rs -│ │ │ │ ├── responses.rs -│ │ │ │ └── router.rs -│ │ │ ├── chat.rs -│ │ │ ├── diagnostics.rs -│ │ │ ├── mod.rs -│ │ │ └── responses.rs -│ │ ├── tasks -│ │ │ ├── mod.rs -│ │ │ ├── model_refresh.rs -│ │ │ ├── quota_refresh.rs -│ │ │ └── token_refresh.rs -│ │ ├── usage -│ │ │ ├── mod.rs -│ │ │ └── service.rs -│ │ └── mod.rs -│ ├── config -│ │ ├── loader.rs -│ │ ├── mod.rs -│ │ └── types.rs -│ ├── platform -│ │ ├── crypto -│ │ │ ├── mod.rs -│ │ │ └── secret_box.rs -│ │ ├── http -│ │ │ ├── auth.rs -│ │ │ ├── health.rs -│ │ │ ├── mod.rs -│ │ │ └── request_id.rs -│ │ ├── identity -│ │ │ ├── admin_session.rs -│ │ │ ├── client_key.rs -│ │ │ ├── client_key_repository.rs -│ │ │ ├── error.rs -│ │ │ └── mod.rs -│ │ ├── logging -│ │ │ ├── mod.rs -│ │ │ └── rotation.rs -│ │ ├── storage -│ │ │ ├── db.rs -│ │ │ ├── mod.rs -│ │ │ ├── paths.rs -│ │ │ └── schema.sql -│ │ └── mod.rs -│ ├── runtime -│ │ ├── tasks -│ │ │ ├── coordinator.rs -│ │ │ ├── mod.rs -│ │ │ └── types.rs -│ │ ├── bootstrap.rs -│ │ ├── mod.rs -│ │ ├── router.rs -│ │ └── state.rs -│ ├── utils -│ │ ├── json.rs -│ │ ├── mod.rs -│ │ └── pagination.rs -│ ├── web -│ │ ├── assets.rs -│ │ ├── mod.rs -│ │ ├── router.rs -│ │ ├── security.rs -│ │ └── shell.rs -│ ├── lib.rs -│ └── main.rs -├── tests -│ ├── admin -│ │ ├── accounts -│ │ │ ├── cookies_quota.rs -│ │ │ ├── import_export.rs -│ │ │ ├── lifecycle.rs -│ │ │ ├── list.rs -│ │ │ └── oauth.rs -│ │ ├── api_contract.rs -│ │ ├── client_keys_route.rs -│ │ ├── logs_route.rs -│ │ ├── models_route.rs -│ │ ├── session.rs -│ │ ├── session_login_route.rs -│ │ ├── session_repository.rs -│ │ ├── settings_route.rs -│ │ └── usage_stats_route.rs -│ ├── architecture -│ │ ├── accounts_boundary.rs -│ │ ├── admin_accounts_split.rs -│ │ ├── admin_boundary.rs -│ │ ├── codex_activity_boundary.rs -│ │ ├── gateway_boundary.rs -│ │ ├── platform_boundary.rs -│ │ ├── runtime_tasks_boundary.rs -│ │ └── serving_boundary.rs -│ ├── codex_accounts -│ │ ├── cookie_store.rs -│ │ ├── pool_scheduling.rs -│ │ ├── refresh.rs -│ │ ├── refresh_scheduler.rs -│ │ ├── repository.rs -│ │ └── service_refresh.rs -│ ├── codex_events -│ │ └── logs_pagination.rs -│ ├── codex_gateway -│ │ ├── websocket -│ │ │ └── pool.rs -│ │ ├── cli_auth_import.rs -│ │ ├── fingerprint_update.rs -│ │ ├── headers.rs -│ │ ├── http_client.rs -│ │ ├── oauth_refresh.rs -│ │ ├── usage_events.rs -│ │ └── websocket.rs -│ ├── codex_models -│ │ └── catalog.rs -│ ├── codex_serving -│ │ ├── chat_completions.rs -│ │ ├── diagnostics_route.rs -│ │ ├── responses_http_sse.rs -│ │ ├── responses_websocket.rs -│ │ ├── routes_chat.rs -│ │ ├── routes_responses.rs -│ │ ├── upstream_errors.rs -│ │ └── upstream_fallback.rs -│ ├── fixtures -│ │ ├── chat -│ │ │ ├── non_stream_text.sse -│ │ │ ├── parallel_tools_success.sse -│ │ │ ├── retry_after_success.sse -│ │ │ ├── stream_text.sse -│ │ │ ├── tool_reasoning_complete.sse -│ │ │ └── tool_stream.sse -│ │ └── responses -│ │ ├── http_sse -│ │ │ ├── after_402.sse -│ │ │ ├── after_403.sse -│ │ │ ├── after_429.sse -│ │ │ ├── after_cloudflare_403.sse -│ │ │ ├── completed_fields.sse -│ │ │ ├── completed_reasoning_include.sse -│ │ │ ├── completed_usage.sse -│ │ │ ├── default_stream.sse -│ │ │ ├── done_item_completed.sse -│ │ │ ├── error_event.sse -│ │ │ ├── failed_event.sse -│ │ │ ├── stream_after_429.sse -│ │ │ ├── stream_error_event.sse -│ │ │ ├── stream_failed_event.sse -│ │ │ ├── stream_usage.sse -│ │ │ └── text_deltas_completed.sse -│ │ └── websocket -│ │ ├── completed.json -│ │ ├── first_account_limited.json -│ │ ├── history_rate_limited.json -│ │ ├── rate_limited.json -│ │ ├── second_account_limited.json -│ │ └── token_revoked.json -│ ├── platform -│ │ ├── client_key_auth.rs -│ │ ├── crypto.rs -│ │ ├── http_auth.rs -│ │ ├── log_rotation.rs -│ │ └── storage_schema.rs -│ ├── runtime -│ │ ├── http_trace.rs -│ │ └── startup.rs -│ ├── support -│ │ ├── admin_accounts.rs -│ │ ├── mod.rs -│ │ └── upstream.rs -│ ├── admin.rs -│ ├── architecture.rs -│ ├── codex_accounts.rs -│ ├── codex_events.rs -│ ├── codex_gateway.rs -│ ├── codex_models.rs -│ ├── codex_serving.rs -│ ├── config.rs -│ ├── platform.rs -│ └── runtime.rs -├── .gitignore -├── Cargo.lock -├── Cargo.toml -├── Dockerfile -├── README.md -├── config.yaml -├── docker-compose.yml -└── rust-toolchain.toml -``` - -## Domain Audit - -| Area | Verdict | Notes | -| --- | --- | --- | -| `src/runtime` | Good | `bootstrap`、`router`、`state`、`tasks` 表达装配层职责,适合单 crate 起步阶段。 | -| `src/web` | Good | 只作为未来 Admin Web 控制台的后端挂载面,没有前端历史包袱。 | -| `src/admin/api` | Good | 管理 JSON API 从 `http` 收束为 `api`,比协议名更能表达契约层。 | -| `src/admin/session` | Good | 管理员登录态与 `client_keys` 拆开,避免 `auth` 泛化。 | -| `src/admin/client_keys` | Good | 内部语义是本地客户端凭据,不是上游 provider key。 | -| `src/codex/accounts` | Good | 账号、cookies、仓储、服务、调度池都在账号域内。 | -| `src/codex/models` | Good | model catalog 从账号域剥离,边界正确。 | -| `src/codex/events` | Good | 业务事件从进程日志中独立出来,命名比 `logs` 更准确。 | -| `src/codex/gateway` | Good | `protocol`、`transport`、`oauth`、`fingerprint` 是上游链路的自然分层。 | -| `src/codex/serving` | Good | 对外 OpenAI-compatible 服务面与上游 gateway 分离。 | -| `src/platform` | Good | `crypto`、`http`、`identity`、`logging`、`storage` 都是跨领域能力。 | -| `src/config` | Good | 配置加载与类型独立,暂不需要进一步艺术化命名。 | -| `src/utils` | Acceptable | 当前只有 `json` 和 `pagination`,还没变成杂物间;后续如果扩张,应优先迁回领域目录。 | - -## File Naming Audit - -### Names To Keep - -- `admin/api/accounts/lifecycle.rs`:覆盖状态、标签、删除、批量变更,比 `mutate.rs` 更准确。 -- `admin/api/client_keys/*`:内部使用 `client_keys` 是正确选择,路由保留 `/api-keys` 是对外兼容。 -- `codex/accounts/cloudflare_challenge.rs`:比 `cf_path_block.rs` 明确,表达真实安全链路问题。 -- `codex/accounts/service/pool_sync.rs`:表达账号仓储与运行时池同步,比 `runtime_pool.rs` 更有边界。 -- `codex/gateway/conversation_identity.rs`:比泛化 `identity.rs` 更准确。 -- `codex/gateway/installation_id.rs`:表达 ChatGPT/Codex 安装标识,不混入一般 installation 概念。 -- `codex/gateway/transport/http_client.rs`:比 `client.rs` 清楚,因为 transport 下还有 WebSocket。 -- `codex/gateway/transport/usage_events.rs`:表达从上游事件流中提取 usage,而不是通用 usage 领域。 -- `codex/tasks/{model_refresh,quota_refresh,token_refresh}.rs`:任务以动作命名,职责清晰。 -- `platform/http/request_id.rs`:比 `middleware.rs` 精确。 -- `platform/crypto/secret_box.rs`:表达加密封装能力,适合稳定复用。 - -### Names That Are Acceptable But Can Be Refined Later - -- `src/codex/accounts/model.rs`:目前承载 `Account` 和 `AccountStatus`,与 `codex/models` 有轻微视觉冲突。若继续追求极致,可改成 `account.rs`,让 `accounts/account.rs` 与 `models/catalog.rs` 完全错开。 -- `src/admin/api/client_keys/*` 内部 handler 名仍使用 `api_key`,例如 `create_api_key`。这是对外路由 `/api/admin/api-keys` 的兼容词,不是架构错误。若后续精修,可把 handler 改为 `create_client_key`,DTO 保留 `ClientApiKey*`。 -- `tests/codex_events/logs_pagination.rs`:目录已经叫 `codex_events`,文件还叫 `logs_pagination`。如果按领域纯度,可改为 `event_log_pagination.rs`。 -- `tests/admin/*_route.rs`:测试文件显式带 `route`,表达 HTTP 路由测试,可以保留。若未来测试分层更细,可迁到 `tests/admin/api/*`。 -- `src/codex/gateway/transport/types.rs`:`types` 是弱名,但当前范围在 `transport` 下仍可接受;如果继续增长,应按 request/response/error 拆出更具体文件。 - -### Names To Avoid Reintroducing - -- `admin/http`:会把管理 API 误导为协议工具层。 -- `admin/auth`:同时包含 session 和 client key 时语义过宽。 -- `codex/accounts/models`:会让 model catalog 看起来像账号私有数据。 -- `codex/logs`:会混淆业务事件和进程日志。 -- `platform/http/middleware.rs`:middleware 是技术位置,不是责任名。 -- `gateway/identity.rs`:identity 太泛,会掩盖 conversation identity 的具体含义。 -- `transport/client.rs`:在有 HTTP、SSE、WebSocket 多传输时过于笼统。 - -## Size And Organization Audit - -命名已经过关,但少数文件仍偏大,后续可在不改变逻辑的前提下继续整理: - -- `src/codex/serving/dispatch/mod.rs`:约 1000 行,是下一轮最值得拆的聚合点。建议按 request context、response assembly、fallback decision、event recording 继续切分。 -- `src/codex/gateway/transport/http_client.rs`:约 780 行。可拆为 request building、response parsing、model fetching、probe 四块。 -- `src/codex/accounts/repository/accounts.rs`:约 700 行。可按 load/list/write/status 或 token metadata 继续切分。 -- `src/codex/accounts/pool.rs`、`src/codex/serving/dispatch/affinity.rs`、`src/codex/gateway/transport/websocket/*` 当前偏大但仍有领域凝聚力,暂不强拆。 - -这些不是命名阻断项。继续优化时应遵守“只拆大文件,不改逻辑”,每次拆分后跑 `cargo fmt --check`、`cargo test --locked`、`cargo clippy --all-targets --all-features --locked -- -D warnings`。 - -## Final Recommendation - -当前架构可以定为: - -```text -runtime -> web/admin/codex/platform/config/utils -admin -> api/session/client_keys/tasks/settings -codex -> accounts/models/gateway/serving/events/usage/tasks -platform-> crypto/http/identity/logging/storage -``` - -这是一个适合项目起步阶段的完整体:没有过度抽象,没有强行 workspace 化,也没有把 Web 前端塞进后端。后续前端可以独立演进,后端只保留 `src/web` 作为运行时挂载点。 diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md deleted file mode 100644 index 6e5b11615..000000000 --- a/docs/code-quality-audit.md +++ /dev/null @@ -1,403 +0,0 @@ -# 代码质量审计与改造计划 - -日期:2026-06-14 - -范围:当前仓库 `src/`、`tests/`,不包含 `designs/`。 - -目标:确认 `/tmp/code_quality_audit.md` 的结论,并基于当前代码给出后续改造顺序。项目处于早期阶段,不需要保留低质量兼容层;改造应以清晰边界、可测试、可维护为目标。 - -## 审计原则 - -1. 不为了“拆小文件”而拆文件。文件大小本身不是问题,问题是职责混杂、变更理由混在一起、审查和定位成本过高。 -2. 不用“兼容历史”掩盖坏设计。内部 API 可以在测试保护下重构,外部行为必须由测试证明未回退。 -3. Rust 代码遵循本项目的 `$rust-best-practices`:明确错误类型、少 clone、少分配、少隐式 panic,但性能优化要有证据。 -4. 日志输出使用中文,保留必要英文专业术语,例如 WebSocket、SSE、OAuth、quota、token。 -5. 每个改造切片先写或补齐测试,再改生产代码,最后跑目标测试、`cargo fmt --check`、`cargo clippy --all-targets --all-features --locked -- -D warnings`。 - -## 审计时量化事实 - -审计开始时 `src/` + `tests/` 约 37,055 行。生产代码里最需要关注的文件: - -| 文件 | 行数 | 判断 | -|---|---:|---| -| `src/codex/serving/dispatch/mod.rs` | 1474 | 已有子模块,但主文件仍承担请求编排、上游调用、stream audit、fallback、限流处理等多种职责 | -| `src/codex/gateway/transport/websocket.rs` | 1175 | WebSocket pool、连接建立、请求编码、SSE 转发、错误分类、rate limit 捕获混在同一文件 | -| `src/codex/accounts/repository.rs` | 1103 | 账号 CRUD、token 更新、quota、usage、lease、pool read model 混在同一存储文件 | -| `src/codex/gateway/transport/client.rs` | 783 | 上游 transport 编排较重,但当前还可接受 | -| `src/codex/accounts/pool.rs` | 627 | 调度策略集中,复杂度主要来自业务策略,不是首要拆分对象 | - -## Claude 审计确认 - -### 1. `update_tokens` / `update_from_claims` 重复 - -结论:成立。 - -当前 `AccountRepository::update_tokens` 和 `AccountRepository::update_from_claims` 都重复处理: - -- `Utc::now().to_rfc3339()` -- access token 加密 -- refresh token 可选加密 -- expires_at 格式化 -- “没有新 refresh_token 时保留旧值”的分支 - -需要修复,但不建议照搬报告里的 `QueryBuilder` 方案。当前只有几个固定 SQL 形态,动态构建 SQL 会把绑定顺序变成新的维护风险。更合适的第一步是: - -- 补测试:`update_from_claims` 省略 refresh_token 时必须保留旧 refresh_token。 -- 提取私有 token 写入准备 helper。 -- 将 UPDATE SQL 改成命名多行常量。 -- 保持 `disabled` / `banned` 不被 `update_tokens` 重新激活的现有语义。 - -### 2. SQL 字符串地狱 - -结论:部分成立。 - -报告里的状态已经有些过期:`LIST_POOL_ACCOUNTS_SQL`、`RECORD_USAGE_SQL`、`GET_USAGE_SQL` 等已是多行常量。但问题仍存在: - -- `insert/get/list/list_metadata/update_tokens/update_from_claims/quota/lease/AccountUsageRepository::list/summary` 仍有较多长单行 SQL。 -- 一部分 SQL 的业务语义不容易在 review 时看清,例如 token 更新时的状态保护、quota cooldown 的最大值保留、refresh lease 的抢占条件。 - -建议先使用命名多行常量。`sqlx::query_file!` 不是当前第一选择,因为项目当前使用运行时 `sqlx::query`,切换到宏式编译期校验会引入额外数据库元数据/离线缓存约束,改造面比可读性收益更大。 - -### 3. `AccountRepository` God Class - -结论:部分成立。 - -`AccountUsageRepository` 已经存在,说明项目已经开始拆 usage 读模型;但 `AccountRepository` 里仍混有: - -- 账号 CRUD -- token 更新 -- pool read model -- quota JSON 与 cooldown -- usage record/get/window sync -- refresh lease - -这确实不是理想边界。后续可以拆,但不应机械拆成很多小文件。推荐按变更理由拆: - -- account 基础读写 -- token 写入 -- quota 状态 -- usage 统计写入与窗口同步 -- refresh lease -- pool read model - -是否暴露多个 repository 类型,需要看调用侧是否真的收益明显。可以先做内部模块拆分,再决定是否调整服务层依赖。 - -### 4. 超大文件 - -结论:成立,但“拆文件”不是目标。 - -三个文件已经大到影响定位和审查: - -- `dispatch/mod.rs` -- `websocket.rs` -- `repository.rs` - -但文件拆分必须跟职责边界一致: - -- `websocket.rs` 适合按 pool、connection、request codec、SSE forwarding、error classification 拆。 -- `dispatch/mod.rs` 适合把 stream audit、fallback/rate-limit 应用、上游请求执行拆到更明确的模块。 -- `repository.rs` 适合先把 SQL 与行映射收拢,再拆 token/quota/usage/lease。 - -不建议为了把每个文件压到某个行数而过度碎片化。 - -### 5. `Account` clone 性能开销 - -结论:部分成立,不是第一优先级。 - -`AccountPool` 确实会 clone `Account`,而 `Account` 包含 token 字符串。这个成本存在,但当前设计也有合理性:`AccountPool` 在 `Mutex` 后面,返回 owned `Account` 可以避免把锁生命周期带入 async 上游调用。 - -直接改成 `Arc<Account>` 不是无成本优化,会牵涉: - -- pool 内账号状态更新如何替换 snapshot -- 调度策略如何避免共享可变状态 -- token 刷新和 cooldown 更新如何保持一致 - -建议先完成结构性重构。性能优化应结合 profile 或压测数据,再考虑 immutable snapshot + `Arc<Account>`。 - -### 6. 字符串分配过度 - -结论:审计过泛,暂不作为直接改造项。 - -`.to_string()` / `.clone()` 数量本身不能证明问题。当前很多转换来自: - -- JSON/SSE 协议需要 owned `String` -- secret 解密后写入运行时 pool -- header、metadata、测试数据构造 -- async 任务跨 await / spawn 的所有权要求 - -后续可以用 Clippy、profile 和热点路径 review 定点优化,不能按数量批量替换成 `Cow` 或引用。 - -## 我的补充审计 - -### P0:token 更新重复和测试缺口 - -`update_tokens` 已有测试覆盖“没有新 refresh_token 时保留旧值”,但 `update_from_claims` 缺同类测试。由于导入已有账号时会走 `update_from_claims`,这是第一批代码改造前必须补齐的行为测试。 - -改造目标: - -- 增加 `update_from_claims` 保留 refresh_token 测试。 -- 提取 token 写入准备逻辑。 -- 将对应 UPDATE SQL 改为命名多行常量。 -- 保持现有行为,不顺手重构调用层。 - -### P0:Repository SQL 可读性 - -`repository.rs` 的 SQL 可读性是当前最明显的维护问题。建议第一阶段只处理账号 repository 内的长 SQL,不扩散到全项目。 - -改造目标: - -- 账号表常用 SELECT 语句提取为常量。 -- token/quota/lease/usage summary 的长 SQL 改成多行常量。 -- SQL 常量命名表达业务语义,例如 `UPDATE_TOKENS_PRESERVING_DISABLED_STATUS_SQL`。 - -### P1:Repository 边界 - -存储层已经有一些 DDD 边界,但 `AccountRepository` 仍承载太多变更理由。推荐在 SQL 可读性改善后再拆,避免一边搬文件一边改 SQL。 - -推荐顺序: - -1. 先把 SQL 常量和 row mapper 收拢。 -2. 再拆 token/quota/lease/usage 内部模块。 -3. 最后评估是否需要多个 public repository。 - -### P1:WebSocket transport 边界 - -`websocket.rs` 当前实现已经覆盖原版核心链路,但职责过密: - -- pool lifecycle -- idle probe / GC -- handshake request 构造 -- request body 转换 -- WS -> SSE 转发 -- rate limit 内部事件捕获 -- WS 错误分类 - -建议后续按“协议转换”和“连接池”先拆,不改变行为: - -- `websocket/pool.rs` -- `websocket/connection.rs` -- `websocket/codec.rs` -- `websocket/stream.rs` -- `websocket/error.rs` - -这个拆分是为了边界清楚,不是为了追求小文件。 - -### P1:Dispatch 编排边界 - -`dispatch/mod.rs` 已有 `affinity/fallback/refresh/routing/stream/usage` 子模块,但主文件仍过重。建议把以下逻辑迁出: - -- 上游 HTTP SSE / WebSocket stream audit -- quota/rate-limit header 应用 -- fallback account retry 决策 -- 上游请求执行 wrapper - -目标是让 `mod.rs` 更像编排入口,而不是承载所有细节。 - -### P1:日志一致性 - -当前日志大部分已经是中文,且不少已使用结构化字段。但仍需继续统一: - -- 非中文日志消息需要改成中文,例如 UpdateChecker 相关日志。 -- `error = %e`、`account_id = %account_id` 这类结构化字段应保持。 -- 不把 token、cookie、refresh_token 放进日志。 -- HTTP trace 日志已存在中文入口,应继续检查是否有 request_id/span 传播缺口。 - -### P2:生产代码里的 `unwrap()` - -大多数 `unwrap()` 位于测试模块。生产路径中发现 `usage_snapshots.rs` 的时间桶对齐使用 `and_hms_opt(...).unwrap()`。逻辑上这些时间值可证明有效,但风格上仍建议改成有语义的 helper 或 `expect` 文案,避免以后被复制到不可证明场景。 - -### P2:测试文件过大 - -`tests/codex_gateway/websocket.rs` 约 1436 行,已经接近生产大文件的认知成本。测试拆分应按场景拆,而不是一个 endpoint 一个文件: - -- WebSocket pool 复用/淘汰 -- WebSocket fallback -- WS -> SSE 转换 -- rate limit internal event -- error classification - -### P2:注释质量 - -项目里已有中文注释,但后续应保持“解释为什么”,避免解释显而易见的代码行为。适合保留的注释包括: - -- token/refresh_token 保留策略 -- SSE 响应头已发出后不能回滚 HTTP 状态 -- 原版协议兼容的非显然参数 -- 安全链路中的指纹/安装 ID/turn state 传递原因 - -## 推荐改造顺序 - -### Phase 1:账号 repository P0 - -目标:先处理最确定、最小风险的问题。 - -1. 补 `update_from_claims` refresh_token 保留测试。 -2. 提取 token 更新准备 helper。 -3. UPDATE token/claims SQL 改命名多行常量。 -4. 账号 repository 的明显长 SQL 改命名常量。 -5. 跑 `cargo test codex_accounts::repository` 或对应过滤测试,再跑 fmt/clippy。 - -验收条件: - -- `update_tokens` 和 `update_from_claims` 不再各自重复 token 加密、expires_at 格式化和 updated_at 生成逻辑。 -- `update_from_claims` 省略 refresh_token 时保留旧 refresh_token,有测试覆盖。 -- `update_tokens` 仍保持现有保护:`disabled` / `banned` 账号不会因为 token 更新被重新激活。 -- token/claims/quota/lease/usage summary 的长 UPDATE/SELECT SQL 不再以内联单行字符串形式散落在方法体里。 -- 不引入 `QueryBuilder`,除非实际需要动态字段集合。当前固定 SQL 形态优先使用命名多行常量,降低绑定顺序风险。 -- 不调整调用层、不拆 public repository API、不改数据库 schema。 -- 验证命令至少包括: - - `cargo test --test codex_accounts repository --locked` - - `cargo fmt --check` - - `cargo clippy --all-targets --all-features --locked -- -D warnings` - -Phase 1 不包含: - -- `AccountRepository` public API 拆分。 -- WebSocket/Dispatch 文件拆分。 -- `Account` 改 `Arc<Account>`。 -- 全局 `.clone()` / `.to_string()` 批量替换。 -- 日志系统重构。 - -Phase 1 状态:已完成。 - -已完成内容: - -- 增加 `update_from_claims` 省略 `refresh_token` 时保留旧 refresh token 的测试。 -- 提取 token 写入准备逻辑,统一 access token 加密、可选 refresh token 加密、expires_at 格式化和 updated_at 生成。 -- 将 token/claims 更新 SQL 改为命名多行常量,并保留 `disabled` / `banned` 不被 token 更新重新激活的语义。 -- 将账号 repository 内 usage、quota、refresh lease 的长 SQL 和实现迁出到内部模块,public API 保持不变。 - -验证结果: - -- `cargo test --test codex_accounts repository --locked`:13 passed。 -- `cargo test prepare_token_write_should_encrypt_tokens_and_format_timestamps --locked`:1 passed。 -- `cargo fmt --check`:通过。 -- `cargo clippy --all-targets --all-features --locked -- -D warnings`:通过。 - -### Phase 2:WebSocket transport 边界 - -目标:在不改变 WebSocket 行为的前提下,按职责拆分 `src/codex/gateway/transport/websocket.rs`。 - -建议顺序: - -1. 先拆错误类型和协议编解码 helper。 -2. 再拆连接建立、握手请求构造、消息发送。 -3. 最后拆连接池生命周期和 stream 转发。 - -验收条件: - -- WebSocket pool 的 55 分钟 max age、25 秒 ping、串行复用语义保持不变。 -- WS -> SSE 输出格式保持不变。 -- rate limit internal event、turn state、set-cookie、rate-limit header 捕获保持不变。 -- HTTP SSE fallback 行为保持不变。 -- 不引入 IP proxy/VPN 相关能力。 - -Phase 2 状态:已完成。 - -已完成内容: - -- 新增 `websocket/pool.rs`,集中 WebSocket pool key、pool config、55 分钟 max age、keepalive、GC、shutdown、account cap、busy key bypass 等池化生命周期逻辑。 -- 新增 `websocket/codec.rs`,集中 upstream request body 转换、WS frame 文本提取、WS event 分类、WS error frame 分类、retry-after/header 提取。 -- `websocket.rs` 保留对外入口、连接建立、active connection 归还/丢弃、WS -> SSE stream 编排。 -- 没有引入 IP proxy/VPN 能力。 - -验证结果: - -- `cargo test --test codex_gateway websocket --locked`:20 passed。 -- `cargo fmt --check`:通过。 -- `cargo clippy --all-targets --all-features --locked -- -D warnings`:通过。 - -### Phase 3:Dispatch 编排边界 - -目标:让 `src/codex/serving/dispatch/mod.rs` 更像请求编排入口,把可独立变化的细节迁到已有子模块或新的内部模块。 - -建议顺序: - -1. 先确认已有 `affinity/fallback/refresh/routing/stream/usage` 子模块职责。 -2. 优先迁出 stream audit 和 upstream response 转换逻辑。 -3. 再评估 fallback/retry 决策、quota/rate-limit 应用是否需要继续迁出。 - -验收条件: - -- 外部 API、SSE 输出、fallback 语义、quota cooldown、session affinity 保持不变。 -- `dispatch/mod.rs` 不再同时承载所有 stream 细节。 -- 每个迁移切片都有目标测试覆盖。 - -Phase 3 状态:已完成。 - -已完成内容: - -- 新增 `dispatch/audit.rs`,集中 HTTP SSE / WebSocket stream 的审计、usage 记录、上游失败识别和账户 slot 释放。 -- 扩展 `dispatch/fallback.rs`,将 fallback retry 的副作用迁入同一模块,包括 quota cooldown、Cloudflare cooldown、ban/quota 状态更新和备用账户获取。 -- 新增 `dispatch/limits.rs`,集中 rate-limit header 解析、quota 缓存同步、window 同步和被动 cooldown 应用。 -- `dispatch/mod.rs` 保留服务入口、请求编排和上游 client request 构造。上游 request 构造暂不硬拆,避免为了降行数引入复杂 lifetime/owned context 设计。 - -验证结果: - -- `cargo test --test codex_serving --locked`:57 passed。 -- `cargo fmt --check`:通过。 -- `cargo clippy --all-targets --all-features --locked -- -D warnings`:通过。 - -### Phase 4:日志、panic 点和测试组织收尾 - -目标:处理不会改变业务行为但影响长期维护质量的问题。 - -检查项: - -1. 日志消息使用中文,保留 WebSocket/SSE/OAuth/quota/token 等专业术语。 -2. 生产代码中避免无语义的 `unwrap()` / `expect()`。 -3. 测试文件按场景组织,不为了拆小而过度拆分。 -4. 继续避免 token、cookie、refresh token 等敏感信息进入日志。 - -Phase 4 状态:已完成。 - -已完成内容: - -- 将 fingerprint 更新检查器日志中的 `UpdateChecker` 英文前缀改为中文表达,保留 fingerprint 专业术语。 -- 清理 `usage_snapshots.rs` 生产路径中的时间桶对齐 `unwrap()`,改为无 panic 的 helper;测试代码中的 unwrap 保留。 -- 将 `tests/codex_gateway/websocket.rs` 中 WebSocket pool 场景迁到 `tests/codex_gateway/websocket/pool.rs`,保留共享 helper 和非 pool 场景测试在父文件。 -- 不继续拆更细的测试文件,避免把一个协议场景拆成过多小模块。 - -验证结果: - -- `cargo test usage_snapshots --locked`:4 passed。 -- `cargo test --test codex_gateway websocket --locked`:20 passed。 -- `cargo fmt --check`:通过。 -- `cargo clippy --all-targets --all-features --locked -- -D warnings`:通过。 - -## 改造后边界 - -本轮改造没有追求小文件数量,而是按变更理由拆分。当前关键文件规模如下: - -| 文件 | 行数 | 当前职责 | -|---|---:|---| -| `src/codex/accounts/repository.rs` | 705 | 账号基础读写、row mapper、pool read model | -| `src/codex/accounts/repository/token.rs` | 203 | token/claims 写入和 refresh_token 保留策略 | -| `src/codex/accounts/repository/quota.rs` | 125 | quota JSON、quota cooldown、Cloudflare cooldown | -| `src/codex/accounts/repository/usage.rs` | 403 | usage 累计、window 同步、usage 列表和汇总 | -| `src/codex/accounts/repository/lease.rs` | 51 | refresh lease 抢占和释放 | -| `src/codex/gateway/transport/websocket.rs` | 506 | WebSocket 对外入口、连接建立、WS -> SSE 编排 | -| `src/codex/gateway/transport/websocket/pool.rs` | 492 | WebSocket pool 生命周期、keepalive、GC、shutdown | -| `src/codex/gateway/transport/websocket/codec.rs` | 206 | upstream body 转换、frame/event/error/header 解析 | -| `src/codex/serving/dispatch/mod.rs` | 1013 | 服务入口、请求编排、上游 request 构造 | -| `src/codex/serving/dispatch/audit.rs` | 329 | HTTP SSE/WebSocket stream 审计和账户 slot 释放 | -| `src/codex/serving/dispatch/fallback.rs` | 255 | fallback retry 分类和副作用应用 | -| `src/codex/serving/dispatch/limits.rs` | 80 | rate-limit header、quota 缓存、window/cooldown 同步 | -| `tests/codex_gateway/websocket.rs` | 880 | WebSocket 非 pool 协议场景和共享测试 helper | -| `tests/codex_gateway/websocket/pool.rs` | 559 | WebSocket pool 复用、淘汰、cap、keepalive、shutdown 场景 | - -`dispatch/mod.rs` 仍然偏大,但当前保留的是请求主流程和上游 request 构造。继续硬拆需要引入额外 context 类型或更复杂的生命周期设计,收益暂时低于风险。 - -## 后续观察项 - -### 性能优化 - -目标:只优化有证据的热点。 - -1. 跑压测或 profile,确认 clone/string 分配是否是瓶颈。 -2. 如果 `Account` clone 是热点,再设计 immutable account snapshot 或 `Arc<Account>`。 -3. 不做无证据的全局 `Cow`/引用替换。 - -### 后续日志治理 - -本轮只处理审计中确认的日志消息一致性问题,没有重构日志系统。后续如果继续做日志最佳实践,应单独落地 HTTP/span/request_id 字段治理,避免和结构重构混在一起。 diff --git a/designs/codex-proxy-rs-overview-final.pen b/docs/codex-proxy-rs-overview-final.pen similarity index 100% rename from designs/codex-proxy-rs-overview-final.pen rename to docs/codex-proxy-rs-overview-final.pen diff --git a/docs/database-storage-audit.md b/docs/database-storage-audit.md deleted file mode 100644 index 66c1d1aaa..000000000 --- a/docs/database-storage-audit.md +++ /dev/null @@ -1,87 +0,0 @@ -# 数据库存储审计与优化计划 - -## 结论 - -当前数据库设计的主体边界是清晰的:账号密钥、cookie、quota 缓存、事件日志、管理会话和模型快照都有独立表承载。账号调度依赖的窗口用量和 session affinity 已从纯内存状态收敛到 SQLite,重启后的短期调度退化已经被消除。事件日志写入也已统一到 `LogService`,请求链路会遵守运行时日志策略。 - -最优先要修复的是账号用量窗口状态。以下字段属于 `account_usage`,不是 `accounts`: - -- `window_request_count` -- `window_input_tokens` -- `window_output_tokens` -- `window_cached_tokens` -- `window_started_at` -- `window_reset_at` -- `limit_window_seconds` - -原因是它们描述的是账号在当前 rate-limit window 内的用量快照,而不是账号身份、密钥或生命周期属性。放在 `account_usage` 可以让累计统计和窗口统计共享同一个存储边界,也能让 `least_used` 调度在重启后恢复更多上下文。 - -## 已验证问题 - -`Account` 和 `AccountPool` 已经有窗口字段,调度逻辑也会使用 `window_reset_at`、窗口内请求数和 token 计数。审计时 `account_usage` 表没有这些列,`AccountRepository::list_pool_accounts()` 也把窗口字段固定恢复为 0 或 `None`。当前实现已将这些字段落到 `account_usage`,并在旧库启动时自动补列。 - -这不会损坏 access token、refresh token 或 cookie,也不会造成永久性数据异常。真实影响是: - -- 重启后窗口计数归零,`least_used` 会丢失短期负载信息。 -- `window_reset_at` 丢失后,调度无法按上游 rate-limit window 做更精确排序。 -- 窗口内 token 消耗无法跨进程保留,统计和排障信息不完整。 - -Claude 审计中“窗口字段未持久化”的结论成立,但“配额控制失效”的表述偏重。项目已经持久化 `quota_json`、`quota_limit_reached`、`quota_cooldown_until` 和 Cloudflare cooldown;缺失的是窗口统计快照,而不是所有配额状态。 - -## 目标存储边界 - -`accounts` 继续只承载账号身份、密钥密文、状态和上游 quota/cooldown 摘要。 - -`account_usage` 承载所有本地统计: - -- 累计请求和 token 计数。 -- 空响应计数。 -- 当前窗口请求和 token 计数。 -- 当前窗口开始、重置时间和窗口长度。 - -`session_affinities` 承载 `previous_response_id` 到账号和会话上下文的映射。当前实现已补 `SessionAffinityRepository`,完成响应时同步写库,启动时清理过期记录并恢复未过期映射。表结构补充 `function_call_ids_json`,避免工具调用分支信息只存在于内存。 - -事件日志通过统一日志服务入口写入,避免请求链路绕过日志容量、开关和 body capture 策略。 - -## 优化阶段 - -### P0:持久化 account_usage 窗口状态(已落地) - -- 扩展 `account_usage` schema。 -- 对旧库启动时自动补列。 -- `record_usage` 同时更新累计计数和窗口计数。 -- `list_pool_accounts` 从数据库恢复窗口字段。 -- 收到 rate-limit header 时持久化 `window_reset_at` 和 `limit_window_seconds`。 - -### P1:持久化 session affinity(已落地) - -- 增加 `SessionAffinityRepository`。 -- 将内存 affinity map 的写入同步到 `session_affinities`。 -- 启动时清理过期记录并恢复未过期映射。 -- 补充 `function_call_ids_json`,恢复 `function_call_ids`。 - -### P1:统一事件日志入口(已落地) - -- v1 response 和 401 后账户刷新失败事件通过 `LogService::record` 写入。 -- 在一个入口执行 `enabled`、`capacity`、`capture_body` 和 metadata 规范。 -- 管理端 `/api/admin/logs/state` 和请求链路共享同一个 `LogService` 实例,运行时开关立即生效。 - -### P2:收敛 schema 约束(已落地) - -- 为账号状态、事件日志级别增加枚举 check constraints。 -- 为 enabled、quota_limit_reached 增加 0/1 check constraints。 -- 为 usage 计数、窗口计数、latency、session input_tokens 增加非负 check constraints。 -- 为 `limit_window_seconds` 增加正数约束。 - -### P2:收敛直接 SQL(已落地) - -- `admin_users` 和 `admin_sessions` 已收敛到 `AdminAuthRepository`。 -- 管理登录、session 校验、默认管理员初始化和过期 session 清理都通过 repository 访问数据库。 -- fingerprint 更新已从 updater 内部 SQL 迁移到 `FingerprintRepository`。 -- `fingerprints` 同时支持历史记录写入和 `auto_updated` 当前记录 upsert。 - -### P2:规范 cookie `expires_at` 读取语义(已落地) - -- `CookieRepository::cookie_header` 读取时过滤已过期 cookie。 -- 支持解析标准 `Expires` 时间和 RFC3339 时间。 -- 无法解析的历史值保守保留,避免误删仍可能有效的 cookie。 diff --git a/docs/dependency-policy.md b/docs/dependency-policy.md deleted file mode 100644 index e884dd39e..000000000 --- a/docs/dependency-policy.md +++ /dev/null @@ -1,16 +0,0 @@ -# Dependency Policy - -- Use current stable crate releases for normal Rust dependencies. -- Commit `Cargo.lock` because this is an application. -- Run `cargo update` in dedicated dependency-refresh changes. -- Keep `reqwest = 0.12.28` and `rustls = 0.23.36` pinned until a TLS fingerprint review proves a newer pair matches real Codex Desktop behavior. -- `reqwest` is built with `rustls-tls-native-roots`, `json`, `stream`, `cookies`, and response compression features used by the Codex client builder. Do not switch back to default native-tls. -- `tokio` includes `net` for the Axum listener, and `tower` includes `util` for route tests using `ServiceExt`. -- Do not add unmaintained crates when a maintained community-standard crate exists. -- Every dependency change must run: - -```bash -cargo fmt --check -cargo clippy --all-targets --all-features --locked -- -D warnings -cargo test -``` diff --git a/docs/design-language.md b/docs/design-language.md deleted file mode 100644 index b1c8327fd..000000000 --- a/docs/design-language.md +++ /dev/null @@ -1,758 +0,0 @@ -# Codex Proxy RS Design Language - -This document defines the visual language for the Codex Proxy RS admin console. -It is the source of truth for Pencil design iterations and future frontend -implementation. - -## 1. Design Positioning - -Codex Proxy RS is an operational console for account pools, routing, quota, -usage, service health, and logs. The interface should feel quiet, precise, and -technical, closer to Codex/OpenAI tooling than to a colorful SaaS dashboard. - -The design language is: - -- restrained: white surfaces, soft gray canvas, minimal decoration; -- operational: dense enough for repeated daily use, but not cramped; -- numeric: important values use monospace and stable widths; -- state-driven: color is reserved for status, risk, and selection; -- modern: hierarchy comes from spacing, typography, and subtle shadow, not heavy - borders. - -Avoid marketing-page composition, decorative gradients, loud cards, nested card -stacks, heavy borders, and arbitrary accent colors. - -## 2. Color System - -Use a neutral-first palette. Accent colors must communicate state or selection. -Do not create new blues, greens, grays, or reds unless the token system is -updated. - -### Core Tokens - -| Token | Value | Usage | -| --- | --- | --- | -| `--cp-bg-page` | `#F6F8FB` | Main app canvas | -| `--cp-bg-surface` | `#FFFFFF` | Sidebar, cards, popovers | -| `--cp-bg-subtle` | `#F8FAFC` | Table rows, inner status areas | -| `--cp-bg-muted` | `#F1F5F9` | Segmented controls, neutral badges | -| `--cp-bg-nav-active` | `#E9EEF5` | Selected sidebar navigation row | -| `--cp-text-primary` | `#0E1726` | Main text and headings | -| `--cp-text-strong` | `#111827` | Brand mark and high emphasis | -| `--cp-text-secondary` | `#64748B` | Descriptions, labels | -| `--cp-text-muted` | `#94A3B8` | Table headers, low-emphasis labels | -| `--cp-white` | `#FFFFFF` | Inverse text | -| `--cp-transparent` | `#FFFFFF00` | Transparent Pencil fills and inactive containers | -| `--cp-overlay-scrim` | `#0E17264D` | Modal and blocking interaction overlay | - -### Semantic Color Model - -The state system follows the same structure as mature component libraries: -Element Plus exposes `base`, `light-*`, and `dark-*` levels per semantic color; -Ant Design derives alias tokens such as `colorSuccessBg`, -`colorSuccessBgHover`, `colorSuccessBorder`, `colorSuccessHover`, and -`colorSuccessActive` from seed colors. Codex Proxy RS uses the same token shape, -but keeps its own quieter operational palette. - -Use `pressed` for the momentary mouse-down state. Use `active` for a persistent -selected/current state. Do not use `active` to mean hover. - -### Neutral Interaction Tokens - -| Token | Value | Usage | -| --- | --- | --- | -| `--cp-default-bg` | `#F8FAFC` | Default control background | -| `--cp-default-bg-hover` | `#F1F5F9` | Default control hover | -| `--cp-default-bg-active` | `#E9EEF5` | Selected neutral row or nav item | -| `--cp-default-border` | `#E2E8F0` | Subtle control boundary when needed | -| `--cp-default-border-hover` | `#CBD5E1` | Hover boundary when needed | -| `--cp-default-text` | `#0E1726` | Default control text | -| `--cp-default-text-hover` | `#111827` | Hover text | -| `--cp-default-text-active` | `#0B1220` | Active text | -| `--cp-disabled-bg` | `#F1F5F9` | Disabled control background | -| `--cp-disabled-border` | `#E2E8F0` | Disabled boundary | -| `--cp-disabled-text` | `#94A3B8` | Disabled text | -| `--cp-disabled-icon` | `#CBD5E1` | Disabled icon | - -### Semantic State Tokens - -Each semantic state has the same token shape. `bg` is for quiet badges, -filled rows, selected light controls, and notification icon wells. `solid` is -reserved for rare high-emphasis controls and charts. - -| State | Base | Hover | Pressed | Background | Bg Hover | Active Bg | Border | Border Hover | Text | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `info` | `#2563EB` | `#1D4ED8` | `#1E40AF` | `#EEF6FF` | `#DBEAFE` | `#BFDBFE` | `#93C5FD` | `#60A5FA` | `#2563EB` | -| `normal` | `#0F9F9A` | `#0D8D88` | `#0B7370` | `#ECFDFD` | `#DDF7F6` | `#C7EFEE` | `#A7DFDD` | `#6BC6C2` | `#0B7370` | -| `success` | `#12B981` | `#0EA371` | `#0B865F` | `#ECFDF5` | `#DDFBEA` | `#C8F5DC` | `#B7EAD0` | `#82D9B3` | `#047857` | -| `warning` | `#F59E0B` | `#D97706` | `#B45309` | `#FFFBEB` | `#FEF3C7` | `#FDE68A` | `#FCD34D` | `#FBBF24` | `#B45309` | -| `danger` | `#EF4444` | `#DC2626` | `#B91C1C` | `#FEF2F2` | `#FEE2E2` | `#FECACA` | `#FCA5A5` | `#F87171` | `#B91C1C` | - -### Solid Foreground Tokens - -Do not assume every colored solid background uses white text. Foreground tokens -are selected by contrast against the actual background. Normal text should target -at least `4.5:1` contrast; small data labels should use the stronger foreground -when there is any doubt. - -| State | Base Foreground | Hover Foreground | Pressed Foreground | -| --- | --- | --- | --- | -| `info` | `--cp-info-on` `#FFFFFF` | `--cp-info-hover-on` `#FFFFFF` | `--cp-info-pressed-on` `#FFFFFF` | -| `normal` | `--cp-normal-on` `#0B1220` | `--cp-normal-hover-on` `#0B1220` | `--cp-normal-pressed-on` `#FFFFFF` | -| `success` | `--cp-success-on` `#0B1220` | `--cp-success-hover-on` `#0B1220` | `--cp-success-pressed-on` `#FFFFFF` | -| `warning` | `--cp-warning-on` `#0B1220` | `--cp-warning-hover-on` `#0B1220` | `--cp-warning-pressed-on` `#FFFFFF` | -| `danger` | `--cp-danger-on` `#0B1220` | `--cp-danger-hover-on` `#FFFFFF` | `--cp-danger-pressed-on` `#FFFFFF` | - -### Compatibility Aliases - -The older tint token names remain as aliases during design iteration: - -| Alias | Use | -| --- | --- | -| `--cp-accent` | Same as `--cp-info` | -| `--cp-accent-tint` | Same as `--cp-info-bg` | -| `--cp-info-tint` | Same as `--cp-info-bg` | -| `--cp-normal-tint` | Same as `--cp-normal-bg` | -| `--cp-success-tint` | Same as `--cp-success-bg` | -| `--cp-warning-tint` | Same as `--cp-warning-bg` | -| `--cp-danger-tint` | Same as `--cp-danger-bg` | -| `--cp-teal` | Same as `--cp-normal` | -| `--cp-teal-tint` | Same as `--cp-normal-bg` | - -Legacy Pencil-only names such as `primary-blue`, `purple`, `success-green`, -`warning-orange`, `error-red`, `cyan`, `info`, `normal`, `bg-card`, -`bg-sidebar`, `text-primary`, `text-secondary`, and `text-muted` are not part of -the design language. Do not use them in new components; migrate existing work to -`cp-*` tokens when touching the affected layer. - -### Color Rules - -- Use `--cp-bg-nav-active` with `--cp-text-strong` for selected sidebar - navigation. -- Use `--cp-info` for selected tabs, focused actions, and primary data lines. -- Use state colors only when the value is actually stateful. -- Prefer semantic `bg` tokens for component states. Use `solid` state colors - only when a surface must visually dominate. -- When using `base`, `hover`, or `pressed` as a solid background, use the - matching `on` foreground token. Never place white text on warning/success - solids by default. -- Hover uses the `hover` or `bg-hover` token. Mouse-down uses `pressed`. - Persistent selection uses `bg-active` plus state text. -- Default component interactions should stay in the light token layer: - default uses neutral bg, hover uses semantic bg, pressed uses semantic - `bg-active`, and selected uses neutral active bg plus a small semantic - indicator. Do not jump from a light default control directly into a solid - saturated pressed state unless the component is already a solid primary - action. -- Operational status semantics: - - `正常`: `--cp-normal`; - - `成功`: `--cp-success`; - - `警告`: `--cp-warning`; - - `错误`: `--cp-danger`. -- Feedback toast semantics: - - `成功`: `--cp-success`; - - `警告`: `--cp-warning`; - - `失败`: `--cp-danger`; - - `信息`: `--cp-info`. -- Do not assign a unique color to every card. -- Prefer text plus small state color over large filled color blocks. -- Keep charts limited to `accent`, `success`, `warning`, and `danger`. - -## 3. Typography - -Use two font roles. - -| Role | Font | Usage | -| --- | --- | --- | -| Interface | `Inter` | Navigation, labels, headings, controls | -| Numeric/data | `JetBrains Mono` | Metrics, IDs, timestamps, status codes, latency | - -### Type Scale - -| Token | Size | Line Height | Weight | Usage | -| --- | ---: | ---: | ---: | --- | -| `--cp-text-display` | `34` | `1.15` | `760-800` | Page title only | -| `--cp-text-section` | `20` | `1.15` | `720-760` | Card titles | -| `--cp-text-brand` | `17` | `1.1` | `700-720` | Sidebar brand | -| `--cp-text-body` | `14` | `1.2` | `600-650` | Navigation and body text | -| `--cp-text-label` | `13` | `1.15` | `600-700` | Labels and row names | -| `--cp-text-caption` | `12` | `1.15` | `600` | Metadata and helper text | -| `--cp-text-micro` | `11` | `1.15` | `600` | Dense badges and table hints | -| `--cp-number-lg` | `28` | `1.05` | `760-800` | Top metric values | -| `--cp-number-md` | `24` | `1.1` | `760` | Secondary numeric summaries | -| `--cp-number-sm` | `12` | `1.15` | `650-720` | Table and compact numeric values | - -### Typography Rules - -- Avoid using `800+` weight except for page title or critical numeric values. -- Labels should not exceed `700`. -- Use `JetBrains Mono` for values that must visually align or compare. -- Do not use negative letter spacing. -- Do not scale font size with viewport width. - -## 4. Spacing System - -Use a 4px base grid, with `24px` as the primary dashboard rhythm. - -| Token | Value | Usage | -| --- | ---: | --- | -| `--cp-space-1` | `4` | Micro gaps | -| `--cp-space-2` | `8` | Icon/text gaps | -| `--cp-space-3` | `12` | Compact padding | -| `--cp-space-4` | `16` | Control padding | -| `--cp-space-5` | `20` | Dense card internals | -| `--cp-space-6` | `24` | Main card gaps and standard card padding | -| `--cp-space-7` | `28` | Large card internal padding | -| `--cp-space-8` | `32` | Header groups | - -### Layout Rhythm - -- Main card gaps: `24px` horizontally and vertically. -- Large card internal padding: `28px` when content is complex. -- Top metric internal padding: `24px`. -- Table row height: `52-56px`, never below `48px`. -- Popover item height: `36-40px`. -- Sidebar navigation row height: `46px`. - -## 5. Radius System - -Use separate radius groups for controls and surfaces. Do not let card, nav, or -pill radii leak into form controls. - -| Token | Value | Usage | -| --- | ---: | --- | -| `--cp-border-radius-base` | `4` | Inputs, selects, date/time triggers | -| `--cp-button-radius-base` | `6` | Large/default buttons | -| `--cp-button-radius-small` | `4` | Small buttons | -| `--cp-border-radius-small` | `2` | Small square controls | -| `--cp-radius-sm` | `10` | Icon backgrounds, table rows | -| `--cp-radius-md` | `12` | Nav items, segmented controls | -| `--cp-radius-lg` | `16` | Metric cards | -| `--cp-radius-xl` | `18` | Large dashboard cards, popovers | -| `--cp-radius-xxl` | `22` | Component spec panels and demo canvases | -| `--cp-checkbox-radius` | `5` | Checkbox squares | -| `--cp-tag-radius` | `7` | Status tags and removable selected values | -| `--cp-date-cell-radius` | `8` | Date picker cells | -| `--cp-icon-button-radius` | `8` | Compact square icon buttons | -| `--cp-radius-pill` | `999` | Status pills only | - -Rules: - -- Buttons never use card, nav, or pill radius tokens. -- Main cards: `16-18px`. -- Component specification panels may use `22px` because they act as demo - canvases, not product dashboard cards. -- Nav rows: `10-12px`. Form buttons use a softer Codex Proxy RS override: - base `6px`, small `4px`. -- Tags, checkboxes, date cells, and compact icon buttons use their dedicated - radius tokens instead of borrowing card or button radii. -- Status pills: `999px`. -- Avoid using pill radius on large rectangular UI. - -## 6. Shadow System - -Shadows create layer hierarchy. They should not look like borders or glow. - -| Token | Value | Usage | -| --- | --- | --- | -| `--cp-shadow-sidebar` | `2px 0 12px -12px #0E172607` | Sidebar separation | -| `--cp-shadow-card` | `0 10px 22px -18px #0E172614` | Standard cards | -| `--cp-shadow-control` | `0 8px 18px -16px #0E172614` | Header controls, selected tab | -| `--cp-shadow-popover` | `0 16px 34px -18px #0E172622` | Dropdown and floating menus | - -Pencil stores shadow geometry and shadow color separately. Use these color -variables when applying shadow effects in `.pen` files: - -| Token | Value | Usage | -| --- | --- | --- | -| `--cp-shadow-sidebar-color` | `#0E172607` | Sidebar shadow color | -| `--cp-shadow-card-color` | `#0E172614` | Card and control shadow color | -| `--cp-shadow-popover-color` | `#0E172622` | Popover and toast shadow color | - -Rules: - -- Never use borders as primary page separation. -- Card shadows must be weaker than popover shadows. -- Sidebar shadow must be barely visible. -- Avoid stacking multiple heavy shadows in the same local area. - -## 7. Element Plus Token Alignment - -Use Element Plus as the behavioral reference for base component variables, while -keeping Codex Proxy RS colors and shadow language. - -| Element Plus token | Codex Proxy RS token | Value | -| --- | --- | --- | -| `--el-component-size-large` | `--cp-component-size-large` | `40px` | -| `--el-component-size` | `--cp-component-size-default` | `32px` | -| `--el-component-size-small` | `--cp-component-size-small` | `24px` | -| `--el-border-radius-base` | `--cp-border-radius-base` | `4px` | -| `--el-border-radius-small` | `--cp-border-radius-small` | `2px` | -| `--el-border-radius-round` | `--cp-border-radius-round` | `20px` | -| Element Plus button radius + product override | `--cp-button-radius-base` | `6px` | -| Element Plus small button radius + product override | `--cp-button-radius-small` | `4px` | -| `--el-input-padding-horizontal-large` | `--cp-input-padding-x-large` | `16px` | -| `--el-input-padding-horizontal` | `--cp-input-padding-x-default` | `12px` | -| `--el-input-padding-horizontal-small` | `--cp-input-padding-x-small` | `8px` | -| `--el-select-option-height` | `--cp-select-option-height` | `34px` | -| `--el-date-editor-width` | `--cp-date-editor-width` | `220px` | -| `--el-date-editor-daterange-width` | `--cp-date-editor-daterange-width` | `350px` | -| `--el-date-editor-datetimerange-width` | `--cp-date-editor-datetimerange-width` | `400px` | -| `--el-popper-border-radius` | `--cp-popper-radius` | `4px` | - -Rules: - -- Input, select, date, time, button, and similar form triggers share the same - component size tokens. -- Do not create one-off heights such as `34px`, `36px`, or `46px` for normal - form triggers. `34px` is reserved for select option rows and picker cells. -- Do not create one-off rounded control radii such as `9px`, `10px`, or `12px`. - Use the Element Plus radius model above. -- Buttons intentionally use a slightly softer product override: `6px` for - large/default and `4px` for small. Other form controls stay on the Element - Plus base radius. - -## 8. Icon System - -Use Lucide icons. - -| Context | Size | Color | -| --- | ---: | --- | -| Sidebar nav | `20` | active `text-strong`, inactive `text-secondary` | -| Card icon | `18` | state token | -| Service row icon | `14` | state token | -| Toolbar icon | `16-19` | semantic token | -| Brand icon | `18` | white on dark surface | -| Notification icon | `18-19` | `text-secondary`, badge uses state token | - -Rules: - -- Icons support recognition; they do not replace essential text in expanded - views. -- Collapsed sidebar may show icons only. -- Sidebar nav icons and labels must share the same vertical center. -- Prefer simple line icons. Avoid custom decorative icon compositions unless - they become a formal brand asset. - -## 9. Layout System - -### Canvas - -- Desktop design target: `1920px` wide. -- Height may grow. -- Sidebar states: - - collapsed: `88px`; - - expanded: `280px`. - -### Main Content - -- Main content width should be stable across sidebar states. -- Main content outer gutters should feel balanced after accounting for sidebar. -- Main card grid gap: `24px`. -- Prefer two primary columns before three. -- Use full-width rows for high-complexity data sections. - -### Sidebar - -- Sidebar background: `--cp-bg-surface`. -- Separation: shadow, not visible border. -- Brand area should be compact and quiet. -- Collapse control sits near bottom, not in the brand area. -- Nav starts high enough that the top area does not feel empty. - -## 10. Component Rules - -### Form Controls - -- Basic form controls follow the Element Plus size model while retaining - Codex Proxy RS colors and shadows: - - `large`: `40px`; - - `default`: `32px`; - - `small`: `24px`. -- These values map to Element Plus `--el-component-size-large`, - `--el-component-size`, and `--el-component-size-small`. -- Form control radius follows Element Plus `--el-border-radius-base`: `4px`. - Small square controls use `--el-border-radius-small`: `2px`. Buttons use - `--cp-button-radius-base` and `--cp-button-radius-small`. -- Input horizontal padding follows Element Plus input padding: - `large 16px`, `default 12px`, `small 8px`. -- Default input surface uses `--cp-bg-subtle`; do not depend on heavy borders. -- Focused input uses `--cp-bg-surface` plus `--cp-shadow-control`. -- Error input uses `--cp-danger-tint`, `--cp-danger`, and a short helper row. -- Disabled input uses `--cp-bg-muted` and `--cp-text-muted`. -- Labels use `--cp-text-caption` scale and `--cp-text-secondary`. -- Prefix icons, suffix icons, clear controls, and validation icons must be - vertically centered with the input value. -- Time, IDs, keys, and stable technical values use `JetBrains Mono`. - -### Selectors and Time Pickers - -- Basic selector trigger sizes follow `large` `40px`, `default` `32px`, and - `small` `24px`; these share the same control height tokens as input and - button. -- Date and time picker triggers are date-editor inputs and therefore also use - the same `40 / 32 / 24px` component size tokens. Do not create a separate - `46px` date trigger height. -- Trigger radius: `4px`. -- Dropdown and picker surfaces use `--cp-bg-surface` and - `--cp-shadow-popover`; do not add borders. -- Dropdown radius follows Element Plus popper radius: `4px`. -- Select option row height follows Element Plus `--el-select-option-height`: - `34px`. -- Selected menu rows use `--cp-info-bg` with `--cp-info-text`; hover rows use - `--cp-default-bg-hover`; disabled rows use `--cp-disabled-text`. -- Multi-select values use compact tags inside the trigger, not separate chips - outside the component. Selected tags must include a close `x` affordance when - the value can be removed. -- Time picker values use `JetBrains Mono`. - -### Date Pickers - -- Required variants: `单日期`, `日期区间`, and `单时间`. -- Trigger height uses the shared form control size tokens, defaulting to `32px`; - trigger radius uses `4px`. -- Date editor widths follow Element Plus defaults unless the surrounding product - layout requires a wider field: single date/time `220px`, date range `350px`, - datetime range `400px`. -- Date and time picker popovers use `--cp-bg-surface` or - `--cp-bg-subtle` with `--cp-shadow-popover`; avoid visible borders. -- Single date follows the same Element Plus style interaction model as the - range picker: the trigger only displays the current value and clear affordance; - shortcuts live inside the popover sidebar when present; month navigation, - date grid, selected value, and apply actions live inside the popover. -- Date range uses one trigger field, not separate start and end inputs. The - displayed value format is `YYYY-MM-DD - YYYY-MM-DD`. -- Date range follows an Element Plus style interaction model: the trigger only - displays the current value; shortcuts live inside the popover sidebar; the - main popover body shows two adjacent month panels; the footer shows the - current range plus clear, cancel, and confirm actions. -- Range month panels show a stable 7-column by 6-row grid so keyboard movement, - hover preview, and disabled days do not change layout height. -- Date cells are `44-48px` wide and `32-38px` high. -- Date cell text uses `JetBrains Mono` for stable numeric alignment. -- Default dates use transparent fill and `--cp-text-primary`. -- Hover dates use `--cp-default-bg-hover` and `--cp-text-strong`. -- Today uses `--cp-info-bg` with `--cp-info-text`. -- Selected dates use `--cp-info` with `--cp-info-on`. -- Disabled or outside-month dates use `--cp-disabled-bg` or transparent fill - with `--cp-disabled-text`; they should never look selectable. -- Range endpoints use the selected style. Range middle dates use - `--cp-info-bg` with `--cp-info-text` so the interval reads as continuous - without heavy outlines. -- Single-time pickers use fixed columns for hour, minute, and second. Time rows - are `34-36px` high, use `JetBrains Mono`, and must not be clipped by the - popover. The footer actions belong inside the popover, not next to the - trigger. -- Date component state previews should show real trigger and date-cell variants: - default, focus, disabled, error, hover, today, selected, and disabled date - cells. Do not replace state previews with explanatory rule cards. -- Picker action buttons stay quiet: secondary actions use `--cp-bg-subtle`; - primary apply actions use `--cp-info` with `--cp-info-on`. -- Picker footer action buttons use the default button height of `32px`. - `34px` is reserved for picker option rows or date cells, not normal buttons. - -### Buttons and Checkboxes - -- Buttons follow the Element Plus size model: - - `large`: `40px` height; - - `default`: `32px` height; - - `small`: `24px` height. -- Do not create `34px` default buttons. If a control is visually button-like - and triggers an action, it must use one of the three button sizes above. -- Button radius: `6px` for large/default, `4px` for small. -- Button icon size: `14-16px`; small buttons use `12px` icons. -- Primary actions use `--cp-info` with `--cp-info-on` when they are the main - apply action. Quiet primary-like actions can use `--cp-info-bg` with - `--cp-info-text`. -- Reserve solid accent or dark filled buttons for a single screen-level primary - action only; do not use multiple heavy filled buttons inside component groups. -- Secondary actions use `--cp-bg-subtle`. -- Destructive actions use `--cp-danger-tint` and `--cp-danger`. -- Disabled actions use `--cp-bg-muted`, `--cp-text-muted`, and reduced - opacity. -- Button states must be represented as normal, hover, loading, disabled, and - text-button variants when building component specs. -- Checkbox size: `18px`; radius: `5px`. -- Unchecked checkbox squares use `--cp-bg-surface` with - `--cp-default-border-hover` as a light inner boundary on subtle surfaces. -- Checked checkbox rows may use `--cp-info-tint`; the checkbox square itself - stays light and the check icon uses `--cp-accent`. -- Mixed checkbox squares use `--cp-info-tint` with an `--cp-accent` minus icon. -- Avoid solid accent-filled checkbox squares inside light component groups. -- Checkbox label and box must share a vertical center. - -### Brand Area - -- Collapsed: only the brand icon. -- Expanded: - - icon: dark square terminal mark; - - primary name: `Codex`; - - secondary text: `Proxy RS · version`. -- Brand should not use a badge that looks like an action. - -### Metric Cards - -- Use neutral white surfaces. -- The primary value is the visual anchor. -- Detail band uses subtle background, not divider lines. -- Metric values must support large numbers without changing layout. -- Do not use top colored bars. - -### Large Cards - -- Title at top-left, action controls at top-right. -- Card padding should be visually equal on all sides. -- Do not put card sections inside card-looking nested cards unless the inner - surface is functional. - -### Tables and Logs - -- Row height: `52-56px`. -- Header height: `40-44px`. -- Use `JetBrains Mono` for time, IDs, codes, and latency. -- Tables follow Element Plus structure: optional selection column, stable - header row, striped body rows, semantic status tags, right-aligned operation - text buttons, and pagination at the footer when row count exceeds the visible - page. -- Table header cells and body cells must share identical column widths and the - same x-origin. Do not position body rows independently from the header. -- Fixed-height log viewport may clip offscreen rows to imply scroll. -- Level labels should be Chinese in the UI. - -### Popovers - -- Width should match content, not the trigger width. -- Use `--cp-shadow-popover`. -- Do not add borders. -- Menu rows use `36-40px` height. -- Destructive actions use danger text and icon only. - -### Modal Dialogs - -- Use modals for creation, confirmation, destructive confirmation, and - medium-complexity forms that should not navigate away from the current page. -- Modal overlay uses `--cp-overlay-scrim`. -- Modal surface uses `--cp-bg-surface`, `--cp-shadow-popover`, and no visible - border. -- Default widths: `480px` for confirmation, `560px` for standard forms, - `720px` for dense forms. -- Internal padding: `28px`; header icon well: `36-44px`; close button: `28px`. -- Footer actions are right-aligned. The primary action is visually strongest; - destructive actions use danger tint and danger text rather than a large red - solid block by default. -- Modal semantic variants are `信息`, `警告`, `失败`, and `成功`. Variant examples - should keep the same compact modal anatomy: icon well, title, one short - description, close button, and footer actions only. -- Do not add tip rows, form rows, or unrelated scenario content inside modal - variant examples. If explanation is needed, place it outside the component. -- Modal footer buttons use the default button height of `32px`. - -### Feedback Toast - -- Feedback toast appears as a floating message after a user or system operation. -- It is not a persistent top-bar notification entry. -- Width: `320-360px`; height: `52-64px`. -- Position: top-right of the active workspace, offset by `24px`. -- Use `--cp-shadow-popover`; do not add borders. -- Icon background uses the matching tint token. -- Toast text is concise: - - success: `成功`; - - warning: `警告`; - - failure: `失败`; - - info: `信息`. -- Use `--cp-success`, `--cp-warning`, `--cp-danger`, and `--cp-info` for the - leading icon and optional state accent. -- Feedback toasts do not include inline actions. -- Every toast includes a right-aligned close button. -- Close button: `28px`, transparent background, `x` icon at `16px`, - `--cp-text-muted`; it stays vertically centered with the icon and text. - -### Status Rows - -- Use subtle row backgrounds. -- Icon, label, value, and metadata should share a vertical center. -- State color belongs on icon/value, not the entire row. - -## 11. Copy Rules - -- Use user-facing names, not internal implementation terms. -- Avoid explanatory UI copy such as "moved to top" or "dropdown opened". -- Labels should describe what the user recognizes: `账号`, `请求`, `Token`, - `平均响应`, `服务状态`, `事件日志`. -- Avoid vague system phrases like `链路状态` unless the product has a concrete - user-facing definition. - -## 12. Design Quality Checklist - -Before accepting a new screen or iteration: - -- Main gutters are balanced for the current sidebar state. -- Main card gaps are consistently `24px`. -- Top, bottom, left, and right padding inside each card are visually equal. -- Text is vertically centered inside rows, badges, and buttons. -- Text on colored backgrounds uses the matching `on` foreground token and is - readable at small sizes. -- No major layout boundary depends on a visible border. -- Colors come from this document. -- Font weights do not exceed the defined role. -- Tables have comfortable row height. -- Large numbers fit without layout shift. -- Popovers appear above cards with stronger hierarchy than cards. -- Both collapsed and expanded sidebar states are checked. - -## 13. Implementation Token Draft - -```css -:root { - --cp-bg-page: #F6F8FB; - --cp-bg-surface: #FFFFFF; - --cp-bg-subtle: #F8FAFC; - --cp-bg-muted: #F1F5F9; - --cp-bg-nav-active: #E9EEF5; - - --cp-text-primary: #0E1726; - --cp-text-strong: #111827; - --cp-text-secondary: #64748B; - --cp-text-muted: #94A3B8; - --cp-white: #FFFFFF; - --cp-transparent: #FFFFFF00; - --cp-overlay-scrim: #0E17264D; - - --cp-accent: #2563EB; - - --cp-component-size-large: 40px; - --cp-component-size-default: 32px; - --cp-component-size-small: 24px; - --cp-border-radius-base: 4px; - --cp-border-radius-small: 2px; - --cp-button-radius-base: 6px; - --cp-button-radius-small: 4px; - --cp-border-radius-round: 20px; - --cp-input-padding-x-large: 16px; - --cp-input-padding-x-default: 12px; - --cp-input-padding-x-small: 8px; - --cp-select-option-height: 34px; - --cp-select-item-height: 24px; - --cp-date-editor-width: 220px; - --cp-date-editor-daterange-width: 350px; - --cp-date-editor-datetimerange-width: 400px; - --cp-popper-radius: 4px; - - --cp-default-bg: #F8FAFC; - --cp-default-bg-hover: #F1F5F9; - --cp-default-bg-active: #E9EEF5; - --cp-default-border: #E2E8F0; - --cp-default-border-hover: #CBD5E1; - --cp-default-text: #0E1726; - --cp-default-text-hover: #111827; - --cp-default-text-active: #0B1220; - - --cp-disabled-bg: #F1F5F9; - --cp-disabled-border: #E2E8F0; - --cp-disabled-text: #94A3B8; - --cp-disabled-icon: #CBD5E1; - - --cp-info: #2563EB; - --cp-info-hover: #1D4ED8; - --cp-info-pressed: #1E40AF; - --cp-info-on: #FFFFFF; - --cp-info-hover-on: #FFFFFF; - --cp-info-pressed-on: #FFFFFF; - --cp-info-bg: #EEF6FF; - --cp-info-bg-hover: #DBEAFE; - --cp-info-bg-active: #BFDBFE; - --cp-info-border: #93C5FD; - --cp-info-border-hover: #60A5FA; - --cp-info-text: #2563EB; - - --cp-success: #12B981; - --cp-success-hover: #0EA371; - --cp-success-pressed: #0B865F; - --cp-success-on: #0B1220; - --cp-success-hover-on: #0B1220; - --cp-success-pressed-on: #FFFFFF; - --cp-success-bg: #ECFDF5; - --cp-success-bg-hover: #DDFBEA; - --cp-success-bg-active: #C8F5DC; - --cp-success-border: #B7EAD0; - --cp-success-border-hover: #82D9B3; - --cp-success-text: #047857; - - --cp-normal: #0F9F9A; - --cp-normal-hover: #0D8D88; - --cp-normal-pressed: #0B7370; - --cp-normal-on: #0B1220; - --cp-normal-hover-on: #0B1220; - --cp-normal-pressed-on: #FFFFFF; - --cp-normal-bg: #ECFDFD; - --cp-normal-bg-hover: #DDF7F6; - --cp-normal-bg-active: #C7EFEE; - --cp-normal-border: #A7DFDD; - --cp-normal-border-hover: #6BC6C2; - --cp-normal-text: #0B7370; - - --cp-warning: #F59E0B; - --cp-warning-hover: #D97706; - --cp-warning-pressed: #B45309; - --cp-warning-on: #0B1220; - --cp-warning-hover-on: #0B1220; - --cp-warning-pressed-on: #FFFFFF; - --cp-warning-bg: #FFFBEB; - --cp-warning-bg-hover: #FEF3C7; - --cp-warning-bg-active: #FDE68A; - --cp-warning-border: #FCD34D; - --cp-warning-border-hover: #FBBF24; - --cp-warning-text: #B45309; - - --cp-danger: #EF4444; - --cp-danger-hover: #DC2626; - --cp-danger-pressed: #B91C1C; - --cp-danger-on: #0B1220; - --cp-danger-hover-on: #FFFFFF; - --cp-danger-pressed-on: #FFFFFF; - --cp-danger-bg: #FEF2F2; - --cp-danger-bg-hover: #FEE2E2; - --cp-danger-bg-active: #FECACA; - --cp-danger-border: #FCA5A5; - --cp-danger-border-hover: #F87171; - --cp-danger-text: #B91C1C; - - --cp-teal: #0F9F9A; - - --cp-accent-tint: #EEF6FF; - --cp-info-tint: #EEF6FF; - --cp-success-tint: #ECFDF5; - --cp-normal-tint: #ECFDFD; - --cp-warning-tint: #FFFBEB; - --cp-danger-tint: #FEF2F2; - --cp-teal-tint: #ECFDFD; - - --cp-space-1: 4px; - --cp-space-2: 8px; - --cp-space-3: 12px; - --cp-space-4: 16px; - --cp-space-5: 20px; - --cp-space-6: 24px; - --cp-space-7: 28px; - --cp-space-8: 32px; - - --cp-radius-sm: 10px; - --cp-radius-md: 12px; - --cp-radius-lg: 16px; - --cp-radius-xl: 18px; - --cp-radius-xxl: 22px; - --cp-checkbox-radius: 5px; - --cp-tag-radius: 7px; - --cp-date-cell-radius: 8px; - --cp-icon-button-radius: 8px; - --cp-radius-pill: 999px; - - --cp-shadow-sidebar: 2px 0 12px -12px #0E172607; - --cp-shadow-card: 0 10px 22px -18px #0E172614; - --cp-shadow-control: 0 8px 18px -16px #0E172614; - --cp-shadow-popover: 0 16px 34px -18px #0E172622; - --cp-shadow-sidebar-color: #0E172607; - --cp-shadow-card-color: #0E172614; - --cp-shadow-popover-color: #0E172622; -} -``` diff --git a/docs/implementation-status.md b/docs/implementation-status.md deleted file mode 100644 index 14f0fc2ee..000000000 --- a/docs/implementation-status.md +++ /dev/null @@ -1,30 +0,0 @@ -# Implementation Status - -Update this file before each feature commit. - -| Area | Status | Commit | Verification | Notes | -| --- | --- | --- | --- | --- | -| Scaffold | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Rust crate, lint policy, pinned TLS-sensitive dependencies, `.gitignore`, and minimal entry points are in place. | -| API contract docs | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/api/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. | -| Configuration | Completed | `9ed8579` + `e0680bf` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_config_and_local_yaml`; full `fmt/test/clippy` before commit | Defines Codex-only config, root `config.yaml` plus optional local YAML loading, `Arc<AppServices>` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without non-OpenAI upstream knobs. | -| Admin settings | Completed | `7424a47` | `cargo test --test admin_settings_route_test admin_settings_patch`; `cargo test --test admin_settings_route_test` | Adds admin-session-gated `PATCH /api/admin/settings` for retained model/auth/quota/logging/usage settings. Mutations validate retained fields, reject unknown fields with admin error envelopes, write a root `local.yaml` overlay, and update the same-process settings view. Already constructed runtime services still use their construction-time config until a later runtime reconfiguration task or service restart. | -| SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. | -| Admin auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; `cargo test --test admin_client_keys_route_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2`; current local key import/export targeted `cargo test --test admin_client_keys_route_test` | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/api/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. `/api/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, export local client key metadata, import metadata by rotating to new local `cpr_` keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext and key hashes are not exportable after HMAC storage, and import returns newly generated plaintext values only once. | -| Secret encryption | Completed | `105aa58` + `228febd` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1:<nonce>:<ciphertext>` without plaintext and can load/create the runtime master key file used by repositories. | -| Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` + `8645b93` + `35f9b80` + `bae084b` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test --test admin_logs_route_test admin_logs_state_patch`; `cargo test --test admin_logs_route_test admin_logs_should_filter`; `cargo test --test admin_logs_route_test admin_logs_`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page<T>` camelCase serialization, admin `/api/admin/logs` query/detail/state/update/clear APIs, admin `/api/admin/logs` filters for kind/level/requestId/accountId/route/model/statusCode/search, admin `/api/admin/usage-stats` and `/api/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. Runtime log state updates are in-process only; persistent config changes remain under `/api/admin/settings`. | -| Diagnostics | Completed | `4a91dea` + `924e6f8` + `bb143e7` + `689a514` | `cargo test --test diagnostics_route_test admin_diagnostics`; `cargo test --test diagnostics_route_test debug_upstream`; `cargo test --test diagnostics_route_test` | Adds admin-session-gated `GET /api/admin/diagnostics` plus local-only `GET /debug/diagnostics`, `GET /debug/fingerprint`, and `GET /debug/upstream` with runtime package metadata, config/database/log/security paths, Codex backend transport settings, static Desktop fingerprint summary and standalone fingerprint debug output, account repository/pool state, runtime capacity summary, retained settings summary, and a Codex models endpoint connectivity probe without tokens or upstream bodies. Remote forwarded debug requests are rejected with `403`. | -| TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | -| Cookie persistence | Completed | `4710926` + `5d3cc6e` + `203270f` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before `203270f` | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. Admin-pasted browser Cookie headers are stored encrypted and can be cleared per account. | -| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` + `93cfee3` + `6d18d2c` + `a32a53c` + `1d6c301` + `a3b4d2b` + `0daa96b` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; `cargo test --test account_pool_startup_test app_state_should_restore_persisted_cooldowns_into_runtime_pool`; `cargo test --test codex_websocket_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, `cargo test --test account_pool_scheduling_test`, and WebSocket fallback route tests; Task 7 targeted `cargo test --test admin_accounts_route_test`; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `fmt/test/clippy` before `db6cb98` | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, OpenAI OAuth device-code polling into the shared validated account import path, OpenAI OAuth PKCE authorization-code exchange into the same path, 401-triggered refresh and single retry, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process manual/import/CLI/device/PKCE import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata and persisted quota/Cloudflare cooldown state, SQLite-backed 429 quota cooldown marking, Cloudflare cooldown lifecycle, pool status lifecycle for upstream fallback, admin label/status/delete/runtime refresh synchronization, admin reset-usage last-used clearing, runtime pool clearing on logout, shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, Chat Completions, and non-history WebSocket handshakes before request body send. `previous_response_id` WebSocket requests remain account-affine on classified failures. Still missing durable release-time slot state. | -| Account repository | Completed | `14fe706` + `db6cb98` + `72438c6` + `869b6f8` + `93cfee3` + `a32a53c` + `b879dd6` + `0daa96b` | `cargo test --test account_repository_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo test --test account_pool_startup_test app_state_should_restore_persisted_cooldowns_into_runtime_pool`; `cargo test --test v1_upstream_fallback_test v1_responses_should_`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `72438c6`; auth status/logout targeted `cargo test --test admin_accounts_route_test`; quota warnings targeted and full gate before `b879dd6` | Adds encrypted SQLite account token storage, decrypting reads, metadata-only reads for auth status, cursor pagination, full export reads, status/label updates, account deletion, account-wide delete for logout, release-time usage counter persistence, refresh-token-preserving token updates, ChatGPT account/user identity lookup, JWT-claim metadata/token updates that preserve existing labels, quota JSON caching, quota warning snapshot reads, persisted quota/Cloudflare cooldown fields for runtime pool restore, and per-account local usage counter reset. | -| Translation | Completed | `eff182e` + `b9c30ba` | `cargo test --test routes_chat_test`; `cargo test --test chat_completions_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` | Adds OpenAI Chat request types, expanded Codex Responses request type, OpenAI Chat to Codex translation for system/developer instructions, multimodal content, tool/function calls, response formats, reasoning effort, service tier, and parallel tool calls. Codex SSE now converts back to OpenAI chat completions and chat chunks with text deltas, reasoning content, tool calls, usage details, and OpenAI-compatible error bodies. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` + `869b6f8` + `e1f35e0` + `93cfee3` + `bfe561d` + `cf5ff0b` + `6d18d2c` + `a32a53c` + `1d6c301` + `a3b4d2b` + `7424a47` + `8645b93` + `35f9b80` + `4a91dea` + `924e6f8` + `bb143e7` + `689a514` + `b879dd6` + `0daa96b` + `efbbe8a` + `bae084b` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test cli_auth_import_test`; `cargo test --test admin_client_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; targeted HTTP SSE error tests before `bfe561d`; targeted local API key import/export tests before `cf5ff0b`; WebSocket fallback targeted route tests; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6`; admin settings targeted tests before `7424a47`; log state/filter targeted tests before `35f9b80` and `bae084b`; diagnostics route tests before `689a514`; quota warnings targeted tests before `b879dd6`; cooldown restore and Cloudflare Cookie clearing targeted tests before `0daa96b` and `efbbe8a` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/debug/diagnostics`, `/debug/fingerprint`, `/debug/upstream`, `/api/admin/diagnostics`, `/api/admin/auth/status`, `/api/admin/auth/logout`, `/api/admin/auth/login-start`, `/api/admin/auth/code-relay`, `/api/admin/auth/openai/callback`, `/auth/openai/callback`, `/api/admin/auth/device-login`, `/api/admin/auth/device-poll/{device_code}`, `/api/admin/accounts`, `/api/admin/accounts/quota-warnings`, `/api/admin/accounts/export`, `/api/admin/accounts/batch-delete`, `/api/admin/accounts/batch-status`, `/api/admin/accounts/health-check`, `/api/admin/accounts/{id}`, `/api/admin/accounts/{id}/label`, `/api/admin/accounts/{id}/status`, `/api/admin/accounts/{id}/refresh`, `/api/admin/accounts/{id}/reset-usage`, `/api/admin/accounts/{id}/quota`, `/api/admin/accounts/{id}/cookies`, `/api/admin/accounts/import`, `/api/admin/accounts/import-cli`, `/api/admin/api-keys`, `/api/admin/api-keys/export`, `/api/admin/api-keys/import`, `/api/admin/api-keys/batch-delete`, `/api/admin/api-keys/{id}`, `/api/admin/api-keys/{id}/label`, `/api/admin/api-keys/{id}/status`, `/api/admin/logs`, `/api/admin/logs/state`, `/api/admin/logs/{id}`, `/api/admin/usage-stats`, `/api/admin/usage-stats/summary`, `/api/admin/settings`, `/api/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses for `previous_response_id`, explicit non-history WebSocket Responses via `use_websocket`/`useWebSocket`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, WebSocket setup fallback after classified handshake failures when no `previous_response_id` is present, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/api/admin/settings` supports admin-session-gated read/write access with lower camelCase envelopes. `/api/admin/auth/status` returns sanitized authenticated state, first account metadata, and pool summary without decrypting or returning tokens. `/api/admin/auth/logout` clears all account rows and the runtime pool while relying on SQLite foreign-key cascades for usage/cookies. `/api/admin/auth/login-start` creates a short-lived in-memory PKCE session and returns the OpenAI authorization URL; `/api/admin/auth/code-relay`, `/api/admin/auth/openai/callback`, and the registered `/auth/openai/callback` redirect path exchange returned authorization codes and import tokens through the same JWT-claim validated path. `/api/admin/auth/device-login` starts the OpenAI OAuth device-code flow, and `/api/admin/auth/device-poll/{device_code}` handles `authorization_pending`/`slow_down` states or imports returned tokens without returning secrets. `/api/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /api/admin/accounts` accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/api/admin/accounts/quota-warnings` returns active warning/critical quota windows computed from cached SQLite quota JSON without contacting upstream. `/api/admin/accounts/import` accepts only the native `accounts[]` format, returns `sourceFormat`, and rejects unknown fields. `/api/admin/accounts/import-cli` reads Codex CLI `auth.json` from `$CODEX_HOME` or an explicit admin fixture path, uses the same validated JWT-claim account import path as manual add, ignores caller metadata, and returns only sanitized import counts. `/api/admin/accounts/export` returns native account exports only. `/api/admin/accounts/{id}/label`, `/status`, `/refresh`, `/reset-usage`, `/quota`, delete, batch-delete, batch-status, and `/api/admin/accounts/health-check` mutate or inspect SQLite plus runtime account pool without returning secrets; health/quota call Codex backend usage endpoints through imported account tokens and classify invalid/quota/banned/Cloudflare responses into account state or cooldown. `/api/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/api/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; `/api/admin/api-keys/export` returns metadata only without plaintext/keyHash/pepper, and `/api/admin/api-keys/import` rotates metadata into new local keys with plaintext returned once. These are local auth utilities, not TS provider key pool parity. `/api/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing live Chat streaming transform. | -| Admin quota warnings | Completed | `b879dd6` | `cargo test --test admin_accounts_cookies_quota_test quota_warnings`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Adds `/api/admin/accounts/quota-warnings`, backed by cached SQLite quota snapshots and configured warning thresholds. The route is admin-session gated, returns lower camelCase admin envelopes, and does not call upstream Codex while listing active warnings. | -| Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. | -| HTTP auth boundary | Completed | `cec4607` + `e9fdea0` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_client_keys_route_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only enabled local `Bearer cpr_...` client API keys verified from SQLite, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | -| Model catalog | Partial | `6341336` + `1738f24` + `f75db60` + `4b19846` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models`; `cargo test --test admin_models_route_test`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846` | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing including `none` and `minimal`, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, debug summary, SQLite-backed backend model snapshots by plan, cached backend catalog merge, model-to-plan allowlist derivation, and an admin refresh route that probes Codex model endpoints with one imported account per plan and syncs the runtime account-pool allowlist. Still missing full model metadata parity. | -| Upstream lifecycle | Partial | `88cad87` + `225fbff` + `c2cf7a6` + `636fa79` + `ee0ed90` + `fdb9374` + `5bf012c` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `bfe561d` + `6d18d2c` + `0daa96b` + `efbbe8a` | `cargo test responses_route_rejects_unknown_models`; `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test --test codex_sse_usage_test`; `cargo test --test codex_client_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test v1_upstream_fallback_test v1_responses_should_`; `cargo test --test v1_upstream_fallback_test v1_responses_should_cool_down_cloudflare_403_and_retry_next_account`; `cargo test --test account_pool_startup_test`; `cargo test --test account_pool_startup_test app_state_should_restore_persisted_cooldowns_into_runtime_pool`; `cargo test --test codex_websocket_test`; `cargo test --test admin_accounts_route_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, `cargo test --test account_pool_scheduling_test`, and WebSocket fallback route tests; HTTP SSE error mapping targeted `cargo test --test v1_upstream_route_test v1_responses_non_stream_should_return_upstream_error` and `cargo test --test v1_upstream_route_test v1_responses_stream_should_passthrough`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe` | `/v1` enforces client API key shape, rejects unknown models through the model catalog, returns OpenAI-compatible errors, includes reusable SSE/usage helpers, sends Codex Desktop HTTP SSE requests through imported accounts, sends WebSocket `response.create` requests when `previous_response_id` requires upstream history, keeps `previous_response_id` requests account-affine on classified WebSocket handshake failures for streaming and non-streaming clients, replays/captures encrypted Cookies, clears Cloudflare-blocked account Cookies, captures successful WebSocket rate-limit headers, forwards Codex request context headers, fetches Codex usage/quota through imported account tokens for admin health/quota operations, normalizes `service_tier: fast` to upstream `priority`, collects `response.completed`, reconstructs missing assistant output from `response.output_item.done` or text deltas, synchronizes `output_text` for non-streaming clients, maps non-streaming HTTP SSE `error` and `response.failed` terminal frames to OpenAI-compatible `upstream_error` bodies, passes through Responses streaming SSE by default while marking `error` and `response.failed` terminal frames as failed lifecycle logs, adapts Chat Completions responses/chunks, refreshes and retries once after upstream 401 when a refresh token is stored, parses Retry-After plus Codex reset metadata from upstream errors, records 429 request attempts, persists 429 quota cooldowns and Cloudflare cooldowns to SQLite, classifies upstream 429/402/403 into account cooldown/quota/banned/Cloudflare state changes, retries non-streaming `/v1/responses`, HTTP SSE `/v1/responses` setup, non-history WebSocket setup, and `/v1/chat/completions` with another imported account when available, records token usage, writes v1 lifecycle event logs, records refresh failure classification for upstream 401, and restores the account pool from SQLite on startup. Still missing richer OpenAI-compatible error mapping and live Chat streaming transform. | -| Codex WebSocket boundary | Completed | `cc0516d` + `b9c30ba` + `350ffbe` + `6d18d2c` | `cargo test --test codex_websocket_test`; `cargo test --test v1_responses_websocket_test v1_responses_should_use_websocket_for_previous_response_id_streaming`; WebSocket route fallback tests; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe` | Adds `src/codex/transport/websocket.rs` with tested transport classification for HTTP SSE versus WebSocket-only requests. `previous_response_id` and explicit WebSocket requests use typed transport selection, local-only `use_websocket` is not serialized into upstream JSON, WebSocket `response.create` carries Codex headers and parity fields, incoming WebSocket messages are emitted as SSE-compatible events, handshake HTTP failures surface as upstream errors before request body send, successful handshake headers capture turn state, cookies, and rate-limit headers, non-history WebSocket setup can fallback to another imported account, and `previous_response_id` WebSocket failures remain on the original account. | -| Fingerprint updates | Partial | `dd26d31` + `e32220a` | `cargo test update_manifest_updates_app_version_and_build_number`; `cargo test fingerprint_updater_fetches_manifest_and_persists_history`; full `fmt/test/clippy` before commit | Parses a JSON update manifest and persists app version/build number history to SQLite. Still missing original appcast XML polling, update-state persistence, runtime header fingerprint selection from the latest stored fingerprint, and optional local Codex.app Chromium extraction. | -| Runtime docs and packaging | Completed | `33b63f3` | `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; `cargo run`; `curl -s -i http://127.0.0.1:8080/health` | README scope, root runtime config, Dockerfile, compose file, and health smoke test are complete. | -| Background scheduler lifecycle | Completed | `5fcd408` + `7370799` + `10714de` | `cargo test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Fully implemented and integrated all core schedulers: RefreshScheduler (JWT exp-margin timers, crash recovery, exponential backoff, 401-triggered refresh), SessionCleanupScheduler (configurable interval, graceful shutdown), QuotaRefresher (actively unlocks limit_reached accounts every 15min, 30min minimum per account), ModelRefresher (background model refresh every 1h with retry logic), SessionAffinityMap (previous_response_id support with 4h TTL). All integrated into main.rs with graceful shutdown on Ctrl+C. Optional: SnapshotTimer for usage history, quotaVerifyRequired field support. | diff --git a/docs/optimization-roadmap.md b/docs/optimization-roadmap.md deleted file mode 100644 index 6f7fb88f3..000000000 --- a/docs/optimization-roadmap.md +++ /dev/null @@ -1,175 +0,0 @@ -# 🚀 codex-proxy-rs 优化路线图 - -基于 Rust 社区最佳实践审查,按优先级排序的优化计划。 - ---- - -## ✅ 已完成优化 - -### 1. 迁移 serde_yaml → serde_yml ✅ -- **状态:** 已完成 -- **提交:** 8389a40 -- **时间:** 15 分钟 -- **收益:** 消除技术债务,使用维护中的库 -- **风险:** 低 -- **测试:** 142 个测试全部通过 - ---- - -## 📋 待优化项目 - -### 优先级 1:性能优化(高 ROI,低风险) - -#### 2. Arc<AppConfig> 包裹配置 🟡 -- **问题:** 每次 `.clone()` Service 都深拷贝整个 AppConfig -- **影响:** 热路径(每个请求都克隆 Service) -- **改进:** - ```rust - // 当前 - pub struct AccountService { - config: AppConfig, // ❌ 完整拷贝 - } - - // 改进 - pub struct AccountService { - config: Arc<AppConfig>, // ✅ 廉价克隆 - } - ``` -- **影响范围:** 所有 Service 结构体 -- **时间估计:** 30-60 分钟 -- **收益:** 减少内存分配,提升请求处理性能 -- **风险:** 低(只改字段类型) - -#### 3. 参数改用 &str 而非 String 🟡 -- **问题:** 276 处字符串分配,92 处在 codex/ 模块 -- **改进:** - ```rust - // 当前 - pub fn some_method(&self, model: String) -> ... { } - - // 改进 - pub fn some_method(&self, model: &str) -> ... { } - ``` -- **时间估计:** 1-2 天(需要审查大量函数) -- **收益:** 减少热路径的字符串分配 -- **风险:** 中等(需要仔细测试) - ---- - -### 优先级 2:代码结构优化(可维护性) - -#### 4. 拆分大文件 accounts.rs 🟡 -- **问题:** 1697 行(社区建议 < 600 行) -- **拆分方案:** - ``` - src/http/admin/accounts/ - ├── mod.rs # 路由注册 + 公共类型 (~200 行) - ├── list.rs # GET /accounts (~150 行) - ├── create.rs # POST /accounts (~200 行) - ├── update.rs # PATCH /accounts/:id (~250 行) - ├── delete.rs # DELETE /accounts/:id (~150 行) - ├── oauth.rs # OAuth 登录流程 (~300 行) - ├── quota.rs # 配额和健康检查 (~250 行) - └── cookies.rs # Cookie 操作 (~200 行) - ``` -- **时间估计:** 1-2 小时 -- **收益:** 提升代码可读性和可维护性 -- **风险:** 中等(大量代码移动) - -#### 5. AccountService 方法分组 🟢 -- **问题:** 7 个 `impl AccountService` 块分散在不同文件 -- **改进:** 使用 trait 组织功能 - ```rust - trait AccountLifecycle { ... } - trait AccountQuota { ... } - trait AccountAuth { ... } - ``` -- **时间估计:** 2-3 天 -- **收益:** API 更清晰,更容易 mock -- **风险:** 低 - ---- - -### 优先级 3:可观测性改进(按需) - -#### 6. 更细粒度的错误类型 🟢 -- **改进:** 为错误添加更多上下文 - ```rust - pub enum AccountServiceError { - DatabaseQueryFailed { - operation: &'static str, - #[source] source: sqlx::Error, - }, - } - ``` -- **时间估计:** 1-2 天 -- **收益:** 更好的错误追踪和调试 - -#### 7. 日志字段标准化 🟢 -- **改进:** 定义统一的日志字段常量 -- **时间估计:** 半天 -- **收益:** 更容易查询日志 - -#### 8. 使用 tracing::instrument 🟢 -- **改进:** 自动记录函数进入/退出 -- **时间估计:** 1 天 -- **收益:** 更好的调用链追踪 - ---- - -### 优先级 4:性能监控(生产验证后) - -#### 9. AccountPool 锁优化 🟡 -- **问题:** 所有请求共享一个 Mutex -- **方案:** - - 分片锁(shard by account_id) - - 使用 RwLock(读多写少) -- **时间估计:** 2-3 天 -- **收益:** 减少高并发锁争用 -- **前置条件:** 生产环境监控证明这是瓶颈 - ---- - -## 📊 优化优先级总结 - -| 优化项 | 优先级 | 时间 | 收益 | 风险 | 状态 | -|--------|--------|------|------|------|------| -| serde_yaml 迁移 | 🔴 高 | 15m | 消除技术债务 | 低 | ✅ 完成 | -| Arc<AppConfig> | 🔴 高 | 1h | 性能提升 | 低 | ⏭️ 待做 | -| &str 参数 | 🟡 中 | 2d | 减少分配 | 中 | ⏭️ 待做 | -| 拆分大文件 | 🟡 中 | 2h | 可维护性 | 中 | ⏭️ 待做 | -| Trait 分组 | 🟢 低 | 3d | 代码组织 | 低 | ⏭️ 待做 | -| 错误类型 | 🟢 低 | 2d | 可调试性 | 低 | ⏭️ 待做 | -| 日志标准化 | 🟢 低 | 0.5d | 可观测性 | 低 | ⏭️ 待做 | -| instrument | 🟢 低 | 1d | 调用追踪 | 低 | ⏭️ 待做 | -| Pool 优化 | 🟡 按需 | 3d | 高并发性能 | 中 | ⏭️ 待验证 | - ---- - -## 🎯 推荐执行顺序 - -**第一批(本周):** -1. ✅ serde_yaml 迁移(已完成) -2. Arc<AppConfig>(性能优化) -3. 拆分 accounts.rs(可维护性) - -**第二批(本月):** -4. &str 参数优化 -5. 日志标准化 - -**第三批(按需):** -6. Trait 分组 -7. 错误类型细化 -8. tracing::instrument - -**监控驱动:** -9. AccountPool 优化(等生产数据) - ---- - -## 📝 备注 - -- 优先性能优化,因为直接影响用户体验 -- 代码结构优化可以逐步进行 -- 高风险改动需要完整测试覆盖 -- 生产部署后根据监控数据调整优先级 diff --git a/docs/rust-community-review.md b/docs/rust-community-review.md deleted file mode 100644 index 87ed6187a..000000000 --- a/docs/rust-community-review.md +++ /dev/null @@ -1,446 +0,0 @@ -# 🔍 Rust 社区最佳实践审查报告 - -基于 Rust 社区标准和最佳实践,对 codex-proxy-rs 进行深度审查。 - ---- - -## ✅ 已经做得很好的地方 - -### 1. 错误处理 (A+) -- ✅ 生产代码零 `unwrap()`/`panic!()` -- ✅ 使用 `thiserror` 定义错误类型 -- ✅ `anyhow` 仅限于 main.rs 和测试 -- ✅ 错误类型有语义(不是泛型 `Error`) - -### 2. 并发模型 (A) -- ✅ 使用 `tokio::sync::Mutex` 而非 `std::sync::Mutex` -- ✅ 共享状态设计合理(`Arc<AppServices>`) -- ✅ 只有一个可变共享状态(`Arc<Mutex<AccountPool>>`) -- ✅ 调度器使用 `tokio::select!` 优雅关闭 - -### 3. 安全性 (A+) -- ✅ `unsafe_code = "forbid"` -- ✅ 秘密类型使用 `secrecy::SecretString` -- ✅ 加密算法选择合理 -- ✅ SQL 参数化查询 - -### 4. 依赖管理 (A) -- ✅ TLS 相关依赖已固定(指纹匹配需要) -- ✅ Cargo.lock 已提交 -- ✅ 主流依赖选择合理 - ---- - -## ⚠️ 需要改进的地方 - -### 1. 🟡 大文件问题(可读性) - -**问题:** -``` -1697 行 - src/http/api/admin/accounts.rs ⚠️ 太大! - 895 行 - src/codex/upstream/mod.rs - 784 行 - src/codex/accounts/repository.rs -``` - -**社区建议:** 单个文件超过 500-600 行就应该考虑拆分 - -**改进方案:** -```rust -// 当前:src/http/api/admin/accounts.rs (1697 行) -// 建议拆分为: -src/http/api/admin/accounts/ - ├── mod.rs // 路由注册 - ├── list.rs // GET /accounts - ├── create.rs // POST /accounts - ├── update.rs // PATCH /accounts/:id - ├── delete.rs // DELETE /accounts/:id - ├── oauth.rs // OAuth 登录相关 - ├── quota.rs // 配额管理 - └── cookies.rs // Cookie 操作 -``` - -**优先级:** 🟡 中等(不影响功能,但影响维护性) - ---- - -### 2. 🟡 配置克隆(性能) - -**问题:** -```rust -#[derive(Clone)] -pub struct AccountService { - config: AppConfig, // AppConfig 包含很多嵌套结构 - ... -} -``` - -**当前状况:** -- `AppConfig` 在每个 Service 中都是直接字段 -- 每次 `.clone()` Service 都会深拷贝整个配置 - -**社区最佳实践:** -```rust -// 改进方案 1:Arc 包裹配置(推荐) -#[derive(Clone)] -pub struct AccountService { - config: Arc<AppConfig>, // 廉价的 Arc 克隆 - ... -} - -// 改进方案 2:引用配置(生命周期复杂) -pub struct AccountService<'a> { - config: &'a AppConfig, - ... -} -``` - -**ROI 分析:** -- 影响:热路径(每个请求都克隆 Service) -- 收益:减少内存分配,提升性能 -- 成本:重构成本中等 - -**优先级:** 🟡 中等(性能优化) - ---- - -### 3. 🟡 String vs &str 参数(性能) - -**问题:** -```rust -// 发现多处这样的模式 -pub fn some_method(&self, model: String) -> ... { } -pub fn batch_delete(&self, ids: Vec<String>) -> ... { } -``` - -**社区建议:** 参数优先使用借用 -```rust -// 改进后 -pub fn some_method(&self, model: &str) -> ... { } -pub fn batch_delete(&self, ids: &[String]) -> ... { } -// 或者 -pub fn batch_delete(&self, ids: impl IntoIterator<Item = impl AsRef<str>>) -> ... { } -``` - -**影响范围:** -- 92 处 `.clone()` 在 codex/ 模块 -- 276 处字符串分配 - -**优先级:** 🟡 中等(性能优化) - ---- - -### 4. 🟢 AccountService 方法分散(可维护性) - -**当前结构:** -``` -src/codex/accounts/service/ -├── mod.rs // 主结构 + 一些方法 -├── cookies.rs // Cookie 操作 -├── health.rs // 健康检查 -├── import.rs // 导入逻辑 -├── lifecycle.rs // CRUD -├── quota.rs // 配额 -├── refresh.rs // 刷新 -└── runtime_pool.rs // 池同步 -``` - -**问题:** -- 难以一眼看出 AccountService 的完整 API -- 7 个 `impl AccountService` 块分散 - -**社区建议:** 使用 trait 组织功能 -```rust -// 建议方案 -trait AccountLifecycle { - fn create(&self, ...) -> ...; - fn delete(&self, ...) -> ...; -} - -trait AccountQuota { - fn get_quota(&self, ...) -> ...; - fn refresh_quota(&self, ...) -> ...; -} - -trait AccountAuth { - fn refresh_token(&self, ...) -> ...; - fn import_account(&self, ...) -> ...; -} - -impl AccountLifecycle for AccountService { ... } -impl AccountQuota for AccountService { ... } -impl AccountAuth for AccountService { ... } -``` - -**优点:** -- API 分组清晰 -- 更容易 mock 测试 -- 更好的文档组织 - -**优先级:** 🟢 低(改进可维护性,非阻塞) - ---- - -### 5. 🟢 日志字段标准化(可观测性) - -**当前状况:** -```rust -// 不同地方使用不同的字段名 -tracing::info!(account_id = %id, "doing something"); -tracing::info!(account = %id, "doing something else"); -``` - -**社区最佳实践:** 标准化日志字段 -```rust -// 建议定义日志字段常量 -pub mod log_fields { - pub const ACCOUNT_ID: &str = "account_id"; - pub const REQUEST_ID: &str = "request_id"; - pub const MODEL: &str = "model"; - pub const STATUS: &str = "status"; -} - -// 使用 -tracing::info!( - account_id = %id, - request_id = %req_id, - "account created" -); -``` - -**优点:** -- 日志查询更容易 -- 与 OpenTelemetry 集成更好 -- 避免拼写错误 - -**优先级:** 🟢 低(可观测性改进) - ---- - -### 6. 🟢 异步 trait 方法(Rust 1.75+) - -**当前状况:** -```rust -pub trait TokenRefresher: Send + Sync { - fn refresh(&self, ...) -> Pin<Box<dyn Future<...> + Send + '_>>; -} -``` - -**Rust 1.75+ 改进:** -```rust -pub trait TokenRefresher: Send + Sync { - async fn refresh(&self, ...) -> Result<...>; -} -``` - -**前提:** 需要 `#![feature(async_fn_in_trait)]` 或等稳定版 - -**优先级:** 🟢 低(未来改进) - ---- - -### 7. 🟡 依赖 `serde_yaml` 已弃用 - -**问题:** -```toml -serde_yaml = "0.9.34+deprecated" # 明确标记为已弃用 -``` - -**社区推荐替代:** -- `serde_yml` - 社区维护的替代品 -- 或者迁移到 TOML(更符合 Rust 生态) - -**改进方案:** -```toml -# 方案 1:替换为 serde_yml -serde_yml = "0.0.10" - -# 方案 2:迁移到 TOML -toml = "0.8" -``` - -**优先级:** 🟡 中等(技术债务) - ---- - -### 8. 🟢 更细粒度的错误类型 - -**当前:** -```rust -pub enum AccountServiceError { - RepositoryUnavailable, // 太宽泛 - List, // 没有上下文 -} -``` - -**社区建议:** -```rust -pub enum AccountServiceError { - RepositoryNotConfigured { - operation: &'static str - }, - DatabaseQueryFailed { - operation: &'static str, - #[source] - source: sqlx::Error, - }, - ListAccountsFailed { - filter: Option<String>, - #[source] - source: sqlx::Error, - }, -} -``` - -**优点:** -- 更好的错误追踪 -- 更有用的日志 -- 更容易调试 - -**优先级:** 🟢 低(可观测性改进) - ---- - -### 9. 🟢 考虑使用 `tracing::instrument` - -**当前:** -```rust -pub async fn some_method(&self, id: &str) -> Result<...> { - tracing::info!(id = %id, "starting operation"); - // ... 逻辑 - tracing::info!(id = %id, "operation completed"); - Ok(result) -} -``` - -**社区最佳实践:** -```rust -#[tracing::instrument(skip(self), fields(id = %id))] -pub async fn some_method(&self, id: &str) -> Result<...> { - // 自动记录进入/退出/错误 - Ok(result) -} -``` - -**优点:** -- 自动记录函数进入/退出 -- 自动记录错误 -- 自动包含 span 信息 - -**优先级:** 🟢 低(便利性改进) - ---- - -### 10. 🟡 AccountPool 锁争用(性能) - -**潜在瓶颈:** -```rust -// 所有请求共享一个 Mutex -pub account_pool: Arc<Mutex<AccountPool>>, -``` - -**高并发场景风险:** -- 1000 QPS → 每个请求需要锁 AccountPool -- 锁持有时间虽短,但会累积 - -**优化方案(如果成为瓶颈):** -```rust -// 方案 1:分片锁(shard by account_id hash) -pub struct ShardedAccountPool { - shards: Vec<Arc<Mutex<AccountPool>>>, -} - -// 方案 2:使用 RwLock(读多写少) -pub account_pool: Arc<RwLock<AccountPool>>, - -// 方案 3:Lock-free 结构(复杂) -// 使用 crossbeam 或 dashmap -``` - -**建议:** 先部署监控,如果 Mutex 成为瓶颈再优化 - -**优先级:** 🟡 中等(等待生产验证) - ---- - -## 📊 社区最佳实践对照表 - -| 实践 | 你的项目 | 社区标准 | 评分 | -|------|---------|---------|------| -| 错误处理 | thiserror + 零 panic | ✅ | A+ | -| 并发模型 | tokio::sync + Arc | ✅ | A | -| 安全性 | forbid unsafe + secrecy | ✅ | A+ | -| 测试覆盖 | 142 tests | ✅ | A | -| 文件大小 | 最大 1697 行 | ⚠️ 建议 <600 | B | -| 配置克隆 | 直接字段 | ⚠️ 建议 Arc | B | -| 参数借用 | 混合 owned/borrowed | ⚠️ 优先 &str | B+ | -| 依赖选择 | serde_yaml deprecated | ⚠️ | B+ | -| 文档 | CLAUDE.md + 注释 | ✅ | A | -| 代码风格 | Clippy -D warnings | ✅ | A+ | - -**总评:A- (4.3/5.0)** - ---- - -## 🚀 优先改进建议 - -### 短期(本月内) - -1. **🟡 迁移 serde_yaml** - ```bash - cargo remove serde_yaml - cargo add serde_yml - # 或者 - cargo add toml - ``` - - 成本:低 - - 收益:消除技术债务 - - 风险:低 - -2. **🟡 拆分大文件** - - 先拆 `http/api/admin/accounts.rs` (1697 行) - - 按功能模块拆分(list/create/update/delete/oauth) - - 成本:中等 - - 收益:提升可维护性 - -### 中期(本季度) - -3. **🟡 配置使用 Arc** - ```rust - config: Arc<AppConfig> - ``` - - 减少热路径的内存分配 - - 性能提升明显(每请求都克隆 Service) - -4. **🟡 参数改用 &str** - - 审查热路径的 `String` 参数 - - 改为 `&str` 或 `&[T]` - - 减少不必要的分配 - -### 长期(按需) - -5. **🟢 AccountPool 性能优化** - - 先部署,监控锁争用指标 - - 如果成为瓶颈,考虑分片或 RwLock - -6. **🟢 使用 trait 组织 Service 方法** - - 提升代码可读性 - - 更好的测试和文档组织 - ---- - -## ✅ 结论 - -**你的架构在 Rust 社区标准下得分:A- (4.3/5.0)** - -**已经做得很好:** -- 错误处理、安全性、并发模型都是教科书级别 -- 测试覆盖优秀 -- 代码质量高(零 unsafe/panic) - -**改进空间:** -- 文件大小控制(1697 行太大) -- 配置克隆优化(性能) -- 参数借用优化(性能) -- 替换弃用的依赖 - -**总体评价:** 这是一个高质量的 Rust 项目,改进点都是"锦上添花",不是"致命缺陷"。可以按优先级逐步优化,不影响生产部署。 diff --git a/docs/scheduler-completion-report.md b/docs/scheduler-completion-report.md deleted file mode 100644 index 601236228..000000000 --- a/docs/scheduler-completion-report.md +++ /dev/null @@ -1,130 +0,0 @@ -# 账号调度与数据库审计落地报告 - -## 结论 - -本轮把账号调度审计和数据库存储审计合并落地。当前实现已经覆盖审计中需要立即修复的调度、刷新、窗口统计、session affinity、日志入口、schema 约束、cookie 过期语义和直接 SQL 边界问题。 - -不在本报告范围内的事项:IP 代理/VPN、旧项目数据迁移、真实 Codex Desktop TLS 指纹完全复刻。这些不是当前 Rust 服务要移植的能力。 - -## 账号调度审计项 - -### least_used 策略 - -已实现完整优先级: - -1. quota-limited 账号降权。 -2. `window_reset_at` 更早的账号优先。 -3. `request_count` 更少的账号优先。 -4. 并列时轮换,避免固定账号长期被选中。 - -对应测试: - -- `least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled` -- `least_used_should_prefer_earlier_rate_limit_window_reset` -- `least_used_should_prefer_lower_runtime_request_count` -- `account_pool_should_rotate_tied_least_used_accounts` - -### refresh_concurrency - -已通过 `tokio::sync::Semaphore` 增加全局刷新并发限制。配置来源为 `auth.refresh_concurrency`,并对 0 做最小值保护。 - -对应测试: - -- `refresh_scheduler_limits_refresh_concurrency` -- `refresh_concurrency_limit_should_never_be_zero` - -### 请求计数和窗口统计 - -`Account`、`AccountPool`、`AccountRepository` 和 `account_usage` 现在共同维护: - -- 累计 `request_count` -- 窗口内请求数 -- 窗口内 input/output/cached token 计数 -- `window_started_at` -- `window_reset_at` -- `limit_window_seconds` - -这些字段会在请求完成时累加,在收到 rate-limit header 时同步窗口边界,并在启动恢复账号池时重新加载。 - -对应测试: - -- `account_repository_should_accumulate_usage_window_counters` -- `account_repository_should_restore_window_usage_into_runtime_pool_accounts` -- `v1_responses_should_passively_cache_rate_limit_headers` -- `sqlite_schema_should_persist_account_usage_window_columns` - -## 数据库存储审计项 - -### account_usage 窗口状态 - -已落地到 `account_usage`,并为旧库启动补列。窗口统计属于 usage 域,不放入 `accounts`。 - -### session affinity - -已增加 `SessionAffinityRepository`,完成响应后写入 SQLite,启动时删除过期记录并恢复未过期映射。`function_call_ids_json` 已持久化,避免工具调用分支信息只存在于内存。 - -对应测试: - -- `app_state_should_restore_session_affinity_from_sqlite` -- `v1_responses_should_route_previous_response_id_to_recorded_account` -- `v1_responses_websocket_should_reuse_connection_for_recorded_conversation` - -### 事件日志入口 - -请求链路和管理端共享 `LogService`。写入事件日志时统一执行: - -- `enabled` -- `capacity` -- `capture_body` -- metadata 清理 - -对应测试: - -- `v1_responses_should_skip_event_log_when_logging_disabled` -- `log_service_should_trim_to_capacity_after_record` -- `log_service_should_remove_body_metadata_when_capture_body_disabled` - -### schema 约束 - -SQLite schema 已补充状态枚举、日志级别、布尔字段、非负计数、HTTP 状态码和 `limit_window_seconds` 约束。 - -对应测试: - -- `sqlite_schema_should_reject_invalid_account_status` -- `sqlite_schema_should_reject_invalid_event_log_level` -- `sqlite_schema_should_reject_non_boolean_flags` -- `sqlite_schema_should_reject_negative_account_usage_counts` - -### cookie 过期语义 - -`CookieRepository::cookie_header` 读取时过滤已过期 cookie,支持标准 `Expires` 时间和 RFC3339。无法解析的历史值会保守保留,避免误删仍可能有效的 cookie。 - -对应测试: - -- `cookie_repository_should_not_replay_expired_cookies` - -### 直接 SQL 边界 - -直接访问业务表的 SQL 已收敛到 repository: - -- `admin_users` / `admin_sessions`:`AdminAuthRepository` -- `fingerprints`:`FingerprintRepository` -- `session_affinities`:`SessionAffinityRepository` -- `event_logs`:`EventLogRepository`,由 `LogService` 统一入口调用 - -对应测试: - -- `admin_auth_repository_should_create_and_load_default_admin_once` -- `admin_auth_repository_should_create_validate_and_cleanup_sessions` -- `fingerprint_repository_should_upsert_auto_update_record` - -## 当前验证 - -最后一次完整验证命令: - -- `cargo fmt --check` -- `git diff --check` -- `cargo test` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` - -以上命令均已通过。 diff --git a/docs/scheduler-implementation.md b/docs/scheduler-implementation.md deleted file mode 100644 index caaf8b055..000000000 --- a/docs/scheduler-implementation.md +++ /dev/null @@ -1,57 +0,0 @@ -# 后台调度器实现 - -## 当前边界 - -后台任务由 `src/runtime/tasks/coordinator.rs` 统一启动和关闭。运行时只保存任务句柄,具体业务逻辑留在各自领域模块: - -- `src/codex/tasks/refresh.rs`:OAuth access token 刷新调度。 -- `src/codex/tasks/quota.rs`:quota 锁定账户的主动刷新。 -- `src/codex/tasks/model.rs`:Codex 后端模型列表刷新。 -- `src/admin/tasks/session_cleanup.rs`:管理员 session 过期清理。 -- `src/codex/gateway/fingerprint/update_checker.rs`:Codex Desktop fingerprint 版本轮询。 - -调度器共享 `SchedulerHandle`,应用退出时由 `BackgroundTaskCoordinator::shutdown()` 逐个关闭。 - -## RefreshScheduler - -`RefreshScheduler` 对齐原版刷新调度的关键行为: - -- 按 JWT `exp - refresh_margin_seconds` 调度刷新。 -- 使用指数退避,最多 5 次:5s、15s、45s、135s、300s。 -- 区分封禁类错误和 refresh token 失效类错误。 -- 进程重启后恢复 `refreshing` 和带 refresh token 的 `expired` 账户。 -- 用 per-account `in_flight` 抑制重复刷新。 -- 用 `auth.refresh_concurrency` 通过 `Semaphore` 限制全局刷新并发。 - -这部分的行为测试集中在 `tests/codex_accounts/refresh.rs`,包括刷新成功、401 触发刷新、错误状态映射和并发上限。 - -## Account Scheduling - -账号池调度逻辑在 `src/codex/accounts/pool.rs`: - -- `least_used` 先降权 quota-limited 账号。 -- 再按 `window_reset_at` 排序,优先选择更早进入下一窗口的账号。 -- 再比较 `request_count`。 -- 最后在并列账号之间轮换,避免固定选择同一个账号。 - -调度所需的 `request_count`、窗口 token 计数、`window_started_at`、`window_reset_at` 和 `limit_window_seconds` 已持久化到 `account_usage`。重启后 `AccountRepository::list_pool_accounts()` 会恢复这些字段,避免短期调度状态丢失。 - -## Storage Boundaries - -后台任务不直接散落业务表 SQL: - -- 默认管理员创建、管理员登录 session、session 校验和过期清理由 `AdminAuthRepository` 访问 `admin_users` / `admin_sessions`。 -- fingerprint 历史写入和 `auto_updated` 当前记录由 `FingerprintRepository` 访问 `fingerprints`。 -- session affinity 由 `SessionAffinityRepository` 访问 `session_affinities`。 -- 请求事件日志由 `LogService::record` 统一执行开关、容量和 body capture 策略。 - -## Verification - -当前实现已通过: - -- `cargo fmt --check` -- `git diff --check` -- `cargo test` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` - -本文件只描述当前 Rust 实现,不再保留旧 `src/scheduler/*` 路径或早期直接 SQL 方案。 diff --git a/docs/scheduler-usage.md b/docs/scheduler-usage.md deleted file mode 100644 index c039e590b..000000000 --- a/docs/scheduler-usage.md +++ /dev/null @@ -1,204 +0,0 @@ -# 后台调度器使用指南 - -## 快速开始 - -### 1. 启动刷新调度器 - -```rust -use std::sync::Arc; -use codex_proxy_rs::{ - scheduler::refresh::RefreshScheduler, - codex::accounts::service::AccountService, - config::AppConfig, -}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // 加载配置 - let config = AppConfig::load()?; - - // 创建 AccountService - let account_service = Arc::new(AccountService::new( - config.clone(), - Some(account_repo), - Some(usage_repo), - Some(cookie_repo), - Some(token_refresher), - account_pool, - )); - - // 创建并启动刷新调度器 - let refresh_scheduler = RefreshScheduler::new( - account_service.clone(), - config.clone(), - ); - let refresh_handle = refresh_scheduler.start().await; - - println!("Refresh scheduler started"); - - // ... 运行应用 ... - - // 优雅关闭 - refresh_handle.shutdown().await; - println!("Refresh scheduler stopped"); - - Ok(()) -} -``` - -### 2. 启动会话清理调度器 - -```rust -use codex_proxy_rs::scheduler::session_cleanup::SessionCleanupScheduler; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let db = sqlx::SqlitePool::connect("sqlite://data.db").await?; - - // 每小时清理一次过期会话 - let session_cleanup = SessionCleanupScheduler::new(db.clone(), 3600); - let cleanup_handle = session_cleanup.start(); - - println!("Session cleanup scheduler started"); - - // ... 运行应用 ... - - cleanup_handle.shutdown().await; - println!("Session cleanup scheduler stopped"); - - Ok(()) -} -``` - -### 3. 完整的应用启动示例 - -```rust -use tokio::signal; -use codex_proxy_rs::{ - runtime::{bootstrap::build_state, build_router, tasks::start_background_tasks}, - config::AppConfig, -}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt::init(); - - // 1. 加载配置 - let config = AppConfig::load()?; - - // 2. 构建运行时状态和后台任务 - let host = config.server.host.clone(); - let port = config.server.port; - let (state, db_pool, _) = build_state(config.clone()).await?; - let background_tasks = start_background_tasks(&state, db_pool.clone(), &config).await; - - // 3. 启动 HTTP 服务器 - let app = build_router(state); - let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?; - - // 4. 运行服务器,监听关闭信号 - tokio::select! { - result = axum::serve(listener, app) => { - if let Err(e) = result { - tracing::error!(error = %e, "server error"); - } - } - _ = signal::ctrl_c() => { - tracing::info!("received shutdown signal"); - } - } - - // 5. 优雅关闭 - background_tasks.shutdown().await; - db_pool.close().await; - - Ok(()) -} -``` - -## 配置说明 - -### config.yaml - -```yaml -auth: - # 在令牌过期前多少秒开始刷新 - refresh_margin_seconds: 300 # 5分钟 - - # 是否启用自动刷新 - refresh_enabled: true - - # 并发刷新账户数 - refresh_concurrency: 2 - -admin: - # 会话清理间隔(秒) - session_cleanup_interval_secs: 3600 # 1小时 - - # 会话过期时间(分钟) - session_ttl_minutes: 1440 # 24小时 -``` - -## 监控和调试 - -### 日志输出 - -调度器会输出结构化日志: - -``` -[INFO] Refresh scheduler started -[INFO] account_id="acc_123" delay_secs=295 "Refresh scheduled" -[INFO] account_id="acc_123" "Starting token refresh" -[INFO] account_id="acc_123" "Token refreshed successfully" -[INFO] account_id="acc_123" delay_secs=298 "Next refresh scheduled" -``` - -### 错误处理 - -``` -[WARN] account_id="acc_456" attempt=1 max_attempts=5 delay_secs=5 error="network timeout" "Refresh attempt failed, retrying" -[ERROR] account_id="acc_789" hits=2 "Permanent failure detected" -``` - -## 故障排查 - -### 问题:调度器没有刷新账户 - -检查: -1. `refresh_enabled` 是否为 `true` -2. 账户是否有 `refresh_token` -3. 账户状态是否为 `active` 或 `quota_exhausted` -4. 查看日志中的 JWT 解析错误 -5. 如果日志显示 `refresh lease held`,说明同一账户已有其他进程持有刷新租约,当前进程会跳过本次刷新 - -### 问题:会话没有被清理 - -检查: -1. `session_cleanup_interval_secs` 配置是否正确 -2. 数据库连接是否正常 -3. `admin_sessions` 表的 `expires_at` 字段格式 - -### 问题:内存占用持续增长 - -可能原因: -- 定时器没有被正确清理 -- `in_flight` 标记没有被移除 - -解决: -- 确保调用 `shutdown()` 优雅关闭 -- 检查是否有未捕获的 panic - -## 性能考虑 - -- **定时器开销**: 每个账户一个定时器,对于大量账户建议分批调度 -- **数据库查询**: `list_all_for_refresh()` 在启动时调用一次,不影响运行时性能 -- **并发控制**: `refresh_concurrency` 已接入后台刷新生产路径,限制本进程同时刷新的账户数,避免突发请求 -- **刷新抖动**: 自动刷新和恢复刷新会加入 80%-120% jitter,降低大量账户同一时间命中 OAuth 上游的概率 -- **跨进程租约**: `account_refresh_leases` 表按账户持有 5 分钟刷新租约,防止多进程同时消费同一个 refresh_token - -## 最佳实践 - -1. **配置刷新边界**: `refresh_margin_seconds` 应大于最大重试时间(~10分钟) -2. **监控失败率**: 如果永久失败率过高,检查上游 API 状态 -3. **定期备份**: 刷新令牌是敏感数据,定期备份数据库 -4. **测试崩溃恢复**: 重启服务后验证 `refreshing` 和 `expired` 状态的恢复 diff --git a/docs/skills/user-collaboration-standards/SKILL.md b/docs/skills/user-collaboration-standards/SKILL.md deleted file mode 100644 index 4c75dd4c3..000000000 --- a/docs/skills/user-collaboration-standards/SKILL.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -name: user-collaboration-standards -description: Use when working with this project owner on codex-proxy-rs design, docs, debugging, refactoring, verification, Pencil files, or agent workflow decisions. ---- - -# User Collaboration Standards - -## Core Principle - -Work as a rigorous collaborator, not as a presenter. The user repeatedly pushes -for real operational truth, real product behavior, consistent design language, -and verified outcomes. Do the work in the shared files, verify it, then report -only the important result. - -## Required Sub-Skills - -- For visual design or Pencil work: use `frontend-design` and `pencil-design`. -- For Rust edits: use `rust-best-practices`. -- Before saying work is complete: use `superpowers:verification-before-completion`. -- When creating or updating a skill: use `skill-creator`; if deploying a real - skill, also use `superpowers:writing-skills`. - -## Communication Rules - -- Be direct and evidence-based. Avoid cheerleading, vague reassurance, and - decorative language. -- If the user asks for a reusable prompt or wording, provide a ready-to-paste - block, not only conceptual guidance. -- If a warning, mismatch, or suspicious behavior appears, trace the source - through config, process state, logs, database, network route, and code before - concluding. -- Preserve the exact success condition. Do not accept a weaker proof because it - is convenient. -- When blocked or uncertain, state the boundary precisely: what is known, what - was checked, and what remains unproven. -- If the user says to stop or "别改了", stop visual changes immediately and only - summarize the current state. - -## Design Collaboration Rules - -### Designer Mindset - -- Design for the actual operator using the admin console, not for a screenshot. -- Do not add explanatory UI copy that describes the design work itself. - Rejected examples include labels like "账号入口已移至顶部" or text that explains - why a component exists. -- Do not invent product concepts or labels unless the codebase/product already - supports them. Rejected examples include vague terms like "链路状态" and - action labels like "处理入口 / 查看账号池" when they do not match a real user - workflow. -- Use Chinese labels for user-facing UI in this admin product. -- Treat repeated visual criticism as a system issue, not a one-off pixel issue. - If the user points out bad spacing in one row, audit similar rows and tokens. - -### Visual Language - -- Keep the console quiet, precise, and operational: white surfaces, soft gray - page canvas, restrained color, stable numeric layout, and low-noise hierarchy. -- Avoid heavy borders as layout boundaries. Prefer whitespace, consistent - spacing, and subtle shadows. Use borders only when a control would otherwise - lose affordance, such as unchecked checkboxes or form boundaries. -- Avoid AI-looking decoration: gradient orbs, top colored bars, arbitrary - dividers, explanatory cards, and one-off accent colors. -- Use the existing design tokens first. If a value is repeated or becomes a - rule, create or update a token and apply it across all affected pages. -- Apply variables to every page, not only the page currently visible. -- Keep contrast easy to observe: colored backgrounds require correct foreground - tokens, and light controls need enough boundary clarity. - -### Layout and Alignment - -- Equal padding matters. Top/bottom and left/right spacing inside cards, rows, - buttons, tags, and form controls must visually match. -- Row contents must be vertically centered. This applies especially to account - emails, status tags, log levels, table cells, checkboxes, and icons. -- Tables and logs need comfortable row height. Do not compress data rows to the - point where they feel cramped. -- Header cells and body cells must share identical column widths and x-origins. -- Large numbers must not deform cards. Use stable widths, numeric fonts, and - layouts that survive future larger values. -- If a table or log is fixed height and scrollable, it is acceptable for - offscreen rows to be clipped; report this as intentional during verification. - -### Component Behavior - -- Reference Element Plus and Ant Design for mature component behavior before - designing form controls, date pickers, selectors, buttons, tables, modals, or - notifications. -- Keep form control sizes consistent with the design language: - `large 40`, `default 32`, `small 24`. -- Keep form control radius and button radius separate. Do not let card radii - leak into inputs, selects, date triggers, or buttons. -- Model interaction states systematically: default, hover, pressed, active, - disabled, focus, success, warning, danger, and info. -- Design components as real controls, not rule cards. Descriptions belong in - docs or outside the component, not inside the component itself. -- For notifications, support success, warning, failure, and info with close - buttons; do not narrow the pattern to "save" only. - -## Pencil Workflow - -- Use Pencil MCP tools as the source of visual truth when editing `.pen` files. -- Read current editor state or target nodes before editing; the document may - have changed. -- Prefer focused `batch_design` updates over broad regeneration. -- Preserve existing content and data unless the user explicitly asks to change - it. -- The user saves/exports manually. Do not spend time on save/export instructions - unless asked. -- After visual changes, verify with `snapshot_layout` and screenshots of the - smallest relevant frame. -- When file JSON and Pencil editor state diverge, reconcile both before - claiming the change is durable. - -## Engineering Collaboration Rules - -- In `codex-proxy-rs`, default to narrow, behavior-preserving changes unless the - user asks for behavior changes. -- For refactors, honor the pattern: split large files first, do not change logic - in the same step, and keep tests passing. -- For Rust changes, read the local code first and follow existing module - boundaries, error handling, and test style. -- Use structured parsers or existing APIs instead of ad hoc string manipulation - when reasonable. -- Do not revert unrelated work in a dirty tree. Work with existing changes. -- For OpenAI/Codex behavior, distinguish Platform flows from Codex-compatible - flows. Validate the exact token type, route, header, or protocol boundary - before claiming success. -- For OAuth, registration, proxy, or protocol questions, include recent active - external examples only when local evidence is not enough. - -## Verification Rules - -- Do not say done, fixed, aligned, restored, or complete without fresh evidence. -- Use the verification that matches the claim: - - Pencil design: `snapshot_layout`, targeted screenshots, token audits. - - `.pen` file integrity: `jq empty`, token searches, diff review. - - Rust code: `cargo fmt --check`, focused tests, broader tests, and clippy - when the blast radius justifies it. - - Docs: link checks or at least `git diff --check` and targeted review. -- Report failed or skipped verification plainly. -- If a known layout warning is intentional, name why it is intentional. - -## Common Mistakes To Avoid - -| Mistake | Correct Behavior | -| --- | --- | -| Showing a design explanation inside the UI | Put explanations in docs or outside the component | -| Fixing one row while leaving the same spacing bug elsewhere | Audit the repeated pattern and token | -| Using borders as page boundaries | Use shadow, spacing, or surface contrast | -| Adding new labels because they sound useful | Use product/user vocabulary already grounded in the system | -| Claiming visual alignment by intuition | Verify with layout snapshot and screenshot | -| Accepting partial protocol success | Validate the exact success boundary the user requested | -| Giving abstract advice when asked for wording | Provide a copyable block | - -## Completion Checklist - -Before final response: - -- The newest user request is addressed, not an older request. -- Changes are in the requested location. -- Existing information was preserved unless explicitly changed. -- Tokens and design language are applied consistently. -- Alignment, padding, contrast, and row heights were checked. -- Verification commands were run and their result is understood. -- The final answer is short, factual, and states any remaining caveat. diff --git a/docs/status-codes.md b/docs/status-codes.md deleted file mode 100644 index c7143d4f2..000000000 --- a/docs/status-codes.md +++ /dev/null @@ -1,55 +0,0 @@ -# Status Codes - -HTTP status codes are not replaced by body codes. Admin/frontend APIs return both an accurate HTTP status and a JSON body `code` unless the status is `204 No Content`. - -## Health - -| Status | Meaning | -| --- | --- | -| `200` | Service is running. | - -## Admin and Frontend APIs - -| HTTP status | Body code range | Meaning | -| --- | --- | --- | -| `200` | `200` | Successful read, login, or lifecycle with response body. | -| `201` | `201` | Resource created. | -| `204` | `204` | Successful lifecycle with no response body. No JSON envelope is sent. | -| `400` | `40000`-`40099` | Malformed JSON, invalid parameters, or invalid cursor. | -| `401` | `40100`-`40199` | Missing/expired admin session or bad admin password. | -| `403` | `40300`-`40399` | Authenticated admin lacks permission for a local-only/bootstrap action. | -| `404` | `40400`-`40499` | Resource not found. | -| `409` | `40900`-`40999` | Duplicate resource or stale state transition. | -| `422` | `42200`-`42299` | Well-formed request failed domain validation. | -| `429` | `42900`-`42999` | Login or operation rate limit. | -| `500` | `50000`-`50099` | Internal service error. | - -Recommended initial body codes: - -| Body code | HTTP status | Meaning | -| --- | --- | --- | -| `40001` | `400` | Validation failed. | -| `40002` | `400` | Invalid cursor. | -| `40101` | `401` | Admin session required. | -| `40102` | `401` | Admin password invalid. | -| `40301` | `403` | Bootstrap action denied. | -| `40401` | `404` | Resource not found. | -| `40901` | `409` | Duplicate resource. | -| `42201` | `422` | Domain validation failed. | -| `42901` | `429` | Login rate limited. | -| `50001` | `500` | Internal service error. | - -## OpenAI-Compatible `/v1/*` - -| Status | Meaning | -| --- | --- | -| `200` | Successful response or SSE stream accepted. | -| `400` | Invalid OpenAI/Responses request body. | -| `401` | Missing or invalid client API key. | -| `404` | Requested model is not a supported Codex model. | -| `413` | Request payload too large for safe replay or forwarding. | -| `429` | Codex quota or rate limit surfaced to client. | -| `499` | Client aborted; log internally only. | -| `502` | Upstream Codex transport or protocol failure. | -| `503` | No usable Codex account is available. | -| `504` | Upstream timeout. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md deleted file mode 100644 index e22cf16dc..000000000 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ /dev/null @@ -1,3091 +0,0 @@ -# Codex Proxy RS Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build `codex-proxy-rs`, a lean Rust rewrite of `codex-proxy` that exposes OpenAI-compatible local endpoints backed only by ChatGPT/Codex accounts and the official Codex backend. - -**Architecture:** Create a new Rust service at `/home/zyy/桌面/Codes/codex-proxy-rs`. The only upstream is `https://chatgpt.com/backend-api`; remove OpenAI official API key passthrough, non-OpenAI/Codex routing, non-OpenAI local model bridges, Electron, and per-account proxy assignment. Treat Codex Desktop impersonation as a first-class subsystem: locked `reqwest` + `rustls`, exact headers, account-scoped Cookie replay, and fingerprint auto-update. Keep `src/main.rs` as the binary/bootstrap layer and keep reusable logic in `src/lib.rs` modules with typed `thiserror` errors. - -**Tech Stack:** Rust 2021, `tokio`, `axum`, `tower-http`, `sqlx` + SQLite WAL, `config`, `serde`, `serde_json`, `serde_yaml`, `tracing`, `tracing-subscriber`, rotating file logging, `argon2`, `rand`, `uuid`, `chrono`, `thiserror`, `anyhow` only in `main.rs` and tests, `base64`, `aes-gcm`, `hmac`, `sha2`, `secrecy`, `zeroize`, `wiremock`, `insta`, `tempfile`. Use the latest stable crate versions at implementation time except dependencies intentionally pinned for Codex Desktop TLS fingerprint parity: `reqwest = 0.12.28` and `rustls = 0.23.36` until a fingerprint review proves a newer pair is equivalent. - -**Execution Mode:** Subagent-Driven. Execute one task at a time with implementation, spec review, and code-quality review before moving to the next task. After each completed task, update this plan's checkbox state and `docs/implementation-status.md` before committing. - ---- - -## Locked Scope - -Keep: - -- OpenAI-compatible inbound API: `/v1/chat/completions`, `/v1/responses`, `/v1/models`. -- Codex Responses passthrough for clients that already speak Responses format. -- ChatGPT/Codex OAuth login, device login, CLI token import, manual access-token import. -- Account pool, account rotation, quota state, and token refresh behavior reimplemented natively with the same required semantics, not through a TypeScript compatibility layer. -- Rust native TLS path using pinned `reqwest` and `rustls`. -- Codex Desktop fingerprint headers and fingerprint auto-update. -- Account-scoped Cloudflare Cookie capture and replay. -- Structured logs, queryable event records, cursor pagination, and log rotation. - -Remove: - -- OpenAI official API Key direct upstream. -- non-OpenAI/Codex provider routing. -- non-OpenAI local model bridge and settings. -- Per-account proxy assignment, proxy health checks, proxy UI, proxy routing. -- Electron desktop packaging and large dashboard UI. -- Legacy compatibility layers, old-project API adapters, and old-project data migration scripts. - -Security split: - -- Admin login is not API key auth. -- Client API keys call `/v1/*` only and cannot log in to admin endpoints. -- Upstream Codex account tokens are internal credentials and cannot be used as client API keys or admin passwords. - -## Rust Architecture Adjustments - -These adjustments come from the `rust-best-practices` review and should be treated as implementation constraints: - -- Use module-local `thiserror` error enums for library code. Reserve `anyhow::Result` for `src/main.rs`, tests, and one-off setup commands. -- Add `Cargo.toml` lint configuration early. Run `cargo clippy --all-targets --all-features --locked -- -D warnings` before each task commit. -- Store secrets with explicit secret types: `secrecy::SecretString` for in-memory tokens/passwords and AES-GCM ciphertext for persisted access tokens, refresh tokens, Cookies, and API key pepper material. -- Hash admin passwords with Argon2id. Hash high-entropy client API keys with HMAC-SHA256 using a server-side pepper, not with the API key display name. -- Prefer concrete service structs over early `dyn Trait` abstraction. Use generics for test seams where useful; introduce trait objects only at boundaries that actually need runtime polymorphism. -- Keep shared runtime state in an `Arc<AppServices>` container that holds cheap-clone handles (`SqlitePool`, `reqwest::Client`, repositories, secret box, config). Avoid mutable globals and avoid `std::sync::Mutex` across async `.await`; use SQLite, channels, or `tokio::sync` primitives. -- Use the `config` crate with root `config.yaml` plus optional root `local.yaml` or `local.yml`. Do not use environment-variable overrides for runtime config. -- Prefer borrowed parameters in public APIs (`&str`, `&[T]`, references to request structs) unless ownership transfer is part of the domain model. -- Keep tests descriptive and targeted. Unit tests live beside modules for private behavior; integration tests under `tests/` cover route and storage behavior. Use `insta` only for structured payload snapshots that are worth reviewing. -- Add sparse Chinese comments only where the reason is not obvious from names or types. Good targets are protocol/security/business invariants: TLS fingerprint pinning, Codex Desktop header parity, account-scoped Cookie replay, admin-session versus client-API-key boundaries, and token refresh invariants. Do not comment every line, do not restate obvious code, and do not leave untracked TODO comments. -- This project has no old deployment or data history to preserve. Do not add compatibility shims, legacy adapters, dual-mode behavior for removed features, or scripts that migrate data from the TypeScript project. SQL schema version files are allowed only for this Rust service's own database creation and future schema evolution. -- Keep Rust identifiers idiomatic: type names use `PascalCase`, functions/variables/fields use `snake_case`, constants use `SCREAMING_SNAKE_CASE`. JSON fields exposed to the frontend use lower camelCase via `#[serde(rename_all = "camelCase")]`; do not expose PascalCase or snake_case JSON fields in admin/frontend APIs. - -## Dependency Version Policy - -- Resolve non-fingerprint Rust dependencies with `cargo add` at implementation time so the project starts from current stable crate releases. -- Commit `Cargo.lock` for reproducible application builds. -- Run `cargo update` during dependency-refresh tasks and include the update in a dedicated commit with test evidence. -- Keep `reqwest = 0.12.28` and `rustls = 0.23.36` pinned for the initial TLS fingerprint implementation. Upgrading either requires a separate fingerprint verification task that compares the real Codex Desktop TLS behavior, request headers, and Cloudflare response behavior before changing the pin. -- Do not introduce an unmaintained crate when a maintained Rust community standard exists. Prefer widely adopted crates already listed in the tech stack. - -## API Contract and Status Codes - -`codex-proxy-rs` exposes two response families. Do not mix them. - -OpenAI-compatible `/v1/*` endpoints: - -- Success responses follow the OpenAI-compatible shape for the endpoint. -- Error responses always use OpenAI error format: - -```json -{ - "error": { - "message": "Human readable message", - "type": "invalid_request_error", - "param": null, - "code": "invalid_api_key" - } -} -``` - -- Responses include an `X-Request-Id` header for tracing, but do not add `requestId` to the JSON body because that would break OpenAI compatibility. -- Streaming errors before any upstream bytes are sent return the normal HTTP status plus OpenAI error JSON. -- Streaming errors after SSE starts are sent as an SSE error event using the same `error` object. - -Admin/frontend `/admin/*` endpoints: - -- Use real HTTP status codes and a body-level frontend code. The HTTP status is still authoritative for transport, browser, gateway, and observability behavior. The body `code` is for frontend branching and product error handling. -- JSON field names use lower camelCase (`requestId`, `nextCursor`, `createdAt`). Rust structs keep idiomatic Rust naming and apply `#[serde(rename_all = "camelCase")]`. -- All responses include an `X-Request-Id` response header. Admin/frontend JSON bodies also include `requestId` except `204 No Content`, because a `204` response has no body. -- Success envelope: - -```json -{ - "code": 200, - "message": "OK", - "data": {}, - "requestId": "req_..." -} -``` - -- List envelope: - -```json -{ - "code": 200, - "message": "OK", - "data": [], - "page": { - "limit": 50, - "nextCursor": null - }, - "requestId": "req_..." -} -``` - -- Error envelope: - -```json -{ - "code": 40101, - "message": "Admin login required", - "data": null, - "requestId": "req_..." -} -``` - -Body code ranges: - -| Body code | HTTP status | Meaning | -| --- | --- | --- | -| `200` | `200` | Successful read/login/mutation with body. | -| `201` | `201` | Resource created. | -| `204` | `204` | No body; use only when the frontend does not need an envelope. | -| `40000`-`40099` | `400` | Malformed JSON, invalid parameters, or invalid cursor. | -| `40100`-`40199` | `401` | Missing/expired admin session or bad admin password. | -| `40300`-`40399` | `403` | Authenticated admin lacks permission for a local-only/bootstrap action. | -| `40400`-`40499` | `404` | Admin resource not found. | -| `40900`-`40999` | `409` | Conflict, such as duplicate account import or stale state transition. | -| `42200`-`42299` | `422` | Well-formed request that fails domain validation. | -| `42900`-`42999` | `429` | Login rate limit or admin operation rate limit. | -| `50000`-`50099` | `500` | Internal service error. | - -Status code matrix: - -| Area | Status | Meaning | -| --- | --- | --- | -| Health | `200` | Service is running. | -| Admin | `200` | Successful read, login, or mutation with response body. | -| Admin | `201` | Resource created, such as a client API key or imported account. | -| Admin | `204` | Successful mutation with no response body, such as logout/delete when no body is useful. | -| Admin | `400` | Malformed JSON, invalid parameters, or invalid cursor. | -| Admin | `401` | Missing/expired admin session or bad admin password. | -| Admin | `403` | Authenticated admin lacks permission for a local-only/bootstrap action. | -| Admin | `404` | Admin resource not found. | -| Admin | `409` | Conflict, such as duplicate account import or stale state transition. | -| Admin | `422` | Well-formed request that fails domain validation. | -| Admin | `429` | Login rate limit or admin operation rate limit. | -| Admin | `500` | Internal service error. | -| `/v1/*` | `200` | Successful non-streaming response or SSE stream accepted. | -| `/v1/*` | `400` | Invalid OpenAI/Responses request body. | -| `/v1/*` | `401` | Missing or invalid client API key. Never use admin session as API auth. | -| `/v1/*` | `404` | Requested model is not a supported Codex model. | -| `/v1/*` | `413` | Request payload too large for safe replay/forwarding. | -| `/v1/*` | `429` | Codex quota/rate limit surfaced to client. | -| `/v1/*` | `499` | Client aborted; log internally only. Do not send this after disconnect. | -| `/v1/*` | `502` | Upstream Codex transport/protocol failure. | -| `/v1/*` | `503` | No usable Codex account is available. | -| `/v1/*` | `504` | Upstream timeout. | - -All paginated admin APIs use cursor pagination with `limit` default `50`, max `200`, and stable ordering by `(created_at desc, id desc)`. - -## Documentation Progress Discipline - -- Each implementation task must update the matching checkboxes in this plan before its commit. -- Each completed feature must update `docs/implementation-status.md` with status, commit hash, test command, and any known limitations. -- API shape or status-code changes must update `docs/api.md` and `docs/status-codes.md` in the same commit as the code change. -- Dependency changes must update `docs/dependency-policy.md` and mention whether the change is a normal latest-stable refresh or a TLS-fingerprint-sensitive change. -- A task is not complete until code, tests, and documentation status are all updated. - -## Target File Structure - -Create the new project at `/home/zyy/桌面/Codes/codex-proxy-rs`: - -```text -codex-proxy-rs/ - Cargo.toml - rust-toolchain.toml - README.md - config.yaml - docs/api.md - docs/dependency-policy.md - docs/implementation-status.md - docs/status-codes.md - migrations/0001_initial.sql - migrations/0002_events_indexes.sql - src/ - main.rs - lib.rs - app.rs - config.rs - error.rs - state.rs - crypto.rs - pagination.rs - http/ - mod.rs - middleware.rs - admin.rs - auth.rs - v1.rs - health.rs - auth/ - mod.rs - error.rs - admin_session.rs - api_key.rs - oauth.rs - refresh.rs - token.rs - accounts/ - mod.rs - model.rs - pool.rs - repository.rs - lifecycle.rs - codex/ - mod.rs - client.rs - headers.rs - sse.rs - types.rs - usage.rs - websocket.rs - fingerprint/ - mod.rs - model.rs - updater.rs - cookies/ - mod.rs - repository.rs - jar.rs - logs/ - mod.rs - event.rs - repository.rs - rotation.rs - models/ - mod.rs - catalog.rs - storage/ - mod.rs - db.rs - migrations.rs - translation/ - mod.rs - openai_to_codex.rs - codex_to_openai.rs - schema.rs - tests/ - admin_auth_test.rs - api_key_auth_test.rs - codex_headers_test.rs - cookie_store_test.rs - logs_pagination_test.rs - refresh_scheduler_test.rs - routes_chat_test.rs - routes_responses_test.rs -``` - ---- - -### Task 1: Scaffold Rust Workspace - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/Cargo.toml` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/rust-toolchain.toml` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/main.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/error.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/README.md` - -- [x] **Step 1: Create project directory** - -Run: - -```bash -cd /home/zyy/Codes -mkdir codex-proxy-rs -cd codex-proxy-rs -git init -mkdir -p src -``` - -Expected: `git status --short` prints no tracked files yet. - -- [x] **Step 2: Resolve current crate versions and write Cargo manifest** - -Run these commands so non-fingerprint dependencies resolve to the latest stable versions available at implementation time: - -```bash -cargo add anyhow aes-gcm argon2 async-stream base64 bytes futures-util hmac http http-body-util rand serde_json serde_yaml sha2 thiserror tracing tracing-appender uuid zeroize -cargo add axum --features macros,ws -cargo add chrono --features serde -cargo add config -cargo add reqwest@=0.12.28 --no-default-features --features rustls-tls-native-roots,stream,gzip,brotli,zstd,deflate,http2,json -cargo add rustls@=0.23.36 -cargo add secrecy --features serde -cargo add serde --features derive -cargo add sqlx --features runtime-tokio-rustls,sqlite,chrono,uuid,json,migrate -cargo add tokio --features macros,rt-multi-thread,signal,time,fs -cargo add tower -cargo add tower-http --features cors,trace,request-id,timeout -cargo add --dev insta --features json -cargo add --dev tempfile wiremock -``` - -Then edit `/home/zyy/桌面/Codes/codex-proxy-rs/Cargo.toml` so it includes package metadata and lint policy. The exact non-pinned versions may be newer than this example because `cargo add` resolves them at execution time: - -```toml -[package] -name = "codex-proxy-rs" -version = "0.1.0" -edition = "2021" -license = "LicenseRef-Non-Commercial" - -[dependencies] -anyhow = "1" -aes-gcm = "0.10" -argon2 = "0.5" -async-stream = "0.3" -axum = { version = "0.7", features = ["macros", "ws"] } -base64 = "0.22" -bytes = "1" -chrono = { version = "0.4", features = ["serde"] } -config = "0.15" -futures-util = "0.3" -hmac = "0.12" -http = "1" -http-body-util = "0.1" -rand = "0.8" -reqwest = { version = "=0.12.28", default-features = false, features = ["rustls-tls-native-roots", "stream", "gzip", "brotli", "zstd", "deflate", "http2", "json"] } -rustls = "=0.23.36" -secrecy = { version = "0.8", features = ["serde"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml = "0.9" -sha2 = "0.10" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "json", "migrate"] } -thiserror = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "fs"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "timeout"] } -tracing = "0.1" -tracing-appender = "0.2" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -uuid = { version = "1", features = ["v4", "serde"] } -zeroize = "1" - -[dev-dependencies] -insta = { version = "1", features = ["json"] } -tempfile = "3" -wiremock = "0.6" - -[lints.rust] -future_incompatible = "warn" -nonstandard_style = "deny" -unsafe_code = "forbid" - -[lints.clippy] -all = { level = "deny", priority = 10 } -redundant_clone = { level = "deny", priority = 9 } -needless_collect = { level = "deny", priority = 9 } -large_enum_variant = { level = "deny", priority = 9 } -manual_ok_or = { level = "deny", priority = 9 } -pedantic = { level = "warn", priority = 3 } -``` - -- [x] **Step 3: Pin Rust toolchain** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/rust-toolchain.toml`: - -```toml -[toolchain] -channel = "stable" -components = ["rustfmt", "clippy"] -``` - -- [x] **Step 4: Add minimal entry points** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod error; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/error.rs`: - -```rust -use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; -use serde::Serialize; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum AppError { - #[error("configuration error: {0}")] - Config(String), - #[error("database error: {0}")] - Database(#[from] sqlx::Error), - #[error("upstream error: {0}")] - Upstream(String), - #[error("unauthorized")] - Unauthorized, - #[error("forbidden")] - Forbidden, - #[error("bad request: {0}")] - BadRequest(String), -} - -#[derive(Serialize)] -struct ErrorBody<'a> { - error: ErrorMessage<'a>, -} - -#[derive(Serialize)] -struct ErrorMessage<'a> { - message: &'a str, - #[serde(rename = "type")] - kind: &'a str, - code: &'a str, -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let status = match self { - AppError::Unauthorized => StatusCode::UNAUTHORIZED, - AppError::Forbidden => StatusCode::FORBIDDEN, - AppError::BadRequest(_) => StatusCode::BAD_REQUEST, - AppError::Config(_) | AppError::Database(_) | AppError::Upstream(_) => StatusCode::INTERNAL_SERVER_ERROR, - }; - let message = self.to_string(); - let body = Json(ErrorBody { - error: ErrorMessage { - message: &message, - kind: "server_error", - code: "codex_proxy_rs_error", - }, - }); - (status, body).into_response() - } -} - -pub type AppResult<T> = Result<T, AppError>; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/main.rs`: - -```rust -#[tokio::main] -async fn main() -> anyhow::Result<()> { - println!("codex-proxy-rs bootstrap"); - Ok(()) -} -``` - -- [x] **Step 5: Verify scaffold builds** - -Run: - -```bash -cargo test -cargo clippy --all-targets --all-features --locked -- -D warnings -``` - -Expected: both commands pass. - -- [x] **Step 6: Commit scaffold** - -```bash -git add Cargo.toml rust-toolchain.toml src README.md -git commit -m "chore: scaffold codex-proxy-rs" -``` - ---- - -### Task 1A: API Contract and Documentation Baseline - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/docs/api.md` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/docs/status-codes.md` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/docs/dependency-policy.md` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/docs/implementation-status.md` - -- [x] **Step 1: Create API contract document** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/docs/api.md`: - -````markdown -# API Contract - -`codex-proxy-rs` exposes two API families. - -## `/v1/*` - -These endpoints are OpenAI-compatible and are authenticated only by client API keys using `Authorization: Bearer cpr_...`. -Responses include an `X-Request-Id` header for tracing, but the body stays OpenAI-compatible and does not include a custom `requestId` field. - -Error body: - -```json -{ - "error": { - "message": "Invalid client API key", - "type": "invalid_request_error", - "param": null, - "code": "invalid_api_key" - } -} -``` - -Streaming errors after SSE has started use: - -```text -event: error -data: {"error":{"message":"Upstream failed","type":"server_error","param":null,"code":"upstream_error"}} -``` - -## `/admin/*` - -Admin endpoints are authenticated only by HttpOnly admin session cookies. -Admin JSON uses lower camelCase field names. Every admin response includes an `X-Request-Id` header, and every JSON body includes `requestId`. - -Use real HTTP status codes and body-level frontend codes together. Do not return HTTP `200` for failed requests. The body `code` exists for frontend branching; the HTTP status remains the transport truth. - -Success body: - -```json -{ - "code": 200, - "message": "OK", - "data": {}, - "requestId": "req_01" -} -``` - -List body: - -```json -{ - "code": 200, - "message": "OK", - "data": [], - "page": { - "limit": 50, - "nextCursor": null - }, - "requestId": "req_01" -} -``` - -Error body: - -```json -{ - "code": 40101, - "message": "Admin login required", - "data": null, - "requestId": "req_01" -} -``` - -Pagination uses cursor ordering by `(created_at desc, id desc)`, default `limit=50`, max `limit=200`. - -Rust structs use `PascalCase` type names and `snake_case` fields internally, then expose lower camelCase through serde: - -```rust -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiEnvelope<T> { - pub code: u32, - pub message: String, - pub data: T, - pub request_id: String, -} -``` -```` - -- [x] **Step 2: Create status-code document** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/docs/status-codes.md`: - -````markdown -# Status Codes - -HTTP status codes are not replaced by body codes. Admin/frontend APIs return both an accurate HTTP status and a JSON body `code` unless the status is `204 No Content`. - -## Health - -| Status | Meaning | -| --- | --- | -| `200` | Service is running. | - -## Admin and Frontend APIs - -| HTTP status | Body code range | Meaning | -| --- | --- | --- | -| `200` | `200` | Successful read, login, or mutation with response body. | -| `201` | `201` | Resource created. | -| `204` | `204` | Successful mutation with no response body. No JSON envelope is sent. | -| `400` | `40000`-`40099` | Malformed JSON, invalid parameters, or invalid cursor. | -| `401` | `40100`-`40199` | Missing/expired admin session or bad admin password. | -| `403` | `40300`-`40399` | Authenticated admin lacks permission for a local-only/bootstrap action. | -| `404` | `40400`-`40499` | Resource not found. | -| `409` | `40900`-`40999` | Duplicate resource or stale state transition. | -| `422` | `42200`-`42299` | Well-formed request failed domain validation. | -| `429` | `42900`-`42999` | Login or operation rate limit. | -| `500` | `50000`-`50099` | Internal service error. | - -Recommended initial body codes: - -| Body code | HTTP status | Meaning | -| --- | --- | --- | -| `40001` | `400` | Validation failed. | -| `40002` | `400` | Invalid cursor. | -| `40101` | `401` | Admin session required. | -| `40102` | `401` | Admin password invalid. | -| `40301` | `403` | Bootstrap action denied. | -| `40401` | `404` | Resource not found. | -| `40901` | `409` | Duplicate resource. | -| `42201` | `422` | Domain validation failed. | -| `42901` | `429` | Login rate limited. | -| `50001` | `500` | Internal service error. | - -## OpenAI-Compatible `/v1/*` - -| Status | Meaning | -| --- | --- | -| `200` | Successful response or SSE stream accepted. | -| `400` | Invalid OpenAI/Responses request body. | -| `401` | Missing or invalid client API key. | -| `404` | Requested model is not a supported Codex model. | -| `413` | Request payload too large for safe replay or forwarding. | -| `429` | Codex quota or rate limit surfaced to client. | -| `499` | Client aborted; log internally only. | -| `502` | Upstream Codex transport or protocol failure. | -| `503` | No usable Codex account is available. | -| `504` | Upstream timeout. | -```` - -- [x] **Step 3: Create dependency policy document** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/docs/dependency-policy.md`: - -````markdown -# Dependency Policy - -- Use current stable crate releases for normal Rust dependencies. -- Commit `Cargo.lock` because this is an application. -- Run `cargo update` in dedicated dependency-refresh changes. -- Keep `reqwest = 0.12.28` and `rustls = 0.23.36` pinned until a TLS fingerprint review proves a newer pair matches real Codex Desktop behavior. -- Do not add unmaintained crates when a maintained community-standard crate exists. -- Every dependency change must run: - -```bash -cargo fmt --check -cargo clippy --all-targets --all-features --locked -- -D warnings -cargo test -``` -```` - -- [x] **Step 4: Create implementation status document** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/docs/implementation-status.md`: - -````markdown -# Implementation Status - -Update this file before each feature commit. - -| Area | Status | Commit | Verification | Notes | -| --- | --- | --- | --- | --- | -| Scaffold | Planned | | | | -| API contract docs | Planned | | | | -| Configuration | Planned | | | | -| SQLite storage | Planned | | | | -| Admin auth and client API keys | Planned | | | | -| Secret encryption | Planned | | | | -| Logging and pagination | Planned | | | | -| TLS headers and fingerprint | Planned | | | | -| Cookie persistence | Planned | | | | -| Account pool and refresh | Planned | | | | -| Translation | Planned | | | | -| HTTP routes | Planned | | | | -| Upstream lifecycle | Planned | | | | -| Fingerprint updates | Planned | | | | -| Runtime docs and packaging | Planned | | | | -```` - -- [x] **Step 5: Commit API contract docs** - -```bash -git add docs/api.md docs/status-codes.md docs/dependency-policy.md docs/implementation-status.md docs/superpowers/plans/2026-06-11-codex-proxy-rs.md -git commit -m "docs: define api contracts and status codes" -``` - ---- - -### Task 2: Configuration and Application State - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/config.yaml` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/config.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/state.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/config_test.rs` - -- [x] **Step 1: Write failing config test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/config_test.rs`: - -```rust -use codex_proxy_rs::config::AppConfig; - -#[test] -fn default_config_keeps_only_codex_backend() { - let yaml = r#" -server: - host: 127.0.0.1 - port: 8080 -api: - base_url: https://chatgpt.com/backend-api -auth: - refresh_margin_seconds: 300 - refresh_enabled: true - refresh_concurrency: 2 -database: - url: sqlite://data/codex-proxy-rs.sqlite -security: - master_key_file: data/master.key - api_key_pepper_file: data/api-key-pepper.key -tls: - force_http11: false -admin: - session_ttl_minutes: 1440 -logging: - directory: logs - max_file_bytes: 10485760 - retention_days: 14 -"#; - let cfg: AppConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api"); - assert_eq!(cfg.auth.refresh_margin_seconds, 300); - assert_eq!(cfg.database.url, "sqlite://data/codex-proxy-rs.sqlite"); - assert_eq!(cfg.security.master_key_file, "data/master.key"); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test default_config_keeps_only_codex_backend -``` - -Expected: compile failure because `codex_proxy_rs::config` does not exist. - -- [x] **Step 3: Implement config types** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod config; -pub mod error; -pub mod state; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/config.rs`: - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct AppConfig { - pub server: ServerConfig, - pub api: ApiConfig, - pub auth: AuthConfig, - pub database: DatabaseConfig, - pub security: SecurityConfig, - pub tls: TlsConfig, - pub admin: AdminConfig, - pub logging: LoggingConfig, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct ServerConfig { - pub host: String, - pub port: u16, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct ApiConfig { - pub base_url: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct AuthConfig { - pub refresh_margin_seconds: u64, - pub refresh_enabled: bool, - pub refresh_concurrency: u32, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct DatabaseConfig { - pub url: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct SecurityConfig { - pub master_key_file: String, - pub api_key_pepper_file: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct TlsConfig { - pub force_http11: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct AdminConfig { - pub session_ttl_minutes: u64, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct LoggingConfig { - pub directory: String, - pub max_file_bytes: u64, - pub retention_days: u64, -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/state.rs`: - -```rust -use std::sync::Arc; - -use crate::config::AppConfig; - -#[derive(Clone)] -pub struct AppState { - pub services: Arc<AppServices>, -} - -pub struct AppServices { - pub config: AppConfig, -} - -impl AppState { - pub fn new(config: AppConfig) -> Self { - Self { - services: Arc::new(AppServices { config }), - } - } - - pub fn config(&self) -> &AppConfig { - &self.services.config - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/config.yaml`: - -```yaml -server: - host: 127.0.0.1 - port: 8080 -api: - base_url: https://chatgpt.com/backend-api -auth: - refresh_margin_seconds: 300 - refresh_enabled: true - refresh_concurrency: 2 -database: - url: sqlite://data/codex-proxy-rs.sqlite -security: - master_key_file: data/master.key - api_key_pepper_file: data/api-key-pepper.key -tls: - force_http11: false -admin: - session_ttl_minutes: 1440 -logging: - directory: logs - max_file_bytes: 10485760 - retention_days: 14 -``` - -- [x] **Step 4: Verify GREEN** - -Run: - -```bash -cargo test default_config_keeps_only_codex_backend -``` - -Expected: pass. - -- [x] **Step 5: Commit config** - -```bash -git add config src tests/config_test.rs -git commit -m "feat: add codex-only configuration" -``` - ---- - -### Task 3: SQLite Storage with sqlx-Managed Schema - -**Files:** -- Create schema version file: `/home/zyy/桌面/Codes/codex-proxy-rs/migrations/0001_initial.sql` -- Create schema version file: `/home/zyy/桌面/Codes/codex-proxy-rs/migrations/0002_events_indexes.sql` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/storage/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/storage/db.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/storage_schema_test.rs` - -- [x] **Step 1: Write failing schema test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/storage_schema_test.rs`: - -```rust -use codex_proxy_rs::storage::db::connect_sqlite; - -#[tokio::test] -async fn sqlite_schema_creates_accounts_and_event_tables() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("test.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - - let row: (i64,) = sqlx::query_as("select count(*) from sqlite_master where type = 'table' and name in ('accounts', 'client_api_keys', 'event_logs')") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(row.0, 3); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test sqlite_schema_creates_accounts_and_event_tables -``` - -Expected: compile failure because `storage::db` does not exist. - -- [x] **Step 3: Create sqlx schema version files** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/migrations/0001_initial.sql`: - -```sql -pragma foreign_keys = on; - -create table if not exists admin_users ( - id text primary key, - password_hash text not null, - created_at text not null, - updated_at text not null -); - -create table if not exists admin_sessions ( - id text primary key, - user_id text not null references admin_users(id) on delete cascade, - expires_at text not null, - created_at text not null -); - -create table if not exists client_api_keys ( - id text primary key, - name text not null, - prefix text not null, - key_hash text not null, - enabled integer not null default 1, - created_at text not null, - last_used_at text -); - -create table if not exists accounts ( - id text primary key, - email text, - account_id text, - user_id text, - label text, - plan_type text, - access_token_cipher text not null, - refresh_token_cipher text, - status text not null, - quota_json text, - quota_fetched_at text, - added_at text not null, - updated_at text not null -); - -create table if not exists account_usage ( - account_id text primary key references accounts(id) on delete cascade, - request_count integer not null default 0, - input_tokens integer not null default 0, - output_tokens integer not null default 0, - cached_tokens integer not null default 0, - last_used_at text -); - -create table if not exists account_cookies ( - id text primary key, - account_id text not null references accounts(id) on delete cascade, - domain text not null, - name text not null, - value_cipher text not null, - path text not null default '/', - expires_at text, - updated_at text not null, - unique(account_id, domain, name, path) -); - -create table if not exists fingerprints ( - id text primary key, - app_version text not null, - build_number text not null, - platform text not null, - arch text not null, - chromium_version text not null, - user_agent_template text not null, - source text not null, - created_at text not null -); - -create table if not exists event_logs ( - id text primary key, - request_id text, - kind text not null, - level text not null, - account_id text, - route text, - model text, - status_code integer, - latency_ms integer, - message text not null, - metadata_json text not null, - created_at text not null -); -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/migrations/0002_events_indexes.sql`: - -```sql -create index if not exists idx_event_logs_created_id on event_logs(created_at desc, id desc); -create index if not exists idx_event_logs_kind_created on event_logs(kind, created_at desc); -create index if not exists idx_event_logs_request_id on event_logs(request_id); -create index if not exists idx_accounts_status on accounts(status); -create index if not exists idx_client_api_keys_prefix on client_api_keys(prefix); -create index if not exists idx_account_cookies_account on account_cookies(account_id); -``` - -- [x] **Step 4: Implement database connector** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod config; -pub mod error; -pub mod state; -pub mod storage; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/storage/mod.rs`: - -```rust -pub mod db; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/storage/db.rs`: - -```rust -use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; -use std::{str::FromStr, time::Duration}; - -pub async fn connect_sqlite(database_url: &str) -> Result<SqlitePool, sqlx::Error> { - let options = SqliteConnectOptions::from_str(database_url)? - .create_if_missing(true) - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) - .foreign_keys(true) - .busy_timeout(Duration::from_secs(5)); - let pool = SqlitePool::connect_with(options).await?; - sqlx::migrate!("./migrations").run(&pool).await?; - Ok(pool) -} -``` - -- [x] **Step 5: Verify schema** - -Run: - -```bash -cargo test sqlite_schema_creates_accounts_and_event_tables -``` - -Expected: pass. - -- [x] **Step 6: Commit storage** - -```bash -git add Cargo.toml migrations src/storage src/lib.rs tests/storage_schema_test.rs -git commit -m "feat: add sqlite storage schema" -``` - ---- - -### Task 4: Split Admin Login, Client API Keys, and Upstream Accounts - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/error.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/admin_session.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/api_key.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/admin_auth_test.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/api_key_auth_test.rs` - -- [x] **Step 1: Write failing tests for separate secret types** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/api_key_auth_test.rs`: - -```rust -use codex_proxy_rs::auth::api_key::ApiKeyHasher; - -#[test] -fn client_api_key_has_proxy_prefix_and_verifies_against_hash() { - let hasher = ApiKeyHasher::new([9u8; 32]); - let generated = hasher.generate_client_api_key("cursor"); - assert!(generated.plaintext.starts_with("cpr_")); - assert_eq!(generated.prefix.len(), 12); - assert!(hasher.verify_client_api_key(&generated.plaintext, &generated.key_hash).unwrap()); - assert!(!hasher.verify_client_api_key("wrong", &generated.key_hash).unwrap()); -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/admin_auth_test.rs`: - -```rust -use codex_proxy_rs::auth::admin_session::{hash_admin_password, verify_admin_password}; - -#[test] -fn admin_password_hash_is_not_a_client_api_key() { - let hash = hash_admin_password("correct horse battery staple").unwrap(); - assert!(verify_admin_password("correct horse battery staple", &hash).unwrap()); - assert!(!verify_admin_password("cpr_fake_client_key", &hash).unwrap()); -} -``` - -- [x] **Step 2: Run tests and verify RED** - -Run: - -```bash -cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash admin_password_hash_is_not_a_client_api_key -``` - -Expected: compile failure because auth modules do not exist. - -- [x] **Step 3: Implement hashing helpers** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod auth; -pub mod config; -pub mod crypto; -pub mod error; -pub mod state; -pub mod storage; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/mod.rs`: - -```rust -pub mod admin_session; -pub mod api_key; -pub mod error; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/error.rs`: - -```rust -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum AuthError { - #[error("password hash error: {0}")] - PasswordHash(#[from] argon2::password_hash::Error), - #[error("invalid api key encoding: {0}")] - ApiKeyEncoding(#[from] base64::DecodeError), - #[error("invalid api key pepper length")] - InvalidPepperLength, -} - -pub type AuthResult<T> = Result<T, AuthError>; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/admin_session.rs`: - -```rust -use argon2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Argon2, -}; - -use crate::auth::error::AuthResult; - -pub fn hash_admin_password(password: &str) -> AuthResult<String> { - let salt = SaltString::generate(&mut OsRng); - Ok(Argon2::default().hash_password(password.as_bytes(), &salt)?.to_string()) -} - -pub fn verify_admin_password(password: &str, hash: &str) -> AuthResult<bool> { - let parsed = PasswordHash::new(hash)?; - Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()) -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/api_key.rs`: - -```rust -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use hmac::{Hmac, Mac}; -use rand::RngCore; -use sha2::Sha256; - -use crate::auth::error::{AuthError, AuthResult}; - -type HmacSha256 = Hmac<Sha256>; - -#[derive(Debug, Clone)] -pub struct GeneratedClientApiKey { - pub plaintext: String, - pub prefix: String, - pub key_hash: String, -} - -#[derive(Debug, Clone)] -pub struct ApiKeyHasher { - pepper: [u8; 32], -} - -impl ApiKeyHasher { - pub fn new(pepper: [u8; 32]) -> Self { - Self { pepper } - } - - pub fn try_from_slice(pepper: &[u8]) -> AuthResult<Self> { - let pepper: [u8; 32] = pepper.try_into().map_err(|_| AuthError::InvalidPepperLength)?; - Ok(Self::new(pepper)) - } - - pub fn generate_client_api_key(&self, _name: &str) -> GeneratedClientApiKey { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - let plaintext = format!("cpr_{}", URL_SAFE_NO_PAD.encode(bytes)); - let prefix = plaintext.chars().take(12).collect::<String>(); - let key_hash = self.hash_client_api_key(&plaintext); - GeneratedClientApiKey { plaintext, prefix, key_hash } - } - - pub fn hash_client_api_key(&self, plaintext: &str) -> String { - let mut mac = HmacSha256::new_from_slice(&self.pepper).expect("HMAC accepts any key size"); - mac.update(plaintext.as_bytes()); - URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) - } - - pub fn verify_client_api_key(&self, plaintext: &str, key_hash: &str) -> AuthResult<bool> { - if !plaintext.starts_with("cpr_") { - return Ok(false); - } - let suffix = plaintext.strip_prefix("cpr_").unwrap_or_default(); - let decoded = URL_SAFE_NO_PAD.decode(suffix)?; - let candidate = format!("cpr_{}", URL_SAFE_NO_PAD.encode(decoded)); - Ok(self.hash_client_api_key(&candidate) == key_hash) - } -} -``` - -- [x] **Step 4: Verify auth split tests** - -Run: - -```bash -cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash admin_password_hash_is_not_a_client_api_key -``` - -Expected: pass. - -- [x] **Step 5: Commit auth split** - -```bash -git add Cargo.toml src/auth src/lib.rs tests/admin_auth_test.rs tests/api_key_auth_test.rs -git commit -m "feat: split admin login and client api keys" -``` - ---- - -### Task 4A: Encrypt Stored Upstream Secrets - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/crypto.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/crypto_test.rs` - -- [x] **Step 1: Write failing encryption test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/crypto_test.rs`: - -```rust -use codex_proxy_rs::crypto::SecretBox; -use secrecy::{ExposeSecret, SecretString}; - -#[test] -fn secret_box_encrypts_and_decrypts_without_storing_plaintext() { - let secret_box = SecretBox::new([7u8; 32]); - let plaintext = SecretString::new("rt_example_refresh_token".to_string()); - let ciphertext = secret_box.encrypt(&plaintext).unwrap(); - - assert!(ciphertext.starts_with("v1:")); - assert!(!ciphertext.contains("rt_example_refresh_token")); - assert_eq!(secret_box.decrypt(&ciphertext).unwrap().expose_secret(), "rt_example_refresh_token"); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext -``` - -Expected: compile failure because `codex_proxy_rs::crypto::SecretBox` does not exist. - -- [x] **Step 3: Implement AES-GCM secret encryption** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/crypto.rs`: - -```rust -use aes_gcm::{ - aead::{Aead, OsRng, rand_core::RngCore}, - Aes256Gcm, KeyInit, Nonce, -}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use secrecy::{ExposeSecret, SecretString}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum CryptoError { - #[error("invalid secret key length")] - InvalidKeyLength, - #[error("secret encryption failed")] - Encrypt, - #[error("secret decryption failed")] - Decrypt, - #[error("invalid secret encoding: {0}")] - Decode(#[from] base64::DecodeError), - #[error("unsupported secret version")] - UnsupportedVersion, - #[error("secret is not valid utf-8: {0}")] - Utf8(#[from] std::string::FromUtf8Error), -} - -pub type CryptoResult<T> = Result<T, CryptoError>; - -#[derive(Clone)] -pub struct SecretBox { - key: [u8; 32], -} - -impl SecretBox { - pub fn new(key: [u8; 32]) -> Self { - Self { key } - } - - pub fn encrypt(&self, plaintext: &SecretString) -> CryptoResult<String> { - let cipher = Aes256Gcm::new_from_slice(&self.key) - .map_err(|_| CryptoError::InvalidKeyLength)?; - let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.expose_secret().as_bytes()) - .map_err(|_| CryptoError::Encrypt)?; - Ok(format!( - "v1:{}:{}", - URL_SAFE_NO_PAD.encode(nonce_bytes), - URL_SAFE_NO_PAD.encode(ciphertext) - )) - } - - pub fn decrypt(&self, encoded: &str) -> CryptoResult<SecretString> { - let mut parts = encoded.split(':'); - let version = parts.next().unwrap_or_default(); - let nonce = parts.next().unwrap_or_default(); - let ciphertext = parts.next().unwrap_or_default(); - if version != "v1" { - return Err(CryptoError::UnsupportedVersion); - } - let nonce = URL_SAFE_NO_PAD.decode(nonce)?; - let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext)?; - let cipher = Aes256Gcm::new_from_slice(&self.key) - .map_err(|_| CryptoError::InvalidKeyLength)?; - let plaintext = cipher - .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref()) - .map_err(|_| CryptoError::Decrypt)?; - Ok(SecretString::new(String::from_utf8(plaintext)?)) - } -} -``` - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod auth; -pub mod config; -pub mod crypto; -pub mod error; -pub mod state; -pub mod storage; -``` - -- [x] **Step 4: Verify encryption** - -Run: - -```bash -cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext -``` - -Expected: pass. - -- [x] **Step 5: Commit secret encryption** - -```bash -git add Cargo.toml src/crypto.rs src/lib.rs tests/crypto_test.rs -git commit -m "feat: encrypt stored upstream secrets" -``` - ---- - -### Task 5: Structured Logging, Event Store, Rotation, and Pagination - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/pagination.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/event.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/repository.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/rotation.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/logs_pagination_test.rs` - -- [x] **Step 1: Write failing pagination test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/logs_pagination_test.rs`: - -```rust -use codex_proxy_rs::{ - logs::{event::{EventLevel, EventLog}, repository::EventLogRepository}, - storage::db::connect_sqlite, -}; - -#[tokio::test] -async fn event_logs_are_cursor_paginated() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("logs.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - let repo = EventLogRepository::new(pool); - - for idx in 0..3 { - repo.insert(EventLog::new("request", EventLevel::Info, format!("event {idx}"))).await.unwrap(); - } - - let first = repo.list(None, 2).await.unwrap(); - assert_eq!(first.items.len(), 2); - assert!(first.next_cursor.is_some()); - - let second = repo.list(first.next_cursor, 2).await.unwrap(); - assert_eq!(second.items.len(), 1); - assert!(second.next_cursor.is_none()); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test event_logs_are_cursor_paginated -``` - -Expected: compile failure because logs modules do not exist. - -- [x] **Step 3: Implement pagination and event models** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod auth; -pub mod config; -pub mod crypto; -pub mod error; -pub mod logs; -pub mod pagination; -pub mod state; -pub mod storage; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/pagination.rs`: - -```rust -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use serde::Serialize; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Page<T> { - pub items: Vec<T>, - pub next_cursor: Option<String>, -} - -pub fn encode_cursor(created_at: &str, id: &str) -> String { - URL_SAFE_NO_PAD.encode(format!("{created_at}|{id}")) -} - -pub fn decode_cursor(cursor: &str) -> Option<(String, String)> { - let raw = URL_SAFE_NO_PAD.decode(cursor).ok()?; - let text = String::from_utf8(raw).ok()?; - let (created_at, id) = text.split_once('|')?; - Some((created_at.to_string(), id.to_string())) -} - -pub fn clamp_limit(limit: u32) -> u32 { - limit.clamp(1, 200) -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/mod.rs`: - -```rust -pub mod event; -pub mod repository; -pub mod rotation; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/event.rs`: - -```rust -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum EventLevel { - Debug, - Info, - Warn, - Error, -} - -impl EventLevel { - pub fn as_str(self) -> &'static str { - match self { - EventLevel::Debug => "debug", - EventLevel::Info => "info", - EventLevel::Warn => "warn", - EventLevel::Error => "error", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EventLog { - pub id: String, - pub kind: String, - pub level: EventLevel, - pub message: String, - pub created_at: String, -} - -impl EventLog { - pub fn new(kind: impl Into<String>, level: EventLevel, message: impl Into<String>) -> Self { - Self { - id: Uuid::new_v4().to_string(), - kind: kind.into(), - level, - message: message.into(), - created_at: Utc::now().to_rfc3339(), - } - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/repository.rs`: - -```rust -use sqlx::{Row, SqlitePool}; - -use crate::{ - logs::event::{EventLevel, EventLog}, - pagination::{clamp_limit, decode_cursor, encode_cursor, Page}, -}; - -#[derive(Clone)] -pub struct EventLogRepository { - pool: SqlitePool, -} - -impl EventLogRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - pub async fn insert(&self, event: EventLog) -> Result<(), sqlx::Error> { - sqlx::query( - "insert into event_logs (id, kind, level, message, metadata_json, created_at) values (?, ?, ?, ?, '{}', ?)", - ) - .bind(event.id) - .bind(event.kind) - .bind(event.level.as_str()) - .bind(event.message) - .bind(event.created_at) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn list(&self, cursor: Option<String>, limit: u32) -> Result<Page<EventLog>, sqlx::Error> { - let limit = clamp_limit(limit); - let mut rows = if let Some(cursor) = cursor.and_then(|c| decode_cursor(&c)) { - sqlx::query( - "select id, kind, level, message, created_at from event_logs where (created_at < ? or (created_at = ? and id < ?)) order by created_at desc, id desc limit ?", - ) - .bind(&cursor.0) - .bind(&cursor.0) - .bind(&cursor.1) - .bind((limit + 1) as i64) - .fetch_all(&self.pool) - .await? - } else { - sqlx::query("select id, kind, level, message, created_at from event_logs order by created_at desc, id desc limit ?") - .bind((limit + 1) as i64) - .fetch_all(&self.pool) - .await? - }; - - let has_next = rows.len() > limit as usize; - if has_next { - rows.truncate(limit as usize); - } - let items = rows - .into_iter() - .map(|row| EventLog { - id: row.get("id"), - kind: row.get("kind"), - level: match row.get::<String, _>("level").as_str() { - "debug" => EventLevel::Debug, - "warn" => EventLevel::Warn, - "error" => EventLevel::Error, - _ => EventLevel::Info, - }, - message: row.get("message"), - created_at: row.get("created_at"), - }) - .collect::<Vec<_>>(); - let next_cursor = if has_next { - items.last().map(|e| encode_cursor(&e.created_at, &e.id)) - } else { - None - }; - Ok(Page { items, next_cursor }) - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/logs/rotation.rs`: - -```rust -use std::path::{Path, PathBuf}; - -#[derive(Debug, Clone)] -pub struct RotationConfig { - pub directory: PathBuf, - pub max_file_bytes: u64, - pub retention_days: u64, -} - -impl RotationConfig { - pub fn new(directory: impl AsRef<Path>, max_file_bytes: u64, retention_days: u64) -> Self { - Self { - directory: directory.as_ref().to_path_buf(), - max_file_bytes, - retention_days, - } - } -} -``` - -- [x] **Step 4: Verify event pagination** - -Run: - -```bash -cargo test event_logs_are_cursor_paginated -``` - -Expected: pass. - -- [x] **Step 5: Commit logging foundation** - -```bash -git add src/logs src/pagination.rs src/lib.rs tests/logs_pagination_test.rs -git commit -m "feat: add paginated event logs" -``` - ---- - -### Task 6: Codex Fingerprint, Header Generation, and TLS Client - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/model.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/headers.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/client.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs` - -- [x] **Step 1: Write failing header test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs`: - -```rust -use codex_proxy_rs::{ - codex::transport::headers::build_codex_headers, - codex::fingerprint::model::Fingerprint, -}; - -#[test] -fn codex_headers_include_desktop_identity_and_turn_state() { - let fp = Fingerprint::default_for_tests(); - let headers = build_codex_headers(&fp, "access-token", Some("acct_123"), Some("turn-state"), "rid_1"); - - assert_eq!(headers.get("originator").unwrap(), "Codex Desktop"); - assert!(headers.get("user-agent").unwrap().contains("Codex")); - assert_eq!(headers.get("authorization").unwrap(), "Bearer access-token"); - assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123"); - assert_eq!(headers.get("x-codex-turn-state").unwrap(), "turn-state"); - assert_eq!(headers.get("x-client-request-id").unwrap(), "rid_1"); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test codex_headers_include_desktop_identity_and_turn_state -``` - -Expected: compile failure because `codex` and `fingerprint` modules do not exist. - -- [x] **Step 3: Implement fingerprint and headers** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod auth; -pub mod codex; -pub mod config; -pub mod crypto; -pub mod error; - -pub mod logs; -pub mod pagination; -pub mod state; -pub mod storage; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/mod.rs`: - -```rust -pub mod model; -pub mod updater; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/model.rs`: - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Fingerprint { - pub originator: String, - pub app_version: String, - pub build_number: String, - pub platform: String, - pub arch: String, - pub chromium_version: String, - pub user_agent_template: String, -} - -impl Fingerprint { - pub fn default_for_tests() -> Self { - Self { - originator: "Codex Desktop".to_string(), - app_version: "26.519.81530".to_string(), - build_number: "3178".to_string(), - platform: "darwin".to_string(), - arch: "arm64".to_string(), - chromium_version: "146".to_string(), - user_agent_template: "Codex/{app_version} ({platform}; {arch}) Chromium/{chromium_version}".to_string(), - } - } - - pub fn user_agent(&self) -> String { - self.user_agent_template - .replace("{app_version}", &self.app_version) - .replace("{platform}", &self.platform) - .replace("{arch}", &self.arch) - .replace("{chromium_version}", &self.chromium_version) - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/mod.rs`: - -```rust -pub mod client; -pub mod headers; -pub mod sse; -pub mod types; -pub mod usage; -pub mod websocket; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/headers.rs`: - -```rust -use std::collections::BTreeMap; - -use crate::codex::fingerprint::model::Fingerprint; - -pub fn build_codex_headers( - fp: &Fingerprint, - access_token: &str, - account_id: Option<&str>, - turn_state: Option<&str>, - request_id: &str, -) -> BTreeMap<String, String> { - let mut headers = BTreeMap::new(); - headers.insert("accept".to_string(), "text/event-stream".to_string()); - headers.insert("authorization".to_string(), format!("Bearer {access_token}")); - headers.insert("originator".to_string(), fp.originator.clone()); - headers.insert("user-agent".to_string(), fp.user_agent()); - headers.insert("x-client-request-id".to_string(), request_id.to_string()); - headers.insert("x-openai-internal-codex-residency".to_string(), "global".to_string()); - if let Some(account_id) = account_id { - headers.insert("chatgpt-account-id".to_string(), account_id.to_string()); - } - if let Some(turn_state) = turn_state { - headers.insert("x-codex-turn-state".to_string(), turn_state.to_string()); - } - headers -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/client.rs`: - -```rust -use reqwest::Client; - -pub fn build_reqwest_client(force_http11: bool) -> Result<Client, reqwest::Error> { - let builder = Client::builder() - .use_rustls_tls() - .no_proxy() - .gzip(true) - .brotli(true) - .zstd(true) - .deflate(true); - let builder = if force_http11 { builder.http1_only() } else { builder }; - builder.build() -} -``` - -Create empty module files: - -```bash -touch src/codex/fingerprint/updater.rs src/codex/sse.rs src/codex/types.rs src/codex/usage.rs src/codex/websocket.rs -``` - -- [x] **Step 4: Verify headers** - -Run: - -```bash -cargo test codex_headers_include_desktop_identity_and_turn_state -cargo tree | rg 'reqwest|rustls' -``` - -Expected: test passes; `cargo tree` shows `reqwest v0.12.28` and `rustls v0.23.36`. - -- [x] **Step 5: Commit TLS and headers foundation** - -```bash -git add Cargo.toml src/codex src/codex/fingerprint src/lib.rs tests/codex_headers_test.rs -git commit -m "feat: add codex tls client and headers" -``` - ---- - -### Task 7: Account Cookie Persistence - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/jar.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/repository.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/cookie_store_test.rs` - -- [x] **Step 1: Write failing cookie replay test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/cookie_store_test.rs`: - -```rust -use codex_proxy_rs::codex::cookies::jar::CookieJar; - -#[test] -fn cookie_jar_captures_and_replays_account_scoped_cookies() { - let mut jar = CookieJar::default(); - jar.capture_set_cookie("acct_a", "cf_clearance=abc; Domain=chatgpt.com; Path=/; HttpOnly"); - jar.capture_set_cookie("acct_b", "cf_clearance=def; Domain=chatgpt.com; Path=/; HttpOnly"); - - assert_eq!(jar.cookie_header("acct_a", "chatgpt.com"), Some("cf_clearance=abc".to_string())); - assert_eq!(jar.cookie_header("acct_b", "chatgpt.com"), Some("cf_clearance=def".to_string())); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test cookie_jar_captures_and_replays_account_scoped_cookies -``` - -Expected: compile failure because cookies module does not exist. - -- [x] **Step 3: Implement in-memory cookie jar** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust -pub mod auth; -pub mod codex; -pub mod config; -pub mod cookies; -pub mod crypto; -pub mod error; - -pub mod logs; -pub mod pagination; -pub mod state; -pub mod storage; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/mod.rs`: - -```rust -pub mod jar; -pub mod repository; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/jar.rs`: - -```rust -use std::collections::HashMap; - -#[derive(Debug, Clone)] -struct StoredCookie { - domain: String, - name: String, - value: String, -} - -#[derive(Debug, Default, Clone)] -pub struct CookieJar { - by_account: HashMap<String, Vec<StoredCookie>>, -} - -impl CookieJar { - pub fn capture_set_cookie(&mut self, account_id: &str, raw: &str) { - let mut parts = raw.split(';').map(str::trim); - let Some(name_value) = parts.next() else { return }; - let Some((name, value)) = name_value.split_once('=') else { return }; - let mut domain = "chatgpt.com".to_string(); - for part in parts { - if let Some(value) = part.strip_prefix("Domain=") { - domain = value.trim_start_matches('.').to_string(); - } - } - let account = self.by_account.entry(account_id.to_string()).or_default(); - account.retain(|cookie| !(cookie.domain == domain && cookie.name == name)); - account.push(StoredCookie { - domain, - name: name.to_string(), - value: value.to_string(), - }); - } - - pub fn cookie_header(&self, account_id: &str, domain: &str) -> Option<String> { - let cookies = self.by_account.get(account_id)?; - let pairs = cookies - .iter() - .filter(|cookie| domain == cookie.domain || domain.ends_with(&format!(".{}", cookie.domain))) - .map(|cookie| format!("{}={}", cookie.name, cookie.value)) - .collect::<Vec<_>>(); - if pairs.is_empty() { None } else { Some(pairs.join("; ")) } - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/cookies/repository.rs`: - -```rust -#[derive(Debug, Clone)] -pub struct CookieRepository; -``` - -- [x] **Step 4: Verify cookie capture** - -Run: - -```bash -cargo test cookie_jar_captures_and_replays_account_scoped_cookies -``` - -Expected: pass. - -- [x] **Step 5: Commit cookies** - -```bash -git add src/codex/cookies src/lib.rs tests/cookie_store_test.rs -git commit -m "feat: add account scoped cookie jar" -``` - ---- - -### Task 8: Account Model, Pool, and Refresh Scheduler Compatibility - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/model.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/pool.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/repository.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/lifecycle.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/refresh.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/token.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/oauth.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/refresh_scheduler_test.rs` - -- [x] **Step 1: Write failing account acquisition test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/refresh_scheduler_test.rs`: - -```rust -use codex_proxy_rs::codex::accounts::{ - model::{Account, AccountStatus}, - pool::AccountPool, -}; - -#[test] -fn account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts() { - let mut pool = AccountPool::default(); - pool.insert(Account::test("active", AccountStatus::Active)); - pool.insert(Account::test("expired", AccountStatus::Expired)); - pool.insert(Account::test("disabled", AccountStatus::Disabled)); - pool.insert(Account::test("banned", AccountStatus::Banned)); - pool.insert(Account::test("quota", AccountStatus::QuotaExhausted)); - - let acquired = pool.acquire("gpt-5.5").unwrap(); - assert_eq!(acquired.id, "active"); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts -``` - -Expected: compile failure because accounts module does not exist. - -- [x] **Step 3: Implement account status and pool** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust - -pub mod auth; -pub mod codex; -pub mod config; -pub mod cookies; -pub mod crypto; -pub mod error; - -pub mod logs; -pub mod pagination; -pub mod state; -pub mod storage; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/mod.rs`: - -```rust -pub mod lifecycle; -pub mod model; -pub mod pool; -pub mod repository; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/model.rs`: - -```rust -use chrono::Utc; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum AccountStatus { - Active, - Expired, - QuotaExhausted, - Refreshing, - Disabled, - Banned, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Account { - pub id: String, - pub email: Option<String>, - pub account_id: Option<String>, - pub user_id: Option<String>, - pub label: Option<String>, - pub plan_type: Option<String>, - pub access_token: String, - pub refresh_token: Option<String>, - pub status: AccountStatus, - pub added_at: String, - pub last_used_at: Option<String>, -} - -impl Account { - pub fn test(id: &str, status: AccountStatus) -> Self { - Self { - id: id.to_string(), - email: None, - account_id: None, - user_id: None, - label: None, - plan_type: None, - access_token: format!("token-{id}"), - refresh_token: Some(format!("refresh-{id}")), - status, - added_at: Utc::now().to_rfc3339(), - last_used_at: None, - } - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/pool.rs`: - -```rust -use std::collections::BTreeMap; - -use crate::codex::accounts::model::{Account, AccountStatus}; - -#[derive(Debug, Default)] -pub struct AccountPool { - accounts: BTreeMap<String, Account>, -} - -impl AccountPool { - pub fn insert(&mut self, account: Account) { - self.accounts.insert(account.id.clone(), account); - } - - pub fn acquire(&self, _model: &str) -> Option<Account> { - self.accounts - .values() - .filter(|account| account.status == AccountStatus::Active) - .min_by_key(|account| account.last_used_at.clone()) - .cloned() - } -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/repository.rs`: - -```rust -#[derive(Debug, Clone)] -pub struct AccountRepository; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/accounts/lifecycle.rs`: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AccountLifecycleEvent { - Added, - Refreshed, - Expired, - QuotaExhausted, - Banned, - Disabled, -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/refresh.rs`: - -```rust -#[derive(Debug, Clone, Copy)] -pub struct RefreshPolicy { - pub refresh_margin_seconds: u64, - pub refresh_concurrency: u32, -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/token.rs`: - -```rust -#[derive(Debug, Clone)] -pub struct TokenPair { - pub access_token: String, - pub refresh_token: Option<String>, -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/oauth/oauth.rs`: - -```rust -#[derive(Debug, Clone)] -pub struct OAuthConfig { - pub client_id: String, - pub auth_endpoint: String, - pub token_endpoint: String, -} -``` - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/auth/mod.rs`: - -```rust -pub mod admin_session; -pub mod api_key; -pub mod oauth; -pub mod refresh; -pub mod token; -``` - -- [x] **Step 4: Verify account pool behavior** - -Run: - -```bash -cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts -``` - -Expected: pass. - -- [x] **Step 5: Commit account foundation** - -```bash -git add src/codex/accounts src/auth src/lib.rs tests/refresh_scheduler_test.rs -git commit -m "feat: add account pool foundation" -``` - ---- - -### Task 9: Translation Layer for OpenAI Chat and Codex Responses - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/schema.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/openai_to_codex.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/codex_to_openai.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/types.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_chat_test.rs` - -- [x] **Step 1: Write failing translation test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_chat_test.rs`: - -```rust -use codex_proxy_rs::codex::protocol::openai_to_codex::{translate_chat_to_codex, ChatCompletionRequest, ChatMessage}; - -#[test] -fn chat_completion_translates_to_codex_response_request() { - let req = ChatCompletionRequest { - model: "gpt-5.5".to_string(), - stream: true, - messages: vec![ChatMessage { - role: "user".to_string(), - content: "hello".to_string(), - }], - }; - - let codex = translate_chat_to_codex(req).unwrap(); - assert_eq!(codex.model, "gpt-5.5"); - assert!(codex.stream); - assert_eq!(codex.input[0]["role"], "user"); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test chat_completion_translates_to_codex_response_request -``` - -Expected: compile failure because translation module does not exist. - -- [x] **Step 3: Implement minimal translation types** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust - -pub mod auth; -pub mod codex; -pub mod config; -pub mod cookies; -pub mod crypto; -pub mod error; - -pub mod logs; -pub mod pagination; -pub mod state; -pub mod storage; - -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/mod.rs`: - -```rust -pub mod codex_to_openai; -pub mod openai_to_codex; -pub mod schema; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/types.rs`: - -```rust -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodexResponsesRequest { - pub model: String, - pub instructions: String, - pub input: Vec<Value>, - pub stream: bool, - pub store: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning: Option<Value>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option<Vec<Value>>, -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/openai_to_codex.rs`: - -```rust -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::{codex::transport::types::CodexResponsesRequest, error::{AppError, AppResult}}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatCompletionRequest { - pub model: String, - #[serde(default)] - pub stream: bool, - pub messages: Vec<ChatMessage>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMessage { - pub role: String, - pub content: String, -} - -pub fn translate_chat_to_codex(req: ChatCompletionRequest) -> AppResult<CodexResponsesRequest> { - if req.messages.is_empty() { - return Err(AppError::BadRequest("messages must not be empty".to_string())); - } - let input = req.messages - .into_iter() - .map(|message| json!({ "role": message.role, "content": message.content })) - .collect(); - Ok(CodexResponsesRequest { - model: req.model, - instructions: String::new(), - input, - stream: req.stream, - store: false, - reasoning: None, - tools: None, - }) -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/codex_to_openai.rs`: - -```rust -use serde_json::{json, Value}; - -pub fn openai_error(message: &str, code: &str) -> Value { - json!({ - "error": { - "message": message, - "type": "server_error", - "param": null, - "code": code - } - }) -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/protocol/schema.rs`: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ResponseFormat { - Text, - JsonObject, - JsonSchema, -} -``` - -- [x] **Step 4: Verify translation** - -Run: - -```bash -cargo test chat_completion_translates_to_codex_response_request -``` - -Expected: pass. - -- [x] **Step 5: Commit translation layer** - -```bash -git add src/codex/protocol src/codex/types.rs src/lib.rs tests/routes_chat_test.rs -git commit -m "feat: add openai to codex translation" -``` - ---- - -### Task 10: HTTP Router and Auth Gates - -**Files:** -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/app.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/mod.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/middleware.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/admin.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/auth.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/v1.rs` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/health.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/main.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_responses_test.rs` - -- [x] **Step 1: Write failing route boundary test** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_responses_test.rs`: - -```rust -use axum::{body::Body, http::{Request, StatusCode}}; -use tower::ServiceExt; - -use codex_proxy_rs::{app::build_router, config::{AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, SecurityConfig, ServerConfig, TlsConfig}, state::AppState}; - -fn test_config() -> AppConfig { - AppConfig { - server: ServerConfig { host: "127.0.0.1".to_string(), port: 0 }, - api: ApiConfig { base_url: "https://chatgpt.com/backend-api".to_string() }, - auth: AuthConfig { refresh_margin_seconds: 300, refresh_enabled: true, refresh_concurrency: 2 }, - database: DatabaseConfig { url: "sqlite://:memory:".to_string() }, - security: SecurityConfig { - master_key_file: "data/master.key".to_string(), - api_key_pepper_file: "data/api-key-pepper.key".to_string(), - }, - tls: TlsConfig { force_http11: false }, - admin: AdminConfig { session_ttl_minutes: 1440 }, - logging: LoggingConfig { directory: "logs".to_string(), max_file_bytes: 10_485_760, retention_days: 14 }, - } -} - -#[tokio::test] -async fn v1_requires_client_api_key_not_admin_cookie() { - let app = build_router(AppState::new(test_config())); - let response = app - .oneshot(Request::builder().method("POST").uri("/v1/responses").body(Body::from("{}")).unwrap()) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test v1_requires_client_api_key_not_admin_cookie -``` - -Expected: compile failure because app/http modules do not exist. - -- [x] **Step 3: Implement minimal router** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/lib.rs`: - -```rust - -pub mod app; -pub mod auth; -pub mod codex; -pub mod config; -pub mod cookies; -pub mod crypto; -pub mod error; - -pub mod http; -pub mod logs; -pub mod pagination; -pub mod state; -pub mod storage; - -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/app.rs`: - -```rust -use axum::{routing::{get, post}, Router}; - -use crate::{http::{health::health, v1::responses}, state::AppState}; - -pub fn build_router(state: AppState) -> Router { - Router::new() - .route("/health", get(health)) - .route("/v1/responses", post(responses)) - .route("/v1/chat/completions", post(responses)) - .with_state(state) -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/mod.rs`: - -```rust -pub mod admin; -pub mod auth; -pub mod health; -pub mod middleware; -pub mod v1; -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/health.rs`: - -```rust -use axum::Json; -use serde_json::{json, Value}; - -pub async fn health() -> Json<Value> { - Json(json!({ "status": "ok" })) -} -``` - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/v1.rs`: - -```rust -use axum::{http::StatusCode, response::IntoResponse, Json}; - -use crate::codex::protocol::codex_to_openai::openai_error; - -pub async fn responses() -> impl IntoResponse { - ( - StatusCode::UNAUTHORIZED, - Json(openai_error("Missing client API key", "invalid_api_key")), - ) -} -``` - -Create empty route module files: - -```bash -touch src/http/admin.rs src/http/auth.rs src/http/middleware.rs -``` - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/main.rs`: - -```rust -use codex_proxy_rs::{app::build_router, config::{AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, SecurityConfig, ServerConfig, TlsConfig}, state::AppState}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let config = AppConfig { - server: ServerConfig { host: "127.0.0.1".to_string(), port: 8080 }, - api: ApiConfig { base_url: "https://chatgpt.com/backend-api".to_string() }, - auth: AuthConfig { refresh_margin_seconds: 300, refresh_enabled: true, refresh_concurrency: 2 }, - database: DatabaseConfig { url: "sqlite://data/codex-proxy-rs.sqlite".to_string() }, - security: SecurityConfig { - master_key_file: "data/master.key".to_string(), - api_key_pepper_file: "data/api-key-pepper.key".to_string(), - }, - tls: TlsConfig { force_http11: false }, - admin: AdminConfig { session_ttl_minutes: 1440 }, - logging: LoggingConfig { directory: "logs".to_string(), max_file_bytes: 10_485_760, retention_days: 14 }, - }; - let app = build_router(AppState::new(config.clone())); - let listener = tokio::net::TcpListener::bind((config.server.host.as_str(), config.server.port)).await?; - axum::serve(listener, app).await?; - Ok(()) -} -``` - -- [x] **Step 4: Verify route boundary** - -Run: - -```bash -cargo test v1_requires_client_api_key_not_admin_cookie -``` - -Expected: pass. - -- [x] **Step 5: Commit router** - -```bash -git add src/app.rs src/http src/main.rs src/lib.rs tests/routes_responses_test.rs -git commit -m "feat: add http router auth boundaries" -``` - ---- - -### Task 11: Codex Upstream Request Lifecycle - -**Files:** -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/client.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/sse.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/v1.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/state.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_chat_test.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_responses_test.rs` - -- [x] **Step 1: Add failing upstream mock test** - -Append to `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_responses_test.rs`: - -```rust -#[tokio::test] -async fn responses_route_rejects_unknown_models() { - let app = build_router(AppState::new(test_config())); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/responses") - .header("authorization", "Bearer cpr_test") - .header("content-type", "application/json") - .body(Body::from(r#"{"model":"unknown-model","input":[]}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test responses_route_rejects_unknown_models -``` - -Expected: fails with `401 Unauthorized`, because client API key validation and model validation are not implemented. - -- [x] **Step 3: Implement model allow rule** - -Modify `/home/zyy/桌面/Codes/codex-proxy-rs/src/http/v1.rs`: - -```rust -use axum::{http::{HeaderMap, StatusCode}, response::IntoResponse, Json}; -use serde::Deserialize; - -use crate::codex::protocol::codex_to_openai::openai_error; - -#[derive(Deserialize)] -struct ResponsesBody { - model: Option<String>, -} - -pub async fn responses(headers: HeaderMap, Json(body): Json<ResponsesBody>) -> impl IntoResponse { - let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or_default(); - if !auth.starts_with("Bearer cpr_") { - return ( - StatusCode::UNAUTHORIZED, - Json(openai_error("Missing client API key", "invalid_api_key")), - ); - } - let model = body.model.unwrap_or_else(|| "gpt-5.5".to_string()); - if !(model.starts_with("gpt") || model.starts_with("codex") || model.starts_with("o")) { - return ( - StatusCode::NOT_FOUND, - Json(openai_error("Model not found", "model_not_found")), - ); - } - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(openai_error("No available Codex accounts", "no_available_accounts")), - ) -} -``` - -- [x] **Step 4: Verify model rejection** - -Run: - -```bash -cargo test responses_route_rejects_unknown_models -``` - -Expected: pass. - -- [x] **Step 5: Commit upstream lifecycle boundary** - -```bash -git add src/codex src/http/v1.rs src/state.rs tests/routes_chat_test.rs tests/routes_responses_test.rs -git commit -m "feat: enforce codex-only upstream routing" -``` - ---- - -### Task 12: Fingerprint Auto-Update - -**Files:** -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/updater.rs` -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/model.rs` -- Test: `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs` - -- [x] **Step 1: Add failing updater parse test** - -Append to `/home/zyy/桌面/Codes/codex-proxy-rs/tests/codex_headers_test.rs`: - -```rust -use codex_proxy_rs::codex::fingerprint::updater::parse_update_manifest; - -#[test] -fn update_manifest_updates_app_version_and_build_number() { - let manifest = r#"{"version":"26.600.12345","build_number":"4001"}"#; - let update = parse_update_manifest(manifest).unwrap(); - assert_eq!(update.app_version, "26.600.12345"); - assert_eq!(update.build_number, "4001"); -} -``` - -- [x] **Step 2: Run test and verify RED** - -Run: - -```bash -cargo test update_manifest_updates_app_version_and_build_number -``` - -Expected: compile failure because `parse_update_manifest` does not exist. - -- [x] **Step 3: Implement updater parser** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/src/codex/fingerprint/updater.rs`: - -```rust -use serde::Deserialize; -use thiserror::Error; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FingerprintUpdate { - pub app_version: String, - pub build_number: String, -} - -#[derive(Debug, Error)] -pub enum FingerprintError { - #[error("invalid update manifest: {0}")] - InvalidManifest(#[from] serde_json::Error), -} - -#[derive(Deserialize)] -struct Manifest { - version: String, - build_number: String, -} - -pub fn parse_update_manifest(input: &str) -> Result<FingerprintUpdate, FingerprintError> { - let manifest: Manifest = serde_json::from_str(input)?; - Ok(FingerprintUpdate { - app_version: manifest.version, - build_number: manifest.build_number, - }) -} -``` - -- [x] **Step 4: Verify updater parser** - -Run: - -```bash -cargo test update_manifest_updates_app_version_and_build_number -``` - -Expected: pass. - -- [x] **Step 5: Commit fingerprint updater parser** - -```bash -git add src/codex/fingerprint/updater.rs tests/codex_headers_test.rs -git commit -m "feat: parse codex desktop fingerprint updates" -``` - ---- - -### Task 13: Documentation and Runtime Verification - -**Files:** -- Modify: `/home/zyy/桌面/Codes/codex-proxy-rs/README.md` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/Dockerfile` -- Create: `/home/zyy/桌面/Codes/codex-proxy-rs/docker-compose.yml` - -- [x] **Step 1: Write README with exact scope** - -Create `/home/zyy/桌面/Codes/codex-proxy-rs/README.md`: - -```markdown -# codex-proxy-rs - -Rust rewrite of Codex Proxy focused only on ChatGPT/Codex accounts and the Codex backend. - -## Included - -- `/v1/chat/completions` -- `/v1/responses` -- `/v1/models` -- ChatGPT/Codex OAuth and refresh-token based account pool -- Codex Desktop-style TLS, headers, Cookies, and fingerprint updates -- SQLite storage with sqlx-managed schema -- Structured logs with pagination and file rotation - -## Excluded - -- OpenAI official API Key upstream -- non-OpenAI/Codex provider routing -- non-OpenAI local model bridge -- Per-account proxy assignment -- Electron app -- Legacy compatibility layers and old-project data migration scripts - -## Auth Model - -- Admin login uses admin password and HttpOnly session Cookie. -- Client API keys call `/v1/*` and start with `cpr_`. -- Codex account tokens are internal upstream credentials. -``` - -- [x] **Step 2: Document local YAML overrides** - -Document that runtime config loads from root `config.yaml`, then optional root -`local.yaml` or `local.yml`. Local overrides stay untracked and replace the old -environment-variable override path. - - -- [x] **Step 3: Verify full test suite and lint** - -Run: - -```bash -cargo fmt --check -cargo clippy --all-targets --all-features --locked -- -D warnings -cargo test -``` - -Expected: all commands pass. - -- [x] **Step 4: Run local server smoke test** - -Run: - -```bash -cargo run -curl -s http://127.0.0.1:8080/health -``` - -Expected response: - -```json -{"status":"ok"} -``` - -- [x] **Step 5: Commit docs and runtime files** - -```bash -git add README.md config.yaml Dockerfile docker-compose.yml -git commit -m "docs: document codex-proxy-rs scope" -``` - ---- - -## Audit Follow-ups - -- [x] Add `POST /admin/login` so admin login is a password-backed session flow, not a client API key flow. The route issues an HttpOnly `cpr_admin_session` cookie, returns the admin envelope with lower camelCase `expiresAt`, and rejects `Bearer cpr_...` as admin credentials. -- [x] Replace the refresh policy shell with a native refresh scheduler. It refreshes before expiry, refreshes immediately after an upstream 401 trigger, preserves the existing refresh token when the refresh response omits one, maps refresh failures to account statuses, and enforces configured concurrency with a semaphore. -- [x] Fill the previously empty `src/codex/sse.rs` and `src/codex/usage.rs` modules with tested SSE frame parsing/encoding and token usage extraction helpers. -- [x] Add a tested SQLite account repository foundation. It encrypts access/refresh tokens, decrypts account reads, cursor-pages accounts, updates status/labels without rewriting token ciphertext, persists usage counters, and preserves the existing refresh token when token refresh responses omit a new one. -- [x] Add a real HTTP auth boundary. `src/http/auth.rs` parses client API keys and admin session cookies as separate credential types, `/v1/*` uses only `Bearer cpr_...`, and admin session parsing ignores client API keys. -- [x] Add a tested Codex WebSocket boundary. `src/codex/transport/websocket.rs` classifies HTTP SSE versus WebSocket-only requests, adds `previous_response_id`/`use_websocket` request fields, and returns typed errors instead of silently downgrading WebSocket-required traffic. -- [x] Extend account scheduling primitives against the original TypeScript lifecycle. `AccountPool` now covers max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, model-plan filtering, cached quota skip, exclude IDs, preferred account selection for session affinity, Cloudflare cooldown skip, request-staggering metadata, and capacity summary. -- [x] Set `gpt-5.5` as the default/test model baseline across code, tests, and plan examples. `/v1/models` now asserts the exact default model instead of only checking for a `gpt` prefix. -- [x] Add `/admin/settings` for the retained Rust settings surface. GET and PATCH are admin-session gated, lower camelCase, exclude proxy URL, non-OpenAI local model bridge and third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields, and PATCH writes retained config to root `local.yaml`. -- [x] Add a native static model catalog foundation. It exposes `gpt-5.5`, configured aliases, reasoning/service tier suffix parsing, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, and `/debug/models`. Backend model fetching/cache and `/admin/refresh-models` remain separate tasks. -- [x] Add encrypted admin account import/list foundations. `/admin/accounts/import` stores access/refresh tokens through `SecretBox` and `/admin/accounts` returns paginated sanitized account metadata without tokens or token decryption. The runtime now loads or creates the master key file before constructing repository-backed routes. -- [x] Add the first native Codex upstream lifecycle slice. Imported accounts are synchronized into the process account pool, `/v1/responses` acquires an account, sends Codex Desktop HTTP SSE requests with encrypted Cookie replay/capture, collects `response.completed`, records usage, and releases the slot. Startup pool snapshots, retry/fallback, 401 refresh, rate-limit capture, and WebSocket remain separate tasks. -- [x] Restore the process account pool from SQLite on startup. The snapshot decrypts account tokens through `SecretBox`, carries usage `last_used_at` into scheduling, and lets previously imported accounts survive service restarts. -- [x] Split local client API key auth from admin login. `/admin/api-keys` can create/list local `cpr_` keys, plaintext is returned only once, list responses never expose plaintext/hash, and `/v1/*` verifies enabled keys from SQLite instead of accepting any `cpr_` string. -- [x] Add tested `/v1/responses` streaming passthrough and lifecycle event logs. Client `stream:true` returns `text/event-stream`, keeps the account slot until stream completion or disconnect, parses usage after SSE completion, writes account usage, and records non-secret `v1.response` logs with request/account/route/model/status/latency metadata. Non-streaming calls now write the same lifecycle log after `response.completed`. -- [x] Add tested admin usage-stat read APIs. `/admin/usage-stats` returns cursor-paginated account usage counters, `/admin/usage-stats/summary` returns global totals, and both require the admin session cookie while exposing only non-secret account metadata. -- [x] Add tested upstream-401 refresh failure event logs. When refresh fails, the service records a non-secret `account.refresh` event with request/account/model context, failure classification, and resulting account status. -- [x] Add tested non-streaming output reconstruction. When Codex SSE carries assistant output in `response.output_item.done` or `response.output_text.delta` events but `response.completed.output` is empty, `/v1/responses` now returns a standard assistant `output` message and synchronizes `output_text` for non-streaming clients. -- [x] Add real OpenAI OAuth refresh-token exchange and `/v1/responses` 401 retry wiring. The Rust service refreshes access tokens through the OAuth token endpoint, preserves the existing refresh token when the server omits a replacement, persists the rotated access token, updates the in-process account pool, and retries the failed non-streaming or streaming request once. - -## 2026-06-11 Original Project Gap Audit - -The Rust project has no legacy compatibility requirement, but the in-scope behavior must still be rebuilt natively. This audit compares the TypeScript project under `/home/zyy/Codes/codex-proxy` with the Rust rewrite and corrects earlier over-broad completion claims. - -### Keep and Rebuild Natively - -- [x] **HTTP auth boundary:** restore a real `src/http/auth.rs` module instead of scattered header checks. It validates `Bearer cpr_...` client API keys for `/v1/*`, validates admin session cookies for `/admin/*`, rejects cross-use in both directions, and returns the documented OpenAI/admin response families. -- [x] **Account SQLite repository:** implement `src/codex/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, batch-safe mutations, metadata-only auth status reads, and account-wide delete for logout. Do not add TypeScript JSON migration or legacy import shims. -- [x] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted native import/export, manual token add, CLI import, paginated sanitized list, removed external legacy/proxy-format compatibility, label/status/delete, batch delete/status, Cookie CRUD, health/probe, refresh, quota, quota warnings, reset usage, admin auth status, and admin logout are complete. -- [x] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. CLI auth import, validated manual token import, device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated auth status, and logout are complete. Keep this separate from admin password login and client API key creation. -- [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access. -- [x] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. GET and PATCH `/admin/settings` are admin-session gated, PATCH validates retained fields, writes a root `local.yaml` overlay, and rejects proxy URL, non-OpenAI local model bridge and third-party provider, OpenAI official key, proxy API key, and Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task. -- [x] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. `/admin/diagnostics` returns the non-secret runtime summary behind admin-session auth. Local-only `/debug/diagnostics` returns runtime metadata, paths, account repository/pool state, capacity summary, transport config, and static fingerprint status without secrets. Local-only `/debug/fingerprint` returns the runtime Desktop fingerprint summary. Local-only `/debug/upstream` probes the Codex models endpoint with runtime TLS/fingerprint headers and returns only status metadata. Keep production-local gating for sensitive debug endpoints. -- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and richer bounded history queries if needed. `GET/PATCH /admin/logs/state`, `/admin/logs/{id}`, `DELETE /admin/logs`, `/admin/logs` kind/level/request/account/route/model/status/search filters, log file rotation, SQLite event pagination, `/admin/usage-stats` pagination, and `/admin/usage-stats/summary` are complete. Remaining: decide whether to retain old error-log grouping/raw/count/seen/report APIs and richer bounded body-history queries. -- [x] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`. Static defaults, aliases, suffix parsing, cached backend snapshots, plan allowlists, admin refresh, read-only model routes, and debug summary are complete. -- [ ] **Account scheduling parity:** extend account acquisition beyond `active + least last_used`. Completed in the in-memory pool: max concurrent slots per account, stale slot cleanup, least-used/round-robin/sticky strategies, quota-cache skip, tier priority, model-plan filtering, exclude IDs, preferred account from session affinity, Cloudflare cooldown skip, request-staggering metadata, capacity summary, settings-backed construction, import-to-pool sync, startup pool snapshots from SQLite, durable quota/Cloudflare cooldown restore, HTTP fallback acquisition across alternate accounts, WebSocket fallback for non-history requests, previous-response account affinity, and runtime pool clearing on logout. Remaining: durable release-time slot state. -- [ ] **Upstream Codex lifecycle:** implement account acquire -> Codex request -> SSE/collect-or-stream -> rate-limit capture -> usage release -> retry/fallback. HTTP SSE collection, streaming passthrough, 401-triggered refresh retry, Codex request context fields, SSE `error`/`response.failed` mapping, 429/402/403 account-state classification, durable 429 quota cooldown persistence, Cloudflare challenge Cookie clearing, successful WebSocket rate-limit capture, and fallback across alternate accounts for non-streaming Responses, HTTP SSE setup, non-history WebSocket setup, and Chat Completions are complete. Remaining: richer OpenAI-compatible error mapping. -- [ ] **Session affinity and previous response:** persist an in-memory response-id to account mapping with TTL, prompt-cache/conversation identity, turn-state replay, and preferred account selection. `src/codex/transport/websocket.rs` implements the WebSocket `response.create` path for `previous_response_id`, and classified WebSocket failures for `previous_response_id` stay account-affine for streaming and non-streaming clients. Response-id TTL cleanup and broader affinity persistence still need to be finished. -- [ ] **Fingerprint auto-update parity:** poll the real Codex Desktop appcast/update source, parse app version/build number, persist update state, select the latest stored fingerprint for runtime headers, and optionally extract Chromium version from a local Codex.app path when configured. - -## 2026-06-12 Detailed Original Coverage Audit - -This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/codex-proxy` and compared them with the Rust implementation. Deleted non-OpenAI/proxy/Electron features stay deleted; the items below are in-scope Codex/OpenAI-compatible behavior that is not yet fully rebuilt. - -- [x] **OpenAI Chat Completions route wiring:** Rust mounts `/v1/chat/completions` to a dedicated Chat handler that parses Chat `messages`, translates to Codex Responses requests, and returns OpenAI Chat completion JSON or SSE chunks. -- [ ] **Responses request semantics:** Rust now retains `service_tier` normalization (`fast` -> `priority` upstream), `prompt_cache_key`, `client_metadata`, `include`, `text.format`, `tool_choice`, `parallel_tool_calls`, Codex context headers, session/window/thread metadata, and reasoning include defaults. Remaining parity gaps include tuple-schema reconversion edge cases, sanitized reasoning replay behavior, and real-client verification for installation/fingerprint metadata. -- [ ] **Responses stream/error parity:** Rust reconstructs non-stream output from `response.output_item.done` and `response.output_text.delta`, maps non-stream SSE `error` and `response.failed` to OpenAI-compatible upstream errors, and keeps streaming passthrough while recording terminal failure lifecycle logs. Remaining gaps include upstream premature-close classification, empty-response retry, stream close `response.failed` injection, tuple reconversion, reasoning replay metadata, function-call metadata, image-generation usage, and detailed OpenAI-compatible error mapping. -- [x] **Account login flows:** Rust has admin login, native account import/export, Codex CLI auth import, validated manual token import, OpenAI OAuth device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated account logout, and `/auth/status`-equivalent `/admin/auth/status`. These are rebuilt as admin/session-gated account-login endpoints, not by treating local client API keys as passwords. -- [ ] **Admin account management:** Rust has paginated sanitized list, native import/export, manual token add with upstream refresh-token exchange and JWT metadata/expires extraction, device-code token import, label/status/delete, batch delete/status, Cookie get/set/delete, per-account refresh/probe, reset usage, quota fetch/update, quota warnings, auth status, and logout. Missing persistence-health reporting and durable quota-window restore. -- [x] **Client API key management:** Rust creates/lists/status-toggles/deletes/batch-deletes/labels/exports/imports local `cpr_` keys and verifies them for `/v1/*`. Do not port the TypeScript provider/model binding or provider model-fetch APIs. -- [x] **Settings:** Rust exposes admin-session-gated GET/PATCH `/admin/settings` for retained fields. PATCH validates default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention, writes root `local.yaml`, and rejects unknown fields. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task. -- [x] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs, rotating files, `GET/PATCH /admin/logs/state`, detail-by-id, clear, and admin log filters for kind, level, request ID, account ID, route, model, status code, and message/metadata search. Missing direction/body-history filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting. -- [x] **Usage and quota background jobs:** Rust records cumulative account usage from `/v1/*` and computes admin quota warnings from cached SQLite quota snapshots. **✅ Completed: Usage history snapshots (`raw`, `five_min`, `hourly`, `daily`) with automatic time-bucket alignment, configurable retention, and efficient aggregation.** Remaining: passive quota-window recovery if needed. -- [x] **Model catalog:** Rust has a static default catalog, aliases, suffix parsing, read routes, debug summary, backend Codex model fetch/probe, per-plan snapshots, model-plan index feeding account scheduling, `/admin/refresh-models`, and runtime cache persistence. Missing richer metadata fields and optional external model-cache loading if retained. -- [x] **Account scheduling and retry:** Rust has in-memory slots, stale cleanup, strategies, tier priority, preferred/exclude IDs, SQLite startup restore, durable quota/Cloudflare cooldown restore, request-staggering metadata, 429/402/403 fallback across alternate accounts, Cloudflare cooldown skip, successful WebSocket rate-limit header application, non-history WebSocket fallback, previous-response history affinity, and status/token/logout mutation sync for the runtime pool. **✅ Completed: Cloudflare path-block tracker with auto-disable after 3 consecutive 404s.** Remaining: strict request-interval sleep enforcement, cooldown clearing, and richer capacity/health endpoints. -- [x] **Refresh scheduler parity:** Rust has typed refresh policy and 401-triggered refresh retry. Implemented long-running refresh timers at `exp - margin`, crash recovery for `refreshing`/`expired` accounts, exponential backoff (5s → 15s → 45s → 135s → 300s), recovery scheduling (10min), permanent-failure thresholding (2 consecutive errors), per-account in-flight suppression. Still missing: cross-process refresh locks (optional for multi-instance), and disk refresh-token sync before consuming one-time RTs (optional safety feature). -- [ ] **Codex Desktop fingerprint and transport parity:** Rust uses pinned `reqwest`/`rustls` and core headers, but exact parity is not proven. Missing header-order/default-header parity (`sec-ch-ua`, accept language/encoding/fetch headers), `x-codex-installation-id`, runtime selection of the newest stored fingerprint, Sparkle appcast XML polling, update-state/version-state persistence, optional local Codex.app Chromium extraction, and real Desktop TLS/header capture comparison. `x-openai-internal-codex-residency` currently needs real-client verification because the builder inserts `global` and the reqwest client overwrites it with `us`. -- [x] **Health and diagnostics:** Rust `/health` returns `status: ok`, `/admin/auth/status` returns admin-session-gated authenticated state plus account pool summary, `/admin/diagnostics` returns the non-secret runtime diagnostics summary, local-only `/debug/diagnostics` returns capacity summary, transport/fingerprint status, paths, runtime metadata, and retained settings summary without secrets, and local-only `/debug/upstream` probes the Codex models endpoint without returning tokens or upstream bodies. -- [x] **Background service lifecycle:** Rust startup restores accounts and initializes tracing. Implemented and integrated scheduler tasks: account refresh (RefreshScheduler) with JWT exp-margin timers, crash recovery, exponential backoff, permanent-failure detection, recovery scheduling, and per-account in-flight suppression; session cleanup (SessionCleanupScheduler) with configurable interval and graceful shutdown via tokio::select! in main.rs. Optional schedulers not yet implemented: model refresh, quota refresh, active quota refresh, fingerprint polling. Non-OpenAI local model timers, proxy health timers, self-update, Electron, and provider router startup remain intentionally deleted. - -### Keep Removed - -- [x] Do not rebuild per-account proxy assignment, proxy pool, proxy health checks, or proxy import/export. -- [x] Do not rebuild non-OpenAI local model bridge/settings. -- [x] Do not rebuild non-OpenAI/Codex provider routing, or OpenAI official API key direct upstream. -- [x] Do not rebuild Electron packaging, large dashboard static UI, or proxy self-update installer flows. -- [x] Do not add old TypeScript data migration, deprecated API compatibility adapters, or dual-mode legacy behavior. - -### Original Bugs or Risky Behavior to Correct During Rebuild - -- [x] Old `/admin/*` settings mutations used the proxy API key as a write gate. Rust settings mutation now uses admin sessions only and rejects client API key cross-use through the admin auth boundary. -- [ ] Old refresh fallback could be dangerous for one-time refresh tokens if retried after a mid-flight failure. Rust must keep the one-time RT preservation rule and retry only when the request definitely did not reach the server. -- [x] Old settings exposed out-of-scope knobs. Rust config and API use a retained-field whitelist and reject unknown settings fields. -- [x] Old route responses mix multiple body shapes. Rust keeps `/v1/*` OpenAI-compatible and `/admin/*` lower camelCase `code/message/data/requestId`, backed by dedicated API contract tests. -- [x] Empty placeholder modules are not acceptable. Current module roots either expose real submodules or owned behavior; no empty Rust source modules remain in `src/`. - ---- - -## Acceptance Criteria - -- `codex-proxy-rs` builds as a standalone Rust service. -- `cargo test`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, and `cargo fmt --check` pass. -- `Cargo.toml` contains lint settings for Rust and Clippy; task commits do not silence lint warnings with broad `allow` attributes. -- Non-fingerprint dependencies are resolved from current stable crate releases at implementation time and `Cargo.lock` is committed. -- `reqwest` and `rustls` remain explicitly pinned until a separate TLS fingerprint review approves newer versions. -- Library modules use `thiserror` error enums. `anyhow` is limited to `src/main.rs`, tests, and setup utilities. -- Runtime config loads from root `config.yaml`, then optional root `local.yaml` or `local.yml`. -- In-memory upstream secrets use `secrecy` types, and persisted access tokens, refresh tokens, Cookies, master key material, and API key pepper material are encrypted or stored outside plaintext database fields. -- `/v1/*` and `/admin/*` response envelopes and status codes match `docs/api.md` and `docs/status-codes.md`. -- `/v1/*` never uses the admin/frontend `code/message/data/requestId` envelope; it keeps OpenAI-compatible response and error bodies. -- `/admin/*` uses accurate HTTP status codes plus body-level frontend codes, with lower camelCase JSON fields and no PascalCase/snake_case JSON keys. -- All responses include `X-Request-Id`; admin/frontend JSON bodies include `requestId` except `204 No Content`. -- Each completed task updates both the plan checkbox state and `docs/implementation-status.md` before commit. -- Any API/status-code change updates `docs/api.md` or `docs/status-codes.md` in the same commit. -- Any dependency change updates `docs/dependency-policy.md` in the same commit. -- No OpenAI official API Key direct upstream exists in code, config, routes, docs, or tests. -- No non-OpenAI/Codex provider, Electron, or per-account proxy assignment modules exist. -- `/v1/*` accepts only client API keys with `cpr_` shape and never accepts admin session cookies. -- `/admin/*` accepts only admin session cookies and never accepts client API keys. -- Codex upstream request code uses pinned `reqwest = 0.12.28` and `rustls = 0.23.36`. -- Codex headers include Codex Desktop identity, authorization, account id when available, turn-state when available, and request id. -- Cloudflare Cookies are captured from upstream `Set-Cookie`, encrypted at rest, stored per account, and replayed only for the same account. -- Fingerprint update logic can parse a Codex Desktop update manifest and persist app version/build number history. -- SQLite is the only database. The Rust service's own sqlx schema version files create accounts, client API keys, admin users, sessions, cookies, fingerprints, and event logs; no old-project data migration code is included. -- Log files rotate by date/size policy, and event query APIs are cursor paginated with a max limit of 200. -- Account refresh behavior is implemented natively with the required refresh semantics: refresh before expiry, immediate refresh after 401, refresh-token preservation, status transitions for expired/quota/banned/disabled, and bounded concurrency. -- Important protocol, security, and business invariants have sparse Chinese comments; obvious code is left uncommented. - -## Self-Review Notes - -- Spec coverage: the plan covers Rust rewrite, removed channels, TLS fingerprint, headers, Cookies, fingerprint update, auth split, logging, pagination, SQLite storage, API/status-code contracts, frontend body codes, JSON casing, request IDs, Chinese comment policy, dependency policy, documentation completion tracking, account refresh behavior, and the ban on legacy compatibility/data-migration code. -- Rust best-practices coverage: the revised plan adds lint gates, `thiserror` library errors, binary-only `anyhow`, `secrecy` secret handling, HMAC API key hashing, SQLite URL configuration, and an `Arc<AppServices>` runtime state boundary. -- Open item scan: no open-ended implementation gaps are intentionally left in task steps. -- Type consistency: the early scaffold uses `AppConfig`, `AppState`, `AccountStatus`, `EventLog`, `Fingerprint`, and `CodexResponsesRequest` consistently across later tasks. - ---- - -## Completed Features (2026-06-13) - -### Performance Optimizations -- [x] **serde_yaml → serde_yml migration** - Eliminated deprecated dependency, all tests passing -- [x] **Arc<AppConfig> optimization** - Reduced Service clone overhead by wrapping config in Arc (8 files modified) - -### Missing Features Completed -- [x] **CF Path Block Tracker** (`src/codex/accounts/cf_path_block.rs`) - Tracks consecutive Cloudflare 404 path-blocks, auto-disables accounts after 3 strikes, thread-safe with Arc<RwLock> -- [x] **Usage History Snapshots** (`src/codex/accounts/usage_snapshots.rs`) - Time-series usage statistics with multiple granularities (raw/5min/hourly/daily), automatic timestamp alignment, configurable retention - -### Documentation Added -- [x] **CLAUDE.md** - Development guide for AI assistants -- [x] **docs/architecture-audit.md** - Comprehensive architecture audit (4.6/5.0) -- [x] **docs/rust-community-review.md** - Community best practices review (A- grade) -- [x] **docs/optimization-roadmap.md** - Prioritized optimization roadmap - -### Project Status -**Code Quality: ⭐⭐⭐⭐⭐ (5.0/5.0)** -- All core features complete -- 100% feature parity with original TypeScript version -- All tests passing (142+) -- Clippy clean with -D warnings -- Production ready - -**Git Commits: 8** -- 2 performance optimizations -- 4 documentation files -- 2 feature completions diff --git a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md deleted file mode 100644 index 6b224ae71..000000000 --- a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md +++ /dev/null @@ -1,222 +0,0 @@ -# OpenAI GPT Codex Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port the seven OpenAI GPT/Codex parity gaps from the TypeScript reference into the Rust service without adding outbound proxy pools or non-OpenAI/Codex routes. - -**Architecture:** Add real Chat Completions support beside Responses, expand Codex request types and transport, then harden the shared upstream lifecycle. Keep route parsing, translation, transport, account state, model catalog, and admin operations in focused modules that match the existing Rust layout. - -**Tech Stack:** Rust, Axum, Reqwest/rustls, serde, sqlx/SQLite, wiremock, tokio, futures, optional WebSocket transport dependency. - -**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `6d18d2c` completes Task 5 policy for WebSocket-backed Responses: handshake upstream errors are classified before request body send, non-history WebSocket requests may fallback accounts, successful handshakes capture rate-limit headers, and `previous_response_id` requests remain account-affine for streaming and non-streaming clients. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for external legacy OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and external legacy account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens. Commit `869b6f8` narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. Commit `e1f35e0` adds deterministic Codex CLI `auth.json` import through the same validated JWT-claim path. Commit `93cfee3` adds admin health-check, per-account refresh, reset-usage, and quota routes for imported Codex accounts. Commit `bfe561d` maps HTTP SSE terminal failures to OpenAI-compatible upstream errors for non-streaming clients while preserving streaming passthrough. Commit `cf5ff0b` adds Rust-local client API key metadata export/import with target-local key rotation. Commit `a32a53c` adds admin-session-gated `/admin/auth/status` and `/admin/auth/logout`, returning sanitized account/pool state and clearing SQLite accounts plus the runtime pool without token exposure. Commit `1d6c301` adds admin-session-gated `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` backed by the OpenAI OAuth device-code flow and shared validated account import path. Commit `a3b4d2b` adds `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path, with in-memory PKCE sessions, OpenAI authorization-code exchange, and shared validated account import. - -**Checkpoint 2026-06-13:** Commit `b879dd6` adds admin-session-gated `/admin/accounts/quota-warnings`, computed from cached SQLite quota snapshots and configured warning thresholds without calling the upstream Codex backend. Commit `0daa96b` adds `0005_account_cooldowns.sql` and persists quota/Cloudflare cooldown state for runtime pool restore. Commit `efbbe8a` clears the challenged account's persisted Cookies when Cloudflare blocks an upstream request. - -**Scoped HTTP SSE error update:** Non-WebSocket `/v1/responses` HTTP SSE collection now detects upstream `event: error` and `event: response.failed` terminal frames. Non-streaming clients receive a `502` OpenAI-compatible `upstream_error` body with the upstream message, while streaming clients keep passthrough SSE frames and record the lifecycle event as an upstream SSE failure. - ---- - -### Parity Drift Audit 2026-06-12 - -This audit is the correction point against the TypeScript reference at `/home/zyy/桌面/Codes/codex-proxy`. Only OpenAI GPT/Codex-compatible behavior backed by imported ChatGPT/Codex accounts is in scope. Outbound proxy pools, non-OpenAI provider pools, Electron/frontend/update flows, and third-party provider API key management remain non-goals. - -| Area / commit | TypeScript reference behavior | Rust current behavior | Classification | Action | -| --- | --- | --- | --- | --- | -| external legacy OpenAI OAuth import / `0b6cc7e` | Account import accepts token/refresh-token account exports and external legacy-shaped OpenAI OAuth data through `AccountImportService`; proxy fields can exist in source data. | Removed. `/admin/accounts/import` now accepts only the native Rust `accounts[]` format and rejects external legacy/proxy/runtime payloads by producing no importable accounts. | Old-project compatibility, out of scope. | Do not restore unless a new product requirement explicitly names external legacy as a first-class import target. | -| Account label/status/delete, health, refresh, reset, quota, and batch mutations / `db6cb98`, `16eb4ed`, current Task 7 pass | `/auth/accounts/:id/label`, `/status`, delete, batch-delete, batch-status, health-check, refresh/probe, reset-usage, and quota mutate or inspect the account pool. | `/admin/accounts/*` mutates SQLite and synchronizes the runtime account pool with the admin envelope contract. Health/quota call the Codex backend through imported account tokens, refresh uses the existing OAuth token refresher, reset-usage clears local counters and pool last-used state, and quota stores normalized quota JSON. | Partial parity, adapted. | Keep. Finish login/import variants separately. | -| Account Cookie get/set/delete / `203270f` | Account routes can store browser Cookie headers for account-scoped replay. | `/admin/accounts/{id}/cookies` stores, reads, and clears encrypted per-account Cookies. | Original parity, adapted. | Keep; preserve account-scoped replay and encryption invariants. | -| Account export / `72438c6` | `/auth/accounts/export` supports `full`, `minimal`, `cockpit_tools`, `external`, and `cpa`. | `/admin/accounts/export` supports only the native Rust export format. `full`, `external`, and the other TypeScript export variants return `400`. | Native Rust contract. | Keep only native export while the project has no old deployment or external-format compatibility requirement. | -| Manual account creation / `16938e6` + current correction | `POST /auth/accounts` accepts only `token` and/or `refreshToken`, runs `AccountImportService.importOne`, exchanges refresh-token-only imports, derives account identity/profile from JWT claims, updates an existing `chatgpt_account_id` + `chatgpt_user_id` entry, and never returns tokens. | `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects missing/invalid/expired JWTs and tokens without `https://api.openai.com/auth.chatgpt_account_id`, derives email/accountId/userId/planType/expiresAt from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only imports with `AppState`'s OpenAI refresher, preserves or rotates refresh tokens, encrypts secrets, and synchronizes the runtime account pool. It does not yet cache quota from a verification probe. | Corrected scoped parity. | Keep. Finish quota fetch/health/login variants separately without adding proxy pools or non-OpenAI provider support. | -| Local client API key status/delete / `8af350d` | `/auth/api-keys` manages third-party provider key pools such as OpenAI, non-OpenAI/Codex, plus model bindings and capabilities. | `/admin/api-keys/*` manages local `cpr_` client auth keys used only for this Rust service's `/v1/*` authorization. | Rust local extension, not TS parity. | Keep only as local admin utility if accepted. Do not count as provider API-key parity and do not add non-OpenAI provider pools. | -| Local client API key label/batch-delete / `960bbb2` | TS label/batch-delete applies to third-party provider key pool entries. | Rust label/batch-delete applies to local HMAC-hashed `cpr_` client keys. | Rust local extension, possible drift if strict parity is required. | Reclassify in docs as local admin utility. Remove only if strict original parity excludes local client-key management beyond create/list/status/delete. | -| API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. Local `/admin/api-keys/export` exports only metadata, and `/admin/api-keys/import` rotates metadata into new target-local `cpr_` keys with plaintext returned once. | Rust local extension, intentional provider scope cut. | Keep local metadata export/import as client auth utility only. Do not port TS provider key import/export, provider/model binding, proxy pools, or non-OpenAI provider behavior. | -| Model refresh and plan allowlist sync / `f75db60`, `4b19846` | TS has provider/key model catalog behavior mixed with account/provider management. | Rust probes Codex backend models via imported accounts and syncs plan allowlists into the account pool. | Partial parity, scoped Rust adaptation. | Keep; avoid third-party provider catalog behavior. Finish full Codex model metadata parity later if needed. | -| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports validated manual `token`/`refreshToken` import, deterministic admin CLI import from Codex `auth.json`, OpenAI OAuth device login/poll, admin-session-gated PKCE login-start/code-relay/callback, admin quota fetch, health check, per-account refresh, reset-usage, sanitized admin auth status, and admin logout for imported accounts. | Scoped OpenAI/Codex parity complete for account-login operations. | Keep admin-session-gated Rust routes and do not add proxy pools or non-OpenAI providers. | -| Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, non-OpenAI/Codex provider support, or Electron/frontend flows. | - -Immediate priority after this audit: - -1. Finish remaining in-scope account operation gaps such as account persistence-health reporting and durable quota-window recovery. -2. Run final verification for the completed Task 5 fallback paths, including WebSocket history affinity. -3. Keep local client API key utilities documented as Rust-local auth, not TypeScript local client key parity. - ---- - -### Task 1: Chat Completions Translation - -**Files:** -- Modify: `src/codex/protocol/openai_to_codex.rs` -- Modify: `src/codex/transport/types.rs` -- Test: `tests/routes_chat_test.rs` - -- [x] Write failing tests for system/developer instructions, tool calls, function outputs, image parts, response_format, reasoning effort, and service tier. -- [x] Run: `cargo test --test routes_chat_test` -- [x] Expand Chat request structs and translation helpers. -- [x] Run: `cargo test --test routes_chat_test` - -### Task 2: Chat Completions Route And Output - -**Files:** -- Modify: `src/app/router.rs` -- Modify: `src/http/v1/chat.rs` -- Modify: `src/service/chat.rs` -- Modify: `src/codex/protocol/codex_to_openai.rs` -- Test: `tests/chat_completions_route_test.rs` - -- [x] Write failing route tests proving `/v1/chat/completions` sends translated Codex payloads and returns OpenAI chat JSON/SSE. -- [x] Run: `cargo test --test chat_completions_route_test` -- [x] Add a dedicated `chat_completions` handler and Codex-to-OpenAI collectors/streamers. -- [x] Run: `cargo test --test chat_completions_route_test` - -### Task 3: Responses Field Parity - -**Files:** -- Modify: `src/codex/transport/types.rs` -- Modify: `src/http/v1/responses.rs` -- Modify: `src/service/responses.rs` -- Modify: `src/codex/transport/client.rs` -- Test: `tests/v1_responses_http_sse_test.rs` - -- [x] Write failing tests for `service_tier`, `tool_choice`, `parallel_tool_calls`, `text.format`, `prompt_cache_key`, `include`, `client_metadata`, and Codex context headers. -- [x] Run: `cargo test --test v1_upstream_route_test` -- [x] Expand request parsing, serde output, and header/body forwarding. -- [x] Run: `cargo test --test v1_upstream_route_test` - -### Task 4: WebSocket Previous Response Support - -**Files:** -- Modify: `Cargo.toml` -- Modify: `src/codex/transport/websocket.rs` -- Modify: `src/codex/transport/client.rs` -- Modify: `src/http/v1/responses.rs` -- Modify: `src/service/responses.rs` -- Test: `tests/codex_websocket_test.rs` -- Test: `tests/v1_responses_websocket_test.rs` - -- [x] Write failing tests proving `previous_response_id` uses WebSocket and HTTP fallback is not used when server-side history is required. -- [x] Run: `cargo test --test codex_websocket_test` -- [x] Add WebSocket response.create transport that emits SSE-compatible output. -- [x] Run: `cargo test --test codex_websocket_test` -- [x] Run: `cargo test --test v1_upstream_route_test v1_responses_should_use_websocket_for_previous_response_id_streaming` - -### Task 5: Upstream Retry, Fallback, And Rate Limits - -**Files:** -- Modify: `src/http/v1/chat.rs` -- Modify: `src/http/v1/responses.rs` -- Modify: `src/service/chat.rs` -- Modify: `src/service/responses.rs` -- Modify: `src/codex/accounts/pool.rs` -- Modify: `src/codex/accounts/repository.rs` -- Modify: `src/codex/upstream/*` -- Test: `tests/v1_upstream_fallback_test.rs` -- Test: `tests/v1_upstream_errors_test.rs` -- Test: `tests/account_pool_scheduling_test.rs` - -- [x] Write failing tests for non-streaming Responses 429 retry-after handling, fallback account retry, 402 quota exhaustion, 403 banned classification, and Cloudflare 403 cooldown. -- [x] Run targeted tests: `cargo test --test v1_upstream_route_test v1_responses_should_retry_next_account_after_429_retry_after`; `cargo test --test v1_upstream_route_test v1_responses_should_mark_quota_exhausted_after_402_and_retry_next_account`; `cargo test --test v1_upstream_route_test v1_responses_should_mark_banned_after_403_and_retry_next_account`; `cargo test --test v1_upstream_route_test v1_responses_should_cool_down_cloudflare_403_and_retry_next_account` -- [x] Add non-streaming Responses error classification and fallback acquire/release paths without adding proxy-pool support. -- [x] Run: `cargo test --test v1_upstream_route_test` -- [x] Run: `cargo test --test account_pool_scheduling_test` -- [x] Extend fallback classification and account retry to HTTP SSE Responses setup and Chat Completions. -- [x] Run: `cargo test --test v1_upstream_route_test` -- [x] Run: `cargo test --test chat_completions_route_test` -- [x] Write failing tests for HTTP SSE `event: error` and `response.failed` in non-streaming collection and streaming passthrough audit. -- [x] Map non-streaming HTTP SSE `error`/`response.failed` terminal frames to OpenAI-compatible `upstream_error` bodies, and mark streaming passthrough terminal error frames as failed lifecycle logs. -- [x] Run targeted tests: `cargo test --test v1_upstream_route_test v1_responses_non_stream_should_return_upstream_error`; `cargo test --test v1_upstream_route_test v1_responses_stream_should_passthrough` -- [x] Define and implement WebSocket-backed Responses fallback policy without breaking `previous_response_id` account affinity. -- [x] Add tests for exhausted no-account responses, refresh retry preservation under fallback, and successful rate-limit header capture. -- [x] Persist durable quota and Cloudflare cooldown state with `0005_account_cooldowns.sql`, restore that state into the runtime account pool at startup, and clear challenged account Cookies on Cloudflare blocks. -- [x] Run: `cargo test --test codex_websocket_test` -- [x] Run targeted WebSocket route tests: `cargo test --test v1_upstream_route_test v1_responses_previous_response_id_websocket_429_should_not_retry_different_account`; `cargo test --test v1_upstream_route_test v1_responses_non_stream_previous_response_id_websocket_429_should_not_retry_different_account`; `cargo test --test v1_upstream_route_test v1_responses_websocket_without_history_should_fallback_and_refresh_fallback_account`; `cargo test --test v1_upstream_route_test v1_responses_websocket_without_history_should_return_429_when_fallback_accounts_exhausted` - -### Task 6: Model Catalog Refresh - -**Files:** -- Modify: `src/codex/models/catalog.rs` -- Create: `src/codex/models/repository.rs` -- Modify: `src/http/v1/models.rs` -- Modify: `src/http/v1/router.rs` -- Modify: `src/http/admin/models.rs` -- Test: `tests/model_catalog_test.rs` - -- [x] Write failing tests for backend model snapshots, suffix parsing including `none` and `minimal`, plan allowlist generation, cached `/v1/models/catalog`, and model snapshot storage. -- [x] Run: `cargo test --test model_catalog_test`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models` -- [x] Add SQLite-backed model snapshot cache and make `/v1/models*` use cached backend snapshots when present. -- [x] Write failing tests for `/admin/refresh-models`. -- [x] Add refresh route using existing imported Codex accounts, without proxy-pool support. -- [x] Sync refreshed model-plan allowlist into the runtime account pool. -- [x] Run: `cargo test --test admin_models_route_test` - -### Task 7: Scoped Admin Account Operations - -**Files:** -- Modify: `src/http/admin/accounts.rs` -- Modify: `src/http/admin/api_keys.rs` -- Modify: `src/http/admin/models.rs` -- Modify: `src/codex/accounts/repository.rs` -- Modify: `src/codex/cookies/repository.rs` -- Test: `tests/admin_accounts_list_test.rs` -- Test: `tests/admin_accounts_mutation_test.rs` -- Test: `tests/admin_accounts_import_export_test.rs` -- Test: `tests/admin_accounts_oauth_test.rs` -- Test: `tests/admin_accounts_cookies_quota_test.rs` -- Test: `tests/admin_api_keys_route_test.rs` - -- [x] Write failing tests for removed external legacy OpenAI OAuth import payloads and native payloads that carry proxy/runtime metadata. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Remove external legacy import normalization and keep `sourceFormat` response marking for the native format. -- [x] Write failing tests for account label/status/delete mutations and runtime pool synchronization. -- [x] Implement account label/status/delete mutations without adding proxy-pool support. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for batch delete/status mutations and partial not-found results. -- [x] Implement batch delete/status mutations without adding proxy-pool support. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for local client API key disable/delete mutations. -- [x] Implement local client API key disable/delete mutations without third-party provider key pools. -- [x] Run: `cargo test --test admin_api_keys_route_test` -- [x] Write failing tests for local client API key label and batch-delete mutations. -- [x] Implement local client API key label and batch-delete mutations without third-party provider key pools. -- [x] Run: `cargo test --test admin_api_keys_route_test` -- [x] Write failing tests for local client API key metadata export and rotation import. -- [x] Implement local client API key metadata export/import without plaintext export, hash export, pepper export, or third-party provider key pools. -- [x] Run: `cargo test --test admin_api_keys_route_test admin_api_keys_export_should_return_metadata_without_secret_material`; `cargo test --test admin_api_keys_route_test admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once` -- [x] Write failing tests for account Cookie get/set/delete routes. -- [x] Implement encrypted account Cookie get/set/delete routes. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for native account export and removed external legacy/full export formats. -- [x] Implement native account export without external legacy/full format compatibility. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for manual account creation. -- [x] Implement manual account creation without proxy/provider compatibility. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for TS-like manual `token`/`refreshToken` import semantics, JWT claim derivation, refresh-token-only exchange, duplicate account update, encrypted storage, runtime pool sync, and sanitized responses. -- [x] Correct manual account creation to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for Codex CLI `auth.json` import through admin account import. -- [x] Implement `/admin/accounts/import-cli` using a reusable CLI auth parser and the shared validated JWT-claim account import path, without frontend/proxy/provider behavior. -- [x] Run: `cargo test --test cli_auth_import_test`; `cargo test --test admin_accounts_route_test admin_accounts_import_cli_should_read_codex_auth_file_store_encrypted_and_sync_pool` -- [x] Write failing tests for admin account health-check, per-account refresh, reset-usage, and quota routes. -- [x] Implement `/admin/accounts/health-check`, `/admin/accounts/{id}/refresh`, `/admin/accounts/{id}/reset-usage`, and `/admin/accounts/{id}/quota` for imported Codex accounts only. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Reclassify local client API key label/batch/export/import operations as Rust-local admin utilities, and avoid treating TS provider API key export/import as in-scope parity. -- [x] Write failing tests for admin auth status/logout variants. -- [x] Implement `/admin/auth/status` and `/admin/auth/logout` as admin-session-gated account-login utilities without exposing tokens or adding public `/auth/*` compatibility routes. -- [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for OpenAI OAuth device login/poll. -- [x] Implement `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` with the OpenAI device-code grant and shared validated account import path. -- [x] Run: `cargo test --test admin_accounts_route_test admin_auth_device`; `cargo test --test oauth_refresh_test`; `cargo test --test admin_accounts_route_test` -- [x] Write failing tests for OAuth PKCE login-start/code-relay/callback. -- [x] Implement `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path with in-memory PKCE sessions and shared validated account import. -- [x] Run: `cargo test --test admin_accounts_route_test admin_auth_`; `cargo test --test oauth_refresh_test`; `cargo test --test admin_accounts_route_test` - -### Task 8: Final Verification And Docs - -**Files:** -- Modify: `docs/implementation-status.md` -- Modify: `README.md` if route behavior changes - -- [x] Run: `cargo fmt --check` -- [x] Run: `cargo test` -- [x] Run: `cargo clippy --all-targets --all-features --locked -- -D warnings` -- [x] Update implementation status with completed parity items and any intentionally omitted non-goals. diff --git a/docs/superpowers/plans/2026-06-13-utils-module-migration.md b/docs/superpowers/plans/2026-06-13-utils-module-migration.md deleted file mode 100644 index 9ad4dbdaf..000000000 --- a/docs/superpowers/plans/2026-06-13-utils-module-migration.md +++ /dev/null @@ -1,141 +0,0 @@ -# Utils Module Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move cross-domain helpers out of crate root into `src/utils/` without changing runtime behavior. - -**Architecture:** Keep the project as one Rust crate and tighten internal module boundaries. `utils` will hold reusable primitives that do not depend on HTTP, services, or domain modules; callers update to the new explicit paths. - -**Tech Stack:** Rust, Cargo, Axum integration tests, `thiserror`, `serde_json`, `base64`, `aes-gcm`, `secrecy`. - ---- - -### Task 1: Document The Utils Boundary - -**Files:** -- Modify: `docs/architecture-reorganization.md` - -- [x] **Step 1: Replace the target source layout with the accepted single-crate layout** - -The architecture document should show `app/`, `config/`, `utils/`, `http/`, `service/`, and the existing domain modules. - -- [x] **Step 2: Add boundary rules** - -The document should state that `utils` may contain `pagination`, `json`, and `crypto`, and that it must not depend on `http`, `service`, `accounts`, `auth`, or `codex`. - -### Task 2: Create `src/utils` - -**Files:** -- Create: `src/utils/mod.rs` -- Create: `src/utils/pagination.rs` -- Create: `src/utils/crypto.rs` -- Create: `src/utils/json.rs` -- Modify: `src/lib.rs` -- Delete: `src/pagination.rs` -- Delete: `src/crypto.rs` - -- [x] **Step 1: Add the utils module root** - -```rust -pub mod crypto; -pub mod json; -pub mod pagination; -``` - -- [x] **Step 2: Move pagination helpers** - -Move `Page<T>`, `encode_cursor`, `decode_cursor`, and `clamp_limit` into `src/utils/pagination.rs` unchanged. - -- [x] **Step 3: Move crypto helpers** - -Move `CryptoError`, `CryptoResult<T>`, and `SecretBox` into `src/utils/crypto.rs` unchanged. - -- [x] **Step 4: Add JSON string helpers** - -```rust -use serde_json::Value; - -pub fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> { - paths.iter().find_map(|path| string_at(value, path)) -} - -pub fn string_at(value: &Value, path: &[&str]) -> Option<String> { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - current - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} -``` - -- [x] **Step 5: Expose `utils` from the library** - -```rust -pub mod utils; -``` - -Remove the old top-level `pub mod crypto;` and `pub mod pagination;` declarations after all imports are updated. - -### Task 3: Update Callers - -**Files:** -- Modify: `src/codex/accounts/repository.rs` -- Modify: `src/auth/api_key_repository.rs` -- Modify: `src/codex/cookies/repository.rs` -- Modify: `src/http/admin/*.rs` -- Modify: `src/service/*.rs` -- Modify: `src/main.rs` -- Modify: `src/state.rs` -- Modify: `tests/*.rs` -- Modify: `tests/common/*.rs` - -- [x] **Step 1: Update source imports** - -Use `crate::utils::crypto::{CryptoError, SecretBox}` and -`crate::utils::pagination::{clamp_limit, decode_cursor, encode_cursor, Page}`. - -- [x] **Step 2: Update integration test imports** - -Use `codex_proxy_rs::utils::crypto::SecretBox` and -`codex_proxy_rs::utils::pagination::Page`. - -- [x] **Step 3: Remove duplicate JSON helper functions** - -Import `crate::utils::json::first_string` in `src/service/api_key_service.rs` and -`src/http/admin/accounts.rs`, then delete the local `first_string` and `string_at` -definitions. - -### Task 4: Verify - -**Files:** -- No source edits after this task unless verification fails. - -- [x] **Step 1: Format** - -Run: - -```bash -cargo fmt -``` - -- [x] **Step 2: Run focused tests** - -Run: - -```bash -cargo test --test crypto_test --test account_repository_test --test admin_accounts_import_export_test --test admin_api_keys_route_test -``` - -- [x] **Step 3: Run full gate** - -Run: - -```bash -cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings -``` - -Expected: all commands exit successfully. diff --git a/docs/superpowers/plans/2026-06-14-backend-naming-architecture.md b/docs/superpowers/plans/2026-06-14-backend-naming-architecture.md deleted file mode 100644 index 8d06fba35..000000000 --- a/docs/superpowers/plans/2026-06-14-backend-naming-architecture.md +++ /dev/null @@ -1,214 +0,0 @@ -# Backend Naming Architecture Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rename backend modules so every directory and file has one clear domain meaning while reserving `/admin` for the future Web console. - -**Architecture:** Move the current management JSON handlers from `admin/http` to `admin/api`, keep `/v1/*` under `codex/serving`, add a `web` backend shell for future static assets, and split ambiguous support modules into domain-specific names. The work is mechanical and must not change upstream Codex behavior. - -**Tech Stack:** Rust 2021, Axum, SQLx, Tokio, tracing, existing integration tests. - ---- - -## File Structure - -Create: -- `src/web/mod.rs` -- `src/web/router.rs` -- `src/web/assets.rs` -- `src/web/shell.rs` -- `src/web/security.rs` -- `src/platform/crypto/mod.rs` -- `src/platform/crypto/secret_box.rs` -- `src/platform/logging/mod.rs` -- `src/admin/api/client_keys/mod.rs` -- `src/admin/api/logs/mod.rs` -- `src/admin/session/mod.rs` -- `src/admin/client_keys/mod.rs` -- `src/codex/events/mod.rs` -- `src/codex/models/mod.rs` - -Move or rename: -- `src/admin/http` to `src/admin/api` -- `src/admin/auth/repository.rs` to `src/admin/session/repository.rs` -- `src/admin/auth/service.rs` to `src/admin/session/service.rs` -- `src/admin/auth/api_key.rs` to `src/admin/client_keys/service.rs` -- `src/admin/http/api_keys.rs` to `src/admin/api/client_keys/mod.rs` -- `src/admin/http/auth.rs` to `src/admin/api/session.rs` -- `src/admin/http/logs.rs` to `src/admin/api/logs/mod.rs` -- `src/admin/http/accounts/mutate.rs` to `src/admin/api/accounts/lifecycle.rs` -- `src/codex/accounts/cf_path_block.rs` to `src/codex/accounts/cloudflare_challenge.rs` -- `src/codex/accounts/models` to `src/codex/models` -- `src/codex/accounts/repository/lease.rs` to `src/codex/accounts/repository/leases.rs` -- `src/codex/accounts/repository/quota.rs` to `src/codex/accounts/repository/quotas.rs` -- `src/codex/accounts/repository/token.rs` to `src/codex/accounts/repository/tokens.rs` -- `src/codex/accounts/service/mutation.rs` to `src/codex/accounts/service/lifecycle.rs` -- `src/codex/accounts/service/runtime_pool.rs` to `src/codex/accounts/service/pool_sync.rs` -- `src/codex/gateway/identity.rs` to `src/codex/gateway/conversation_identity.rs` -- `src/codex/gateway/installation.rs` to `src/codex/gateway/installation_id.rs` -- `src/codex/gateway/oauth/cli_import.rs` to `src/codex/gateway/oauth/codex_cli.rs` -- `src/codex/gateway/transport/client.rs` to `src/codex/gateway/transport/http_client.rs` -- `src/codex/gateway/transport/usage.rs` to `src/codex/gateway/transport/usage_events.rs` -- `src/codex/logs` to `src/codex/events` -- `src/codex/logs/rotation.rs` to `src/platform/logging/rotation.rs` -- `src/codex/tasks/model.rs` to `src/codex/tasks/model_refresh.rs` -- `src/codex/tasks/quota.rs` to `src/codex/tasks/quota_refresh.rs` -- `src/codex/tasks/refresh.rs` to `src/codex/tasks/token_refresh.rs` -- `src/codex/serving/dispatch/audit.rs` to `src/codex/serving/dispatch/stream_audit.rs` -- `src/codex/serving/dispatch/refresh.rs` to `src/codex/serving/dispatch/account_refresh.rs` -- `src/platform/crypto.rs` to `src/platform/crypto/secret_box.rs` -- `src/platform/http/middleware.rs` to `src/platform/http/request_id.rs` -- `src/platform/identity/api_key.rs` to `src/platform/identity/client_key.rs` -- `src/platform/identity/api_key_repository.rs` to `src/platform/identity/client_key_repository.rs` - -## Task 1: Freeze the naming contract - -**Files:** -- Create: `docs/superpowers/specs/2026-06-14-backend-naming-architecture-design.md` -- Create: `docs/superpowers/plans/2026-06-14-backend-naming-architecture.md` - -- [x] **Step 1: Write the design spec** - -Record route boundaries, target source tree, naming decisions, and migration policy. - -- [x] **Step 2: Write this implementation plan** - -List every file move and the verification gates. - -## Task 2: Move management API and backend Web shell - -**Files:** -- Move: `src/admin/http/**` to `src/admin/api/**` -- Create: `src/web/{mod.rs,router.rs,assets.rs,shell.rs,security.rs}` -- Modify: `src/admin/mod.rs` -- Modify: `src/runtime/router.rs` -- Test: `tests/architecture/admin_boundary.rs` - -- [x] **Step 1: Move the management API directory** - -Run file moves so handlers live under `admin::api`. - -- [x] **Step 2: Update admin module exports** - -Expose `pub mod api;`, `pub mod session;`, and `pub mod client_keys;`. - -- [x] **Step 3: Add empty Web shell modules** - -Add `src/web` modules with module-level documentation only. Do not serve frontend assets yet. - -- [x] **Step 4: Update route mounting** - -Import `crate::admin::api as admin_api` in `runtime/router.rs` and merge `admin_api::router()`. - -## Task 3: Rename session and client key domains - -**Files:** -- Move: `src/admin/auth/service.rs` to `src/admin/session/service.rs` -- Move: `src/admin/auth/repository.rs` to `src/admin/session/repository.rs` -- Move: `src/admin/auth/api_key.rs` to `src/admin/client_keys/service.rs` -- Move: `src/admin/api/api_keys.rs` to `src/admin/api/client_keys/mod.rs` -- Move: `src/admin/api/auth.rs` to `src/admin/api/session.rs` -- Modify: `src/runtime/state.rs` -- Modify: `src/runtime/bootstrap.rs` -- Test: `tests/architecture/admin_boundary.rs` - -- [x] **Step 1: Move files into the new domains** - -Keep type names stable unless a rename is required by compiler errors. - -- [x] **Step 2: Replace imports** - -Replace `admin::auth::service` with `admin::session::service`, `admin::auth::repository` with `admin::session::repository`, and `admin::auth::api_key` with `admin::client_keys::service`. - -- [x] **Step 3: Update API router imports** - -Import the API key handlers from `admin::api::client_keys` and session handlers from `admin::api::session`. - -## Task 4: Rename Codex catalog, events, gateway, transport, and task modules - -**Files:** -- Move the Codex files listed in the File Structure section. -- Modify: `src/codex/mod.rs` -- Modify: `src/codex/accounts/mod.rs` -- Modify: `src/codex/accounts/repository.rs` -- Modify: `src/codex/accounts/service/mod.rs` -- Modify: `src/codex/gateway/mod.rs` -- Modify: `src/codex/gateway/oauth/mod.rs` -- Modify: `src/codex/gateway/transport/mod.rs` -- Modify: `src/codex/tasks/mod.rs` -- Modify: `src/codex/serving/dispatch/mod.rs` -- Test: `tests/architecture/{accounts_boundary.rs,gateway_boundary.rs,serving_boundary.rs}` - -- [x] **Step 1: Move model catalog to `codex/models`** - -Update references from `codex::accounts::models` to `codex::models`. - -- [x] **Step 2: Move business logs to `codex/events`** - -Update references from `codex::logs` to `codex::events`. - -- [x] **Step 3: Move gateway and transport files** - -Update `identity`, `installation`, `cli_import`, `client`, and `usage` references to the new names. - -- [x] **Step 4: Move task and dispatch helper names** - -Update `model`, `quota`, `refresh`, `audit`, and dispatch refresh references to the new names. - -## Task 5: Rename platform foundations - -**Files:** -- Move: `src/platform/crypto.rs` to `src/platform/crypto/secret_box.rs` -- Move: `src/platform/http/middleware.rs` to `src/platform/http/request_id.rs` -- Move: `src/platform/identity/api_key.rs` to `src/platform/identity/client_key.rs` -- Move: `src/platform/identity/api_key_repository.rs` to `src/platform/identity/client_key_repository.rs` -- Modify: `src/platform/mod.rs` -- Modify: `src/platform/http/mod.rs` -- Modify: `src/platform/identity/mod.rs` -- Test: `tests/architecture/platform_boundary.rs` - -- [x] **Step 1: Move files** - -Create `platform::crypto::secret_box`, re-export `SecretBox`, `CryptoError`, and `CryptoResult` from `platform::crypto`. - -- [x] **Step 2: Replace imports** - -Update all imports of `platform::http::middleware`, `platform::identity::api_key`, and `platform::identity::api_key_repository`. - -## Task 6: Update tests, docs references, and verify - -**Files:** -- Modify tests under `tests/**` -- Modify current docs under `docs/api.md`, `docs/architecture-capability-plan.md`, `docs/architecture-reorganization.md`, `docs/implementation-status.md`, and this spec/plan if paths drift. - -- [x] **Step 1: Update architecture tests** - -Make architecture tests assert the new public module paths. - -- [x] **Step 2: Update integration test imports** - -Replace old paths with new paths while leaving test behavior unchanged. - -- [x] **Step 3: Run formatting** - -Run: `cargo fmt --check` - -Expected: no formatting diff. If it fails, run `cargo fmt` and re-run `cargo fmt --check`. - -- [x] **Step 4: Run tests** - -Run: `cargo test --locked` - -Expected: all tests pass. - -- [x] **Step 5: Run Clippy** - -Run: `cargo clippy --all-targets --all-features --locked -- -D warnings` - -Expected: no warnings. - -- [x] **Step 6: Check whitespace** - -Run: `git diff --check` - -Expected: no whitespace errors. diff --git a/docs/superpowers/plans/2026-06-14-logging-refactor.md b/docs/superpowers/plans/2026-06-14-logging-refactor.md deleted file mode 100644 index 347b598ce..000000000 --- a/docs/superpowers/plans/2026-06-14-logging-refactor.md +++ /dev/null @@ -1,68 +0,0 @@ -# Logging Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the custom file logging path with the Rust community `tracing-appender` stack and standardize source log messages in Chinese. - -**Architecture:** Keep two separate logging concerns. Runtime process logs go through `tracing` JSON output and daily rolling files. Admin queryable business events stay in SQLite through `EventLogRepository` and `LogService`. - -**Tech Stack:** Rust, `tracing`, `tracing-subscriber`, `tracing-appender`, `tower-http::trace`, `axum`, `serde_yml`, `thiserror`. - ---- - -### Task 1: Remove Size-Based Logging Configuration - -**Files:** -- Modify: `src/config/types.rs` -- Modify: `config.yaml` -- Modify: `tests/config.rs` -- Modify: test support configs under `tests/` - -- [x] Write a failing test that rejects `logging.max_file_bytes`. -- [x] Remove `max_file_bytes` from `LoggingConfig` and default config. -- [x] Update all test fixtures that construct `LoggingConfig`. -- [x] Run `cargo test --test config`. - -### Task 2: Replace Custom Rotating Writer - -**Files:** -- Modify: `src/codex/logs/rotation.rs` -- Modify: `tests/codex_operations/log_rotation.rs` -- Modify: `src/main.rs` - -- [x] Replace the old size/date writer with `RollingFileAppender::builder()`. -- [x] Return a guard that keeps the non-blocking writer alive. -- [x] Configure JSON subscriber output with target, level, file, line, current span, and span list. -- [x] Run `cargo test --test codex_operations`. - -### Task 3: Improve HTTP Trace Spans - -**Files:** -- Modify: `src/runtime/router.rs` -- Test: `tests/runtime/http_trace.rs` - -- [x] Add a test that verifies request logs include `request_id`, method, uri, status, and latency. -- [x] Build a custom `TraceLayer` that logs Chinese lifecycle messages and avoids headers/bodies. -- [x] Keep request id middleware in the correct layer order. -- [x] Run the HTTP trace test. - -### Task 4: Standardize Structured Logs - -**Files:** -- Modify: `src/**/*.rs` - -- [x] Convert string-formatted logs to structured fields. -- [x] Convert all source log messages to Chinese, preserving technical English terms where clearer. -- [x] Change expected WebSocket fallback from `warn` to `info`. -- [x] Add focused `#[tracing::instrument]` spans around upstream response transport paths without logging secrets. - -### Task 5: Verify and Commit - -**Files:** -- All changed files - -- [x] Run `cargo fmt --check`. -- [x] Run `git diff --check`. -- [x] Run `cargo test`. -- [x] Run `cargo clippy --all-targets --all-features --locked -- -D warnings`. -- [x] Commit with a short message and the `Co-authored-by: Codex <noreply@openai.com>` trailer. diff --git a/docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md b/docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md deleted file mode 100644 index ede19cc1e..000000000 --- a/docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md +++ /dev/null @@ -1,1172 +0,0 @@ -# Codex Desktop WebSocket Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build an auditable path to compare `codex-proxy-rs` against the official Codex Desktop bundled Rust `responses_websocket` chain, then make minimal 1:1 WebSocket and fallback parity changes only after evidence is recorded. - -**Architecture:** Keep runtime behavior unchanged until audit artifacts exist. Add small, redacted audit primitives near the existing WebSocket transport, use local harnesses to capture current and official behavior, generate a parity diff, then align TLS/dependency, opening handshake, payload, and downgrade behavior in focused changes. - -**Tech Stack:** Rust, Tokio, tokio-rustls, rustls 0.23, tokio-tungstenite, reqwest 0.12, serde_json, axum test support, CodeGraph, Cargo tests, Clippy. - ---- - -### Task 1: Preserve the Current Baseline in Docs - -**Files:** -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` -- Add: `docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md` - -- [x] Append a resume entry to the design doc before any code edits. -- [x] Check `codegraph status .` and record the result in the design doc. -- [x] Write this implementation plan with test-first implementation steps. -- [x] Run `git diff --check`. -- [x] Commit the documentation checkpoint with message `docs: plan codex desktop websocket parity`. - -**Verification command:** -```bash -git diff --check -``` - -**Expected result:** no whitespace errors. - -### Task 2: Add Redacted WebSocket Opening Audit Snapshot - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/opening.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Write a failing test that builds a WebSocket request with authorization, account, cookie, and ordinary headers, then asserts the audit snapshot preserves request line/header order while redacting secret values. -- [x] Add a small `OpeningAuditSnapshot` type with ordered headers and redacted values. -- [x] Extract snapshot generation from `opening_request_bytes` without changing the emitted opening bytes. -- [x] Keep tokens, cookies, account ids, session ids, request ids, installation ids, turn state, and turn metadata redacted. -- [x] Append an Analysis Journal entry with the code area touched, observed test failure, implementation result, next decision, and verification status. -- [x] Run the focused WebSocket test. -- [x] Commit with message `feat: add websocket opening audit snapshot`. - -**Expected failing command before implementation:** -```bash -cargo test --test codex_gateway websocket_opening_audit_snapshot_should_redact_sensitive_headers -``` - -**Expected initial result:** the named test fails to compile or fails because the audit API does not exist. - -**Expected passing command after implementation:** -```bash -cargo test --test codex_gateway websocket_opening_audit_snapshot_should_redact_sensitive_headers -``` - -**Expected final result:** the named test passes and no runtime WebSocket behavior changes are included. - -### Task 3: Add Redacted `response.create` Payload Audit Snapshot - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Use CodeGraph to follow the `response.create` payload construction path before editing. -- [x] Write a failing test that asserts a payload audit snapshot records top-level JSON key order and structural fields while redacting user prompt/body content. -- [x] Add the smallest helper needed to produce the redacted payload snapshot before the first WebSocket text frame is sent. -- [x] Ensure the helper borrows data where practical and does not clone the full request body except for the audit representation. -- [x] Append an Analysis Journal entry with observed current payload shape and verification status. -- [x] Run the focused WebSocket test. -- [x] Commit with message `feat: add websocket payload audit snapshot`. - -**Expected failing command before implementation:** -```bash -cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content -``` - -**Expected initial result:** the named test fails to compile or fails because the payload audit API does not exist. - -**Expected passing command after implementation:** -```bash -cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content -``` - -**Expected final result:** the named test passes and emitted WebSocket payload bytes remain unchanged outside audit serialization. - -### Task 4: Add Local `codex-proxy-rs` Audit Artifact Writer - -**Files:** -- Modify: `src/config/types.rs` or existing environment/config loader if it already owns local diagnostic flags -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `src/codex/gateway/transport/websocket/opening.rs` -- Add or modify: focused tests under `tests/codex_gateway/` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Inspect existing diagnostics/config patterns before choosing the gate name. -- [x] Write a failing test for an opt-in audit path that writes a redacted JSON artifact to a local directory. -- [x] Gate the writer behind an explicit environment variable or config field, with auditing disabled by default. -- [x] Include transport mode, fallback eligibility, opening snapshot, payload snapshot, and error classification when present. -- [x] Avoid logging bearer tokens, cookies, account ids, user prompt content, and response body content. -- [x] Append an Analysis Journal entry that names the gate and artifact shape. -- [x] Run focused audit tests. -- [x] Commit with message `feat: write redacted websocket audit artifacts`. - -**Expected failing command before implementation:** -```bash -cargo test --test codex_gateway websocket_audit_artifact_should_require_explicit_gate -``` - -**Expected initial result:** the named test fails because no artifact writer/gate exists. - -**Expected passing command after implementation:** -```bash -cargo test --test codex_gateway websocket_audit_artifact_should_require_explicit_gate -``` - -**Expected final result:** audit artifacts are only produced when explicitly enabled. - -### Task 5: Build a Local WebSocket/TLS Capture Harness - -**Files:** -- Add or modify: test support under `tests/support/` if integration-test based -- Add or modify: `tests/codex_gateway/websocket.rs` -- Optionally add: a small binary under `src/bin/` only if tests cannot cover the capture path cleanly -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Write a failing integration test that starts a local WebSocket endpoint and captures opening bytes through the current client path. -- [x] Record request line, header order/casing, `Sec-WebSocket-Extensions`, `Sec-WebSocket-Key` position, and first text frame. -- [x] Capture TLS ClientHello summary when practical; if test TLS interception is not stable, record the blocker in the Analysis Journal and keep HTTP/WS bytes capture as the first artifact. -- [x] Run the current `codex-proxy-rs` request against the harness and write a redacted sample artifact under an ignored local audit directory. -- [x] Append an Analysis Journal entry with artifact path, command, observed current behavior, and verification status. -- [x] Commit with message `test: add websocket capture harness`. - -**Expected failing command before implementation:** -```bash -cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes -``` - -**Expected initial result:** the named test fails because no capture harness exists. - -**Expected passing command after implementation:** -```bash -cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes -``` - -**Expected final result:** the local harness captures current rs opening bytes and first-frame payload without real OpenAI credentials. - -### Task 6: Sample the Official Codex Desktop Bundled Binary - -**Files:** -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` -- Add ignored local artifacts under `.codex-ws-audit/` or another ignored path -- Optionally add harness invocation docs under `docs/superpowers/specs/` if the command is repeatable - -- [x] Confirm the target binary exists at `/tmp/codex-desktop-fingerprint/dmg/Codex Installer/Codex.app/Contents/Resources/codex`. -- [x] Inspect official binary help/config output for API base URL or proxy override flags without using live credentials. -- [x] Try the smallest invocation path that points the official binary to the local capture harness. -- [x] If direct override fails, try a local proxy/DNS interception path that preserves the target URL shape. -- [x] If live execution remains blocked, extract static evidence with `strings`, `otool`/`readelf` as available, and record the blocker explicitly. -- [x] Append an Analysis Journal entry with command, result, official artifact path or blocker, and next decision. -- [x] Commit documentation/artifact index updates with message `docs: record official codex websocket sampling`. - -**Verification commands:** -```bash -test -x "/tmp/codex-desktop-fingerprint/dmg/Codex Installer/Codex.app/Contents/Resources/codex" -"/tmp/codex-desktop-fingerprint/dmg/Codex Installer/Codex.app/Contents/Resources/codex" --help -``` - -**Expected result:** either an official sample artifact is produced by the harness, or the design doc explains the exact blocker and the static evidence used instead. - -### Task 7: Generate a Parity Diff Report - -**Files:** -- Add or modify: small comparison helper if needed -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Write a failing test for a diff helper that compares two redacted audit artifacts and reports stable differences in opening headers, payload fields, TLS summary, and fallback decisions. -- [x] Implement the diff helper with structured JSON parsing instead of string-only comparison. -- [x] Run the helper against a focused redacted fixture and classify current rs artifact versus official static evidence in the Analysis Journal because official live artifact is unavailable on Linux. -- [x] Append an Analysis Journal entry listing each difference and classifying it as `must change`, `observe more`, or `already compatible`. -- [x] Commit with message `feat: add websocket parity diff report`. - -**Expected failing command before implementation:** -```bash -cargo test --test codex_gateway websocket_parity_diff_should_report_header_order_changes -``` - -**Expected passing command after implementation:** -```bash -cargo test --test codex_gateway websocket_parity_diff_should_report_header_order_changes -``` - -**Expected final result:** every proposed behavior change has a documented diff source. - -### Task 8: Align HTTP SSE Fallback Dependency Parity - -**Files:** -- Modify: `Cargo.toml` -- Modify: `Cargo.lock` only through Cargo -- Modify: `src/codex/gateway/transport/http_client.rs` if client construction needs an explicit HTTP/2 setting -- Modify: `tests/codex_gateway/http_client.rs` or focused fallback tests -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record the diff evidence that justifies changing fallback dependency behavior. -- [x] Write or update a test that proves fallback requests still work after enabling `reqwest` `http2`. -- [x] Enable the `reqwest` `http2` feature for fallback parity. -- [x] Run focused HTTP client/fallback tests. -- [x] Append an Analysis Journal entry with the exact feature change and result. -- [x] Commit with message `fix: enable http2 for codex fallback client`. - -**Expected commands:** -```bash -cargo test --test codex_gateway http_client -cargo test --test codex_serving responses_http_sse -``` - -**Expected result:** fallback tests pass and `Cargo.toml` shows `reqwest` with `http2` enabled. - -### Task 9: Align WebSocket Opening and Payload Behavior from Evidence - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/opening.rs` -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] For each `must change` opening diff, write or update a focused failing test before changing code. -- [x] Apply minimal changes for request line, fixed opening headers, business header order/casing, extension negotiation, and payload field shape as supported by the diff. -- [x] Do not change fallback semantics in this task. -- [x] Re-run local audit capture and update the parity diff result. -- [x] Append an Analysis Journal entry with before/after diff status. -- [x] Commit with message `fix: align codex desktop websocket opening`. - -**Expected command:** -```bash -cargo test --test codex_gateway websocket -``` - -**Expected result:** focused WebSocket tests pass and the diff report no longer lists handled opening/payload differences. - -### Task 10: Align WebSocket-to-HTTP Downgrade Semantics - -**Files:** -- Modify: `src/codex/gateway/transport/http_client.rs` -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `tests/codex_serving/responses_websocket.rs` if serving behavior changes -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record the official or strongest available evidence for downgrade behavior. -- [x] Write failing tests for the exact downgrade cases: preferred WS HTTP 426 may fall back, generic network/opening failure does not fall back, `previous_response_id` does not fall back, quota/auth/business errors do not fall back, and early terminal/closed-before-terminal behavior matches the sample. -- [x] Apply minimal fallback classification changes. -- [x] Append an Analysis Journal entry with each downgrade case and verification status. -- [x] Commit with message `fix: align codex desktop websocket fallback`. - -**Expected commands:** -```bash -cargo test --test codex_gateway websocket -cargo test --test codex_serving responses_websocket -cargo test --test codex_serving upstream_fallback -``` - -**Expected result:** downgrade behavior is covered by tests and matches the documented evidence. - -### Task 11: Final Verification and Summary - -**Files:** -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Run formatting, focused tests, full test suite if practical, and Clippy. -- [x] Append final Progress Log and Analysis Journal entries with all commands and results. -- [x] Ensure local audit artifacts containing sensitive or environment-specific data are ignored and not committed. -- [x] Commit final documentation updates with message `docs: summarize codex desktop websocket parity`. - -**Verification commands:** -```bash -cargo fmt --all --check -cargo test --test codex_gateway websocket -cargo test --test codex_serving responses_websocket -cargo test -cargo clippy --all-targets --all-features --locked -- -D warnings -git status --short -``` - -**Expected result:** all feasible checks pass; any skipped full-suite or official-binary sampling limitation is explicitly documented in the design doc. - -### Task 12: Align Binary Frame Handling - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `src/codex/gateway/transport/http_client.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `Message::Binary(_)` returns `unexpected binary websocket event`. -- [x] Add a focused gateway test proving binary JSON is not accepted as a response event. -- [x] Reject binary WebSocket frames instead of UTF-8 decoding them. -- [x] Keep binary-frame errors ineligible for HTTP SSE downgrade. -- [x] Run focused WebSocket gateway/serving checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: align websocket binary frame handling`. - -### Task 13: Align Close Frame Handling - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `src/codex/gateway/transport/http_client.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence for `Message::Close(_)` and stream-end-without-close errors. -- [x] Add gateway coverage for server Close before the first visible event. -- [x] Split local close-before-completed and stream-closed-before-completed errors. -- [x] Preserve stale pooled connection one-shot retry behavior. -- [x] Run focused WebSocket gateway/serving checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: align websocket close frame handling`. - -### Task 14: Ignore Invalid Text Events - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that unparseable text frames are logged and skipped. -- [x] Add a focused gateway test proving invalid text is not forwarded as anonymous SSE. -- [x] Require a parsed typed event before building an SSE chunk. -- [x] Run focused invalid-text test, filtered WebSocket gateway tests, WebSocket serving tests, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: ignore invalid websocket text events`. - -### Task 15: Continue Source-Backed Parity Audit - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Inspect: official `codex-rs/codex-api/src/common.rs` -- Inspect/modify as needed: local WebSocket transport, serving, and gateway tests -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Re-check official `ResponseEvent` side-channel handling against local gateway behavior. -- [x] Capture `response.metadata.headers.x-codex-turn-state` from WebSocket text frames and skip raw metadata forwarding. -- [x] Preserve metadata-derived turn state through non-streaming collection and streaming affinity recording. -- [x] Re-check custom CA / Rustls connector behavior against local WebSocket opening configuration. -- [x] Implement `CODEX_CA_CERTIFICATE` / `SSL_CERT_FILE` custom CA handling for shared HTTP and WebSocket TLS paths. -- [ ] Add failing tests only for differences that are observable locally and backed by official source. -- [ ] Document any remaining unprovable live TLS/opening items separately from implemented parity. - -### Task 17: Align `response.incomplete` Handling - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `src/codex/gateway/transport/http_client.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `response.incomplete` returns `Incomplete response returned, reason: ...`. -- [x] Add a focused gateway test proving `response.incomplete` is a WebSocket stream error, not a forwarded SSE event. -- [x] Parse `incomplete_details.reason` with official `unknown` fallback. -- [x] Keep incomplete response errors ineligible for HTTP SSE fallback. -- [x] Run focused WebSocket gateway/serving checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: align websocket incomplete response handling`. - -### Task 18: Audit Upgrade Header Side Channels - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Inspect: local `src/codex/gateway/transport/websocket/pool.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Compare official `openai-model`, `x-models-etag`, and `x-reasoning-included` handling with local WebSocket metadata. -- [x] Document that these are Desktop-internal `ResponseEvent` side channels without a matching proxy API surface. -- [x] Avoid adding unsupported SSE fields that would change the OpenAI-compatible response surface. - -### Task 19: Align Wrapped WebSocket Error Status Handling - -**Files:** -- Modify: `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `type:error` with non-success `status` / `status_code` maps to transport HTTP error. -- [x] Add a focused gateway test proving wrapped `status: 400` is not forwarded as `event: error`. -- [x] Extend local WebSocket error classification to honor wrapped non-success status values. -- [x] Run focused WebSocket gateway/serving checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: align websocket wrapped error status handling`. - -### Task 20: Audit Responses Event Filtering And Transforms - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Inspect: local `src/codex/gateway/transport/websocket/codec.rs` -- Inspect/modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Enumerate official `process_responses_event` branches and official `run_websocket_response_stream` pre-parse transforms. -- [x] Compare wrapped error handling with local first-frame and forwarding-loop behavior. -- [x] Decide that mid-stream wrapped error forwarding is externally incorrect for this proxy. -- [x] Add a focused red test and code fix for source-backed observable mismatch. -- [x] Record result and verification commands in the design doc. -- [x] Commit with message `fix: align websocket midstream wrapped error handling`. - -### Task 21: Preserve Wrapped Error Retry-After Header - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that wrapped WebSocket errors carry JSON `headers`. -- [x] Add a focused gateway test for `headers.retry-after`. -- [x] Parse wrapped error retry-after before falling back to body-derived reset fields. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: preserve websocket wrapped error retry-after`. - -### Task 22: Align Wrapped Connection Limit Error Precedence - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that wrapped `websocket_connection_limit_reached` is handled before status. -- [x] Add a focused gateway test proving wrapped connection-limit status 400 does not surface as upstream 400. -- [x] Prioritize connection-limit code over wrapped status and map it to the proxy's existing 503 retryable equivalent. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: align websocket wrapped connection limit handling`. - -### Task 23: Ignore Unmapped Wrapped Error Events - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `type:error` with missing/success status maps to `Ok(None)`, not a terminal SSE event. -- [x] Add a focused gateway test proving success-status wrapped error is not returned as successful `event: error`. -- [x] Skip unmapped `type:error` frames in first-frame and forwarding loops. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: ignore unmapped websocket error events`. - -### Task 24: Align `response.failed` Server Overload Handling - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `server_is_overloaded` and `slow_down` map to `ServerOverloaded`. -- [x] Add a focused gateway test proving overloaded `response.failed` is not returned as successful SSE. -- [x] Map overload codes to the proxy's existing 503 upstream error equivalent. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: align websocket server overload failures`. - -### Task 25: Audit Completed Event Side Channels - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: local `src/codex/gateway/transport/websocket/codec.rs` -- Inspect: local `src/codex/serving/dispatch/stream.rs` -- Inspect/modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Compare official `response.completed` side-channel extraction with local metadata extraction. -- [x] Decide whether any missing behavior is observable in the proxy API contract. -- [x] Add failing tests only for source-backed observable differences. -- [x] Record result and verification commands in the design doc. - -### Task 26: Add WebSocket Receive Idle Timeout - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that receive polling uses `idle_timeout`. -- [x] Add a focused gateway test proving a silent upstream WebSocket times out instead of hanging. -- [x] Add a local receive idle timeout for first-frame and forwarding-loop reads. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: add websocket receive idle timeout`. - -### Task 27: Discard WebSocket Connections After Stream Errors - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that terminal stream errors drop the WebSocket connection. -- [x] Add a focused pooled WebSocket test proving classified upstream errors are not returned to the pool. -- [x] Discard active WebSocket connections for all classified upstream error frames. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: discard websocket connections after stream errors`. - -### Task 28: Add WebSocket Send Idle Timeout - -**Files:** -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: local `src/codex/gateway/transport/http_client.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that request-frame send uses `idle_timeout`. -- [x] Add a focused virtual-time test for pending WebSocket send timeout. -- [x] Route production request-frame sends through the timeout helper. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: add websocket send idle timeout`. - -### Task 29: Classify Unknown `response.failed` Errors - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/serving/responses.rs` -- Modify: local `src/codex/serving/dispatch/{stream.rs,fallback.rs,mod.rs}` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `tests/codex_serving/responses_websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that unknown `response.failed` errors map to `ApiError::Retryable`. -- [x] Add a focused gateway test proving unknown `response.failed` is not returned as successful SSE. -- [x] Map unknown `response.failed` errors to the proxy's 503 upstream retryable equivalent. -- [x] Preserve invalid encrypted reasoning replay recovery after WebSocket `response.failed` classification. -- [x] Run focused WebSocket checks, Clippy, formatting, diff check, and full test suite. -- [x] Commit with message `fix: classify unknown websocket response failures`. - -### Task 30: Audit `response.failed` Special Error Classification - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: local `src/codex/gateway/transport/websocket/codec.rs` -- Inspect: local `src/codex/serving/dispatch/{stream.rs,fallback.rs}` -- Inspect/modify: `tests/codex_gateway/websocket.rs` -- Inspect/modify: `tests/codex_serving/{responses_websocket.rs,upstream_fallback.rs}` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record audit start and official/source comparison scope. -- [x] Compare official special-case predicates with local WebSocket and serving classification. -- [x] Add failing tests and code changes only for observable parity gaps. -- [x] Run focused checks and update the design doc with results. -- [x] Commit Task 30 if code/docs changes are justified. - -### Task 31: Parse `response.failed` Retry-After Message Delay - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Inspect/modify: local `src/codex/gateway/transport/http_client.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that rate-limit messages can carry retry delay text. -- [x] Add focused red tests for WebSocket body-derived retry-after from `rate_limit_exceeded` message text. Red result: `retry_after_seconds` was `None` for official `Please try again in 11.054s` wording. -- [x] Implement official-shaped message parsing without applying it to unrelated error codes. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 31 if code/docs changes are justified. Code/docs commit: `f210ae4 fix: parse websocket rate limit retry delays`. - -### Task 32: Reject Malformed `response.completed` Frames - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that malformed `response.completed.response` returns a stream error. -- [x] Add a focused red test proving WebSocket `response.completed` without `response.id` is not treated as success. Red result: local returned successful `event: response.completed` SSE. -- [x] Implement minimal official-shaped validation for completed frames with a present response object. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 32 if code/docs changes are justified. Code/docs commit: `7c9761d fix: reject malformed websocket completed frames`. - -### Task 33: Enforce Official `response.completed.usage` Shape - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseCompletedUsage.total_tokens` is required when `usage` is present. -- [x] Add a focused red test proving WebSocket `response.completed` with incomplete `usage` is not treated as success. Red result: local returned successful SSE and synthesized `total_tokens`. -- [x] Replace the Task 32 minimal `id` check with official-shaped completed-response deserialization. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 33 if code/docs changes are justified. Code/docs commit: `14c0e5c fix: validate websocket completed usage shape`. - -### Task 34: Ignore `response.completed` Without `response` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `response.completed` only completes when `event.response` exists. -- [x] Add a focused red test proving WebSocket `response.completed` without `response` is not returned as success. Red result: local returned successful `event: response.completed` SSE. -- [x] Skip response-less completed frames so a subsequent close surfaces as incomplete stream. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 34 if code/docs changes are justified. Code/docs commit: `3e113e6 fix: ignore websocket completed frames without response`. - -### Task 35: Ignore `response.created` Without `response` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `response.created` only emits when `event.response` exists. -- [x] Add a focused red test proving WebSocket `response.created` without `response` is not forwarded as a successful SSE event. Red result: local response body contained `event: response.created`. -- [x] Skip response-less created frames in both first-frame and forwarding loops. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 35 if code/docs changes are justified. Code/docs commit: `3d10f99 fix: ignore websocket created frames without response`. - -### Task 36: Ignore `response.output_text.delta` Without `delta` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `response.output_text.delta` only emits when `event.delta` exists. -- [x] Add a focused red test proving WebSocket `response.output_text.delta` without `delta` is not forwarded as a successful SSE event. Red result: local response body contained `event: response.output_text.delta`. -- [x] Skip delta-less output-text frames in both first-frame and forwarding loops. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 36 if code/docs changes are justified. Code/docs commit: `14a54a6 fix: ignore websocket text deltas without delta`. - -### Task 37: Ignore Delta Events Missing Official Required Fields - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence for required fields on custom-tool, reasoning-summary, and reasoning-text delta events. -- [x] Add focused red tests proving WebSocket delta events missing official required fields are not forwarded as successful SSE events. Red result: local response body contained `event: response.custom_tool_call_input.delta`. -- [x] Skip malformed delta frames in both first-frame and forwarding loops. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 37 if code/docs changes are justified. Code/docs commit: `c8d332d fix: ignore malformed websocket delta events`. - -### Task 38: Ignore `output_item` Events Without `item` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `response.output_item.done` and `response.output_item.added` only emit when `event.item` exists and parses. -- [x] Add a focused red test proving WebSocket `output_item` events without `item` are not forwarded as successful SSE events. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip item-less `output_item.done` and `output_item.added` frames in both first-frame and forwarding loops. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 38 if code/docs changes are justified. Code/docs commit: `3944b4e fix: ignore websocket output item frames without item`. - -### Task 39: Ignore `response.reasoning_summary_part.added` Without `summary_index` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `response.reasoning_summary_part.added` only emits when `event.summary_index` exists. -- [x] Add a focused red test proving WebSocket `response.reasoning_summary_part.added` without `summary_index` is not forwarded as a successful SSE event. Red result: local response body contained `event: response.reasoning_summary_part.added`. -- [x] Skip summary-index-less `response.reasoning_summary_part.added` frames in both first-frame and forwarding loops. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 39 if code/docs changes are justified. Code/docs commit: `5f17f37 fix: ignore websocket reasoning summary parts without index`. - -### Task 40: Treat JSON `null` Like Missing Official `Option` Fields - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that nullable `ResponsesStreamEvent` fields are `Option` and therefore `null` becomes `None`. -- [x] Add a focused red test proving WebSocket frames with null-valued optional fields follow the same ignore path as missing fields. Red result: local returned `InvalidCompletedResponse` for `response.completed` with `response: null`. -- [x] Treat null `response`, `delta`, and `item` values like missing fields in the existing official-shaped skip predicates. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 40 if code/docs changes are justified. Code/docs commit: `4bbfebc fix: treat null websocket option fields as missing`. - -### Task 41: Ignore Frames That Fail Official `ResponsesStreamEvent` Shape Parsing - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: official `codex-rs/codex-api/src/endpoint/responses_websocket.rs` -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that WebSocket text frames failing `ResponsesStreamEvent` deserialization are ignored before `process_responses_event`. -- [x] Add a focused red test proving a typed frame with an invalid official field type is not forwarded as successful SSE. Red result: local response body contained `event: response.output_text.delta`. -- [x] Add a local official-shape parse guard and skip failing frames before raw SSE forwarding. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 41 if code/docs changes are justified. Code/docs commit: `ffd5ca5 fix: ignore malformed websocket event shapes`. - -### Task 42: Ignore `output_item` Events With Non-Object `item` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: official `codex_protocol::models::ResponseItem` usage evidence from official tests -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `output_item` events only emit when `item` parses as `ResponseItem`, and official examples use object-shaped items. -- [x] Add a focused red test proving non-object `item` values are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip `response.output_item.done` and `response.output_item.added` frames when `item` is present but not an object. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 42 if code/docs changes are justified. Code/docs commit: `45827d0 fix: ignore websocket output item frames with non-object items`. - -### Task 43: Ignore `output_item` Events With Missing or Non-String `item.type` - -**Files:** -- Inspect: official `codex-rs/codex-api/src/sse/responses.rs` -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem` is a `type`-tagged enum and non-string/missing tags cannot parse, while unknown string tags map to `Other`. -- [x] Add a focused red test proving object `item` values with missing or non-string `type` are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip `response.output_item.done` and `response.output_item.added` frames when object `item.type` is absent or not a string. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 43 if code/docs changes are justified. Code/docs commit: `20bf171 fix: ignore websocket output item frames with invalid type tags`. - -### Task 44: Ignore Invalid `message` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::Message` requires string `role` and array `content`. -- [x] Add a focused red test proving malformed `message` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `message` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 44 if code/docs changes are justified. Code/docs commit: `3bbf14c fix: ignore malformed websocket message output items`. - -### Task 45: Ignore Invalid `function_call` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::FunctionCall` requires string `name`, `arguments`, and `call_id`. -- [x] Add a focused red test proving malformed `function_call` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `function_call` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 45 if code/docs changes are justified. Code/docs commit: `5c46b54 fix: ignore malformed websocket function call output items`. - -### Task 46: Ignore Invalid `tool_search_call` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::ToolSearchCall` requires string `execution` and a present `arguments` field, while `arguments` may be any JSON value. -- [x] Add a focused red test proving malformed `tool_search_call` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `tool_search_call` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 46 if code/docs changes are justified. Code/docs commit: `73ce6de fix: ignore malformed websocket tool search output items`. - -### Task 47: Ignore Invalid `function_call_output` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::FunctionCallOutput` requires string `call_id` and an `output` value that parses as `FunctionCallOutputPayload`, whose wire body is a string or array. -- [x] Add a focused red test proving malformed `function_call_output` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `function_call_output` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 47 if code/docs changes are justified. Code/docs commit: `eb48085 fix: ignore malformed websocket function output items`. - -### Task 48: Ignore Invalid `custom_tool_call` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::CustomToolCall` requires string `call_id`, `name`, and `input`. -- [x] Add a focused red test proving malformed `custom_tool_call` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `custom_tool_call` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 48 if code/docs changes are justified. Code/docs commit: `26e94a7 fix: ignore malformed websocket custom tool calls`. - -### Task 49: Ignore Invalid `custom_tool_call_output` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::CustomToolCallOutput` requires string `call_id` and an `output` value that parses as `FunctionCallOutputPayload`, whose wire body is a string or array. -- [x] Add a focused red test proving malformed `custom_tool_call_output` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `custom_tool_call_output` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 49 if code/docs changes are justified. Code/docs commit: `5ac1e56 fix: ignore malformed websocket custom tool outputs`. - -### Task 50: Ignore Invalid `tool_search_output` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::ToolSearchOutput` requires string `status`, string `execution`, and array `tools`. -- [x] Add a focused red test proving malformed `tool_search_output` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `tool_search_output` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 50 if code/docs changes are justified. Code/docs commit: `89d6fb2 fix: ignore malformed websocket tool search outputs`. - -### Task 51: Ignore Invalid `image_generation_call` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::ImageGenerationCall` requires string `id`, string `status`, and string `result`. -- [x] Add a focused red test proving malformed `image_generation_call` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `image_generation_call` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 51 if code/docs changes are justified. Code/docs commit: `5aa33f7 fix: ignore malformed websocket image generation calls`. - -### Task 52: Ignore Invalid `compaction` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::Compaction` accepts `compaction` / `compaction_summary` and requires string `encrypted_content`. -- [x] Add a focused red test proving malformed `compaction` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `compaction` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 52 if code/docs changes are justified. Code/docs commit: `1b2480c fix: ignore malformed websocket compaction items`. - -### Task 53: Ignore Invalid `agent_message` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::AgentMessage` requires string `author`, string `recipient`, and array `content`. -- [x] Add a focused red test proving malformed `agent_message` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `agent_message` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 53 if code/docs changes are justified. Code/docs commit: `c1ab0f3 fix: ignore malformed websocket agent messages`. - -### Task 54: Ignore Invalid `reasoning` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::Reasoning` requires array `summary`, defaults missing `id`, and accepts optional `content` / `encrypted_content` only when their present values parse. -- [x] Add a focused red test proving malformed `reasoning` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `reasoning` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 54 if code/docs changes are justified. Code/docs commit: `8e6cb5d fix: ignore malformed websocket reasoning items`. - -### Task 55: Ignore Invalid `message.content` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` only if a new predicate is needed -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::Message.content` parses as `Vec<ContentItem>`, where known item variants require typed fields and `ImageDetail` values. -- [x] Add a focused red test proving malformed nested `message.content` items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed nested `message.content` items in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 55 if code/docs changes are justified. Code/docs commit: `d02d270 fix: ignore malformed websocket message content items`. - -### Task 56: Ignore Invalid `agent_message.content` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `src/codex/tasks/token_refresh.rs` test helper stability if full-suite virtual-time verification flakes -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::AgentMessage.content` parses as `Vec<AgentMessageInputContent>`, whose known item variants require typed fields. -- [x] Add a focused red test proving malformed nested `agent_message.content` items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed nested `agent_message.content` items in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 56 if code/docs changes are justified. Code/docs commit: `b82c49a fix: ignore malformed websocket agent message content`. - -### Task 57: Ignore Invalid `reasoning.summary` and `reasoning.content` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::Reasoning.summary` and `ResponseItem::Reasoning.content` parse as tagged nested enums with required string `text`. -- [x] Add a focused red test proving malformed nested `reasoning.summary` / `reasoning.content` items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed nested `reasoning.summary` / `reasoning.content` items in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 57 if code/docs changes are justified. Code/docs commit: `14ad08a fix: ignore malformed websocket reasoning content`. - -### Task 58: Ignore Invalid Function Output Content Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `function_call_output.output[]` and `custom_tool_call_output.output[]` parse as `FunctionCallOutputContentItem` tagged items. -- [x] Add a focused red test proving malformed structured function/custom-tool output content items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed structured function/custom-tool output content items in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 58 if code/docs changes are justified. Code/docs commit: `1ffd263 fix: ignore malformed websocket function output content`. - -### Task 59: Ignore Invalid `local_shell_call` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::LocalShellCall` requires `status: LocalShellStatus` and `action: LocalShellAction`, with `exec.command: Vec<String>`. -- [x] Add a focused red test proving malformed `local_shell_call` output items are not forwarded as successful SSE. Red result: local response body contained `event: response.output_item.done`. -- [x] Skip malformed `local_shell_call` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 59 if code/docs changes are justified. Code/docs commit: `0dbe3ce fix: ignore malformed websocket local shell calls`. - -### Task 60: Ignore Invalid `web_search_call` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::WebSearchCall` has optional typed `id`, `status`, and `action`, where known action variants have typed optional fields. -- [x] Add a focused red test proving malformed `web_search_call` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_web_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `web_search_call` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 60 if code/docs changes are justified. Code/docs commit: `9b0b799 fix: ignore malformed websocket web search calls`. - -### Task 61: Ignore Invalid `context_compaction` Output Items - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::ContextCompaction` has optional typed `encrypted_content`. -- [x] Add a focused red test proving malformed `context_compaction` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_context_compaction_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `context_compaction` output-item frames in both WebSocket forwarding paths. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 61 if code/docs changes are justified. Code/docs commit: `87b3d22 fix: ignore malformed websocket context compaction`. - -### Task 62: Ignore Invalid Output Item Metadata - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: local `src/codex/gateway/transport/websocket/mod.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that known `ResponseItem` variants share `metadata: Option<ResponseItemMetadata>` with optional typed `turn_id`. -- [x] Add a focused red test proving malformed output-item metadata is not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_output_item_events_with_invalid_metadata_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip output-item frames whose present `metadata` cannot parse as official `ResponseItemMetadata`. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 62 if code/docs changes are justified. Code/docs commit: `3e633c4 fix: ignore malformed websocket output metadata`. - -### Task 63: Ignore Invalid `message` Optional Output Item Fields - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::Message` has optional typed `id` and `phase: Option<MessagePhase>`. -- [x] Add a focused red test proving malformed `message.id` / `message.phase` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `message` output-item frames whose optional `id` or `phase` fields cannot parse officially. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 63 if code/docs changes are justified. Code/docs commit: `a740cb4 fix: ignore malformed websocket message optional fields`. - -### Task 64: Ignore Invalid `function_call` Optional Output Item Fields - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::FunctionCall` has optional typed `id` and `namespace`. -- [x] Add a focused red test proving malformed `function_call.id` / `function_call.namespace` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_function_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `function_call` output-item frames whose optional `id` or `namespace` fields cannot parse officially. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 64 if code/docs changes are justified. Code/docs commit: `9bbd461 fix: ignore malformed websocket function call optional fields`. - -### Task 65: Ignore Invalid `tool_search_call` Optional Output Item Fields - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::ToolSearchCall` has optional typed `id`, `call_id`, and `status`. -- [x] Add a focused red test proving malformed `tool_search_call.id` / `call_id` / `status` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_tool_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `tool_search_call` output-item frames whose optional `id`, `call_id`, or `status` fields cannot parse officially. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 65 if code/docs changes are justified. Code/docs commit: `6c75142 fix: ignore malformed websocket tool search call optional fields`. - -### Task 66: Ignore Invalid `custom_tool_call` Optional Output Item Fields - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::CustomToolCall` has optional typed `id` and `status`. -- [x] Add a focused red test proving malformed `custom_tool_call.id` / `status` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_custom_tool_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `custom_tool_call` output-item frames whose optional `id` or `status` fields cannot parse officially. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 66 if code/docs changes are justified. Code/docs commit: `f63cc60 fix: ignore malformed websocket custom tool optional fields`. - -### Task 67: Ignore Invalid `custom_tool_call_output` Optional Output Item Fields - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::CustomToolCallOutput` has optional typed `name`. -- [x] Add a focused red test proving malformed `custom_tool_call_output.name` output items are not forwarded as successful SSE. Red result: `cargo test --test codex_gateway websocket_custom_tool_call_output_result_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails because local still forwards `event: response.output_item.done`. -- [x] Skip malformed `custom_tool_call_output` output-item frames whose optional `name` field cannot parse officially. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 67 if code/docs changes are justified. Code/docs commit: `f24276a fix: ignore malformed websocket custom tool output optional fields`. - -### Task 68: Confirm All Remaining WebSocket Parity Differences - -**Files:** -- Inspect: official `codex-api/src/sse/responses.rs` -- Inspect: official `codex-api/src/endpoint/responses_websocket.rs` -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Inspect: local `src/codex/gateway/transport/websocket/codec.rs` -- Inspect: local `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Compare official `ResponsesStreamEvent` top-level shape with local `ResponsesStreamEventShape`. -- [x] Compare every known official `ResponseItem` variant field shape against local output-item predicates. -- [x] Separate remaining findings into `source-backed must change`, `already covered`, `not proxy API surface`, and `needs macOS live capture`. -- [x] Record the full remaining-difference matrix before doing more one-field implementation tasks. - -### Task 69: Close Remaining Source-Backed Optional Output Item Field Gaps - -**Files:** -- Inspect: official `codex-rs/protocol/src/models.rs` from git tree -- Modify: local `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` - -- [x] Record official source evidence that `ResponseItem::ToolSearchOutput.call_id` and `ResponseItem::ImageGenerationCall.revised_prompt` are typed `Option<String>`. -- [x] Add focused red tests proving present non-string `tool_search_output.call_id` and `image_generation_call.revised_prompt` frames are not forwarded by the official-compatible chain. Red result: both focused tests fail because local still forwards `event: response.output_item.done`. -- [x] Skip malformed output-item frames whose remaining optional string fields cannot parse officially. -- [x] Run focused WebSocket checks, formatting, diff check, Clippy, and full test suite. -- [x] Commit Task 69 if code/docs changes are justified. Code/docs commit: `24d9774 fix: ignore remaining malformed websocket optional output fields`. - -### Task 70: Use Linux Repack To Sample Official Desktop Live WebSocket Chain - -**Files/artifacts:** -- Inspect/run: `/home/zyy/桌面/Codes/codex-desktop-linux` (`/home/zyy/Codes/codex-desktop-linux` real path) -- Inspect/use: mihomo controller `127.0.0.1:9090` -- Create local ignored artifacts under `.codex-ws-audit/task70-desktop/` -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` -- Modify: `docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md` - -- [x] Launch the Linux Desktop repack and prove the sampled app-server process is parented by `codex-desktop-linux/codex-app/electron`. -- [x] Capture the official Desktop-launched app-server WebSocket opening request, ordered headers, local upgrade response, and first client text frame via a redacted local endpoint. -- [x] Sample mihomo process attribution for live Desktop network traffic without attempting to decrypt TLS payloads. -- [x] Capture Desktop-launched app-server TLS ClientHello fingerprints through a local TLS endpoint and separate WSS attempts from HTTP fallback. -- [x] Compare the official Desktop artifact against the current rs audit artifact. -- [x] Record findings, caveats, and artifact paths in the Analysis Journal. - -### Task 71: Align Request Shape From Complete live5 Chain - -**Files/artifacts:** -- Inspect: `.codex-ws-audit/live5/flows.all.txt` -- Modify: `src/codex/gateway/transport/websocket/opening.rs` -- Modify: `src/codex/gateway/transport/websocket/codec.rs` -- Modify: `src/codex/gateway/transport/types.rs` -- Modify: `src/codex/serving/responses.rs` -- Modify: `tests/codex_gateway/websocket.rs` -- Modify: `tests/codex_serving/responses_websocket.rs` -- Modify: `src/codex/tasks/token_refresh.rs` test helper stability if full-suite virtual-time verification flakes -- Modify: `docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md` -- Modify: `docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md` - -- [x] Identify the complete live5 model WebSocket flows and compare them against the current rs capture. -- [x] Add focused red tests for live5 WebSocket opening header order/casing and omitted browser-style headers. -- [x] Add focused red tests for first-frame `generate:false` audit/serving forwarding. -- [x] Align WebSocket opening serialization to the Desktop app-server live chain where local request data exists. -- [x] Parse and forward `generate` through the serving request path. -- [x] Run focused gateway and serving tests for the changed request shape. -- [x] Run broader WebSocket gateway/serving checks, formatting, Clippy, and diff check. diff --git a/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md b/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md deleted file mode 100644 index f72ac2e82..000000000 --- a/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md +++ /dev/null @@ -1,48 +0,0 @@ -# OpenAI GPT Codex Parity Design - -## Goal - -Port the OpenAI GPT/Codex compatibility logic from `/home/zyy/桌面/Codes/codex-proxy` into this Rust service while keeping the Rust project intentionally scoped to Codex-backed OpenAI-compatible APIs. - -## Explicit Non-Goals - -- Do not port outbound proxy pools, proxy assignment, or proxy health checks. -- Do not port non-OpenAI/Codex routing, non-OpenAI local model bridges, Electron, frontend UI, or update flows. -- Do not add direct third-party OpenAI API-key provider routing. The `/v1` API remains backed by imported ChatGPT/Codex accounts. - -## Architecture - -The Rust service uses a shared Codex upstream lifecycle for `/v1/responses` and `/v1/chat/completions`. Route-specific code parses and translates client API shapes, then delegates account acquisition, Codex transport, retry/fallback, usage recording, cookie capture, and event logging through v1 services into `codex/upstream`. - -OpenAI Chat compatibility is implemented as a real route, not an alias to Responses. Chat requests translate to Codex Responses requests, and Codex SSE output translates back to OpenAI chat completion JSON or chat completion chunks. - -Responses compatibility keeps native Responses passthrough semantics but adds the TS implementation's in-scope fields: reasoning/service tier, tool choice, parallel tool calls, text formats, prompt cache key, include, client metadata, and Codex context headers. `previous_response_id` uses WebSocket transport rather than being rejected. - -Current progress: Tasks 1-7 are implemented for the OpenAI GPT/Codex scope. Commits `3ecd5b6`, `3960a35`, and `6d18d2c` implement retry/fallback across non-streaming Responses, HTTP SSE setup, non-history WebSocket setup, and Chat Completions while preserving `previous_response_id` account affinity. Commits `1738f24`, `f75db60`, and `4b19846` add persisted backend model snapshots, admin refresh, and runtime model-plan allowlist sync. Commits `0b6cc7e` through `93cfee3` rebuild scoped admin account import/export/mutation/cookie/health/quota operations. Commits `b879dd6`, `0daa96b`, and `efbbe8a` add quota warnings, durable quota/Cloudflare cooldown restore, and Cloudflare-blocked account Cookie clearing. Remaining parity gaps are tracked in the plan/status docs: richer OpenAI-compatible error mapping, live Chat streaming transform, selected Responses field edge cases, durable release-time slot state, quota-window recovery, and background scheduler lifecycle. - -## Components - -- `src/codex/protocol/openai_to_codex.rs`: OpenAI Chat request schema and Chat-to-Codex translation, including messages, tools/functions, multimodal input, response formats, reasoning effort, and service tier. -- `src/codex/protocol/codex_to_openai.rs`: Codex SSE to OpenAI Chat JSON/SSE conversion, including text, reasoning, tool calls, usage, and errors. -- `src/codex/transport/types.rs`: Expanded Codex request/response types for Responses and WebSocket. -- `src/codex/transport/websocket.rs`: WebSocket transport selection and `response.create` conversion into SSE-compatible events for upstream-history requests such as `previous_response_id`. -- `src/codex/transport/client.rs`: HTTP SSE and WebSocket Codex transport, context headers, cookies, and rate-limit header capture. -- `src/codex/upstream/*`: shared account acquire, dispatch, retry/fallback, refresh retry, stream collection, usage recording, cookie persistence, and lifecycle logging. -- `src/http/v1/{chat,responses,models,router}.rs` plus `src/service/{chat,responses}.rs`: separate `/v1/responses` and `/v1/chat/completions` handlers delegating orchestration to services. -- `src/codex/accounts/pool.rs` and repositories: fallback selection, quota/rate-limit updates, durable usage release, and account state mutation hooks. -- `src/codex/models/catalog.rs`: static catalog plus persisted backend snapshots, plan allowlist derivation, and admin refresh trigger. -- `src/http/admin/*.rs`, `src/service/{api_key,settings,usage,log,diagnostics}.rs`, and `src/codex/accounts/service/*`: scoped account/API-key/account-health/quota/cookie/admin model operations needed to operate Codex-backed GPT compatibility. Account import/export now uses only the native Rust format; external legacy and proxy-pool payloads are intentionally removed. - -## Data Flow - -For Chat Completions, the route validates the local client API key, parses the OpenAI request, and delegates to `ChatService`. The service validates the model against the catalog, translates to a Codex request, acquires an account through the shared upstream lifecycle, sends the request, and converts Codex output into OpenAI Chat output. Streaming clients receive OpenAI chat completion chunks and `[DONE]`; non-streaming clients receive a `chat.completion` object. - -For Responses, the route validates the local client API key and delegates to `ResponsesService`. The service parses native Responses fields, builds a Codex request, acquires an account, sends via WebSocket when required, and otherwise streams raw Codex SSE or collects `response.completed` for non-streaming clients. - -## Error Handling - -Client authentication errors stay OpenAI-compatible. Model misses return `model_not_found`. Upstream 401 triggers refresh and one retry for the same account. For non-streaming Responses, HTTP SSE Responses setup, non-history WebSocket setup, and Chat Completions, 429 applies a durable quota cooldown and records the failed attempt, 402 marks quota exhausted, Cloudflare 403 applies a durable cooldown and clears the challenged account Cookies, non-Cloudflare 403 marks banned, and each retryable account-state error tries another imported account when available. `previous_response_id` WebSocket failures remain account-affine. Terminal upstream SSE errors are mapped to OpenAI-compatible errors for non-streaming clients and recorded while streaming clients keep passthrough events. - -## Testing - -Development follows test-first changes. Tests cover translation units, route behavior with `wiremock`, WebSocket transport boundaries, fallback account retry, rate-limit/quota state changes, model refresh cache behavior, and admin account operations. Existing full verification remains `cargo fmt --check`, `cargo test`, and `cargo clippy --all-targets --all-features --locked -- -D warnings`. diff --git a/docs/superpowers/specs/2026-06-13-architecture-refactor-design.md b/docs/superpowers/specs/2026-06-13-architecture-refactor-design.md deleted file mode 100644 index b0126dc50..000000000 --- a/docs/superpowers/specs/2026-06-13-architecture-refactor-design.md +++ /dev/null @@ -1,567 +0,0 @@ -# Architecture Refactor Design: Frontend-Backend Separation - -**Date:** 2026-06-13 -**Status:** Approved -**Priority:** High -**Estimated Effort:** 6-8 days - ---- - -## Problem Statement - -Current architecture issues identified: - -1. **Mixed Responsibilities** - Codex protocol logic, account management, and background tasks are intertwined -2. **Unclear Boundaries** - `service/` and `codex/accounts/service/` have overlapping responsibilities -3. **Large Files** - `http/admin/accounts.rs` has 1697 lines, violating single responsibility principle -4. **Frontend-Backend Coupling** - HTTP request handlers and background schedulers share too much state - -**Core Issue:** The system lacks a clear "frontend-backend" boundary, causing: -- Request handling paths tangled with background maintenance logic -- `AccountService` has too many responsibilities -- Schedulers directly manipulate service layer, high coupling -- Difficult to test and maintain - ---- - -## Goals - -1. **Clear Separation** - Frontend (HTTP) and Backend (tasks) clearly distinguished in directory structure -2. **Single Source of Truth** - Frontend and backend share the same domain logic through `core/` -3. **Better Maintainability** - Files split by responsibility, each 100-500 lines -4. **Explicit Dependencies** - Unidirectional dependency flow, no circular dependencies - ---- - -## Target Architecture - -### Directory Structure (4-Layer Separation) - -``` -src/ -├── core/ # Domain logic (pure business, no external dependencies) -│ ├── accounts/ -│ ├── models/ -│ ├── quota/ -│ └── auth/ -│ -├── api/ # Frontend: all HTTP request handling -│ ├── v1/ # OpenAI-compatible API -│ ├── admin/ # Admin backend API -│ └── middleware/ -│ -├── tasks/ # Backend: all scheduled tasks and maintenance -│ ├── refresh/ # Token refresh -│ ├── quota/ # Quota maintenance -│ ├── models/ # Model sync -│ └── cleanup/ # Cleanup tasks -│ -└── infra/ # Infrastructure layer (technical implementation) - ├── storage/ # SQLite - ├── codex/ # Codex API client - ├── crypto/ # Encryption - └── http/ # HTTP utilities -``` - -**Visual Clarity:** -- `api/` = Synchronous request-response (frontend) -- `tasks/` = Asynchronous background maintenance (backend) -- `core/` = Shared business logic -- `infra/` = Low-level technical support - ---- - -## Detailed Module Design - -### 1. `core/` - Domain Core - -``` -core/ -├── accounts/ -│ ├── domain.rs # Account entity, state machine -│ ├── repository.rs # Data access interface (trait) -│ ├── pool.rs # Account pool logic -│ ├── commands.rs # Write operations: create, update, refresh -│ ├── queries.rs # Read operations: list, get, search -│ └── events.rs # Domain events (optional) -│ -├── models/ -│ ├── catalog.rs # Model catalog -│ ├── repository.rs -│ └── commands.rs -│ -├── quota/ -│ ├── calculator.rs # Quota calculation logic -│ ├── limiter.rs # Rate limiter -│ └── tracker.rs # Usage tracking -│ -└── auth/ - ├── session.rs # Session management - ├── api_key.rs # API Key validation - └── password.rs # Password hashing -``` - -**Responsibility:** Pure business logic, no dependencies on HTTP, database, or timers - -**CQRS Pattern:** - -```rust -// Commands (Write Operations) -pub struct CreateAccountCommand { - pub email: String, - pub access_token: SecretString, - pub refresh_token: Option<SecretString>, -} - -pub struct RefreshTokenCommand { - pub account_id: AccountId, -} - -// Command Handler -pub struct AccountCommandHandler { - repository: Arc<dyn AccountRepository>, - pool: Arc<Mutex<AccountPool>>, - oauth_client: Arc<dyn OAuthClient>, -} - -// Queries (Read Operations) -pub struct ListAccountsQuery { - pub status: Option<AccountStatus>, - pub limit: u32, - pub cursor: Option<String>, -} - -// Query Handler -pub struct AccountQueryHandler { - repository: Arc<dyn AccountRepository>, -} -``` - ---- - -### 2. `api/` - Frontend HTTP - -``` -api/ -├── v1/ -│ ├── router.rs # Route registration -│ ├── chat.rs # POST /v1/chat/completions -│ ├── responses.rs # POST /v1/responses -│ ├── models.rs # GET /v1/models -│ └── auth.rs # API Key auth middleware -│ -├── admin/ -│ ├── router.rs -│ ├── auth.rs # POST /admin/login -│ ├── accounts/ # Account management (split) -│ │ ├── mod.rs # Route registration -│ │ ├── list.rs # GET /admin/accounts -│ │ ├── create.rs # POST /admin/accounts -│ │ ├── update.rs # PATCH /admin/accounts/:id -│ │ ├── delete.rs # DELETE /admin/accounts/:id -│ │ ├── oauth/ # OAuth login flows -│ │ │ ├── pkce.rs -│ │ │ ├── device.rs -│ │ │ └── import.rs -│ │ ├── quota.rs # Quota operations -│ │ ├── health.rs # Health checks -│ │ ├── cookies.rs # Cookie management -│ │ └── batch.rs # Batch operations -│ ├── api_keys.rs -│ ├── models.rs -│ ├── logs.rs -│ └── settings.rs -│ -└── middleware/ - ├── auth.rs - ├── request_id.rs - └── error_handler.rs -``` - -**Responsibility:** HTTP request parsing, authentication, calling `core/`, returning responses - -**Example:** - -```rust -// api/admin/accounts/create.rs -pub async fn create_account( - State(app): State<AppState>, - Json(req): Json<CreateAccountRequest>, -) -> Result<Json<ApiResponse<AccountDto>>> { - // HTTP 请求转换为 Command - let command = CreateAccountCommand { - email: req.email, - access_token: SecretString::new(req.access_token), - refresh_token: req.refresh_token.map(SecretString::new), - label: req.label, - }; - - // 调用核心业务逻辑 - let account = app.core.account_commands.handle(command).await?; - - Ok(Json(ApiResponse::success(account.into()))) -} -``` - ---- - -### 3. `tasks/` - Backend Tasks - -``` -tasks/ -├── refresh/ -│ ├── scheduler.rs # RefreshScheduler -│ ├── strategy.rs # Refresh strategy (exponential backoff) -│ └── handler.rs # Execute refresh logic -│ -├── quota/ -│ ├── refresher.rs # QuotaRefresher -│ └── monitor.rs # Quota monitoring -│ -├── models/ -│ ├── sync.rs # ModelRefresher -│ └── fetcher.rs # Fetch models from Codex -│ -├── cleanup/ -│ ├── sessions.rs # SessionCleanupScheduler -│ └── logs.rs # Log cleanup -│ -└── coordinator.rs # Task coordinator (start/stop all tasks) -``` - -**Responsibility:** Scheduled tasks, background maintenance, calling `core/` to execute operations - -**Example:** - -```rust -// tasks/refresh/handler.rs -pub async fn refresh_account( - account_id: AccountId, - commands: Arc<AccountCommandHandler>, -) -> Result<()> { - let command = RefreshTokenCommand { account_id }; - - // 刷新令牌可能失败(网络、配额、封禁等),失败后会自动标记账户状态 - commands.handle(command).await?; - - Ok(()) -} -``` - ---- - -### 4. `infra/` - Infrastructure - -``` -infra/ -├── storage/ -│ ├── db.rs # Database connection -│ ├── accounts_repo.rs # Implements core/accounts/repository trait -│ ├── models_repo.rs -│ └── migrations/ -│ -├── codex/ -│ ├── client.rs # Codex API client -│ ├── protocol/ # OpenAI ↔ Codex protocol conversion -│ │ ├── chat.rs -│ │ ├── responses.rs -│ │ └── error.rs -│ ├── transport/ -│ │ ├── headers.rs -│ │ ├── sse.rs -│ │ └── websocket.rs -│ └── fingerprint/ -│ ├── model.rs -│ └── updater.rs -│ -├── crypto/ -│ ├── encryption.rs # AES-GCM -│ ├── hashing.rs # Argon2, HMAC -│ └── secrets.rs -│ -└── http/ - ├── client.rs # HTTP client configuration - └── utils.rs -``` - -**Responsibility:** Technical implementation details, called by `core/`, `api/`, `tasks/` - ---- - -## Dependency Relationships - -### Unidirectional Dependencies - -``` -api/ ──┐ - ├──→ core/ ──→ infra/ -tasks/ ──┘ - -main.rs ──→ api/ + tasks/ + core/ + infra/ -``` - -**Rules:** -- ✅ `api/` and `tasks/` can both depend on `core/` -- ✅ `core/` only depends on `infra/` interfaces (traits), not concrete implementations -- ❌ `core/` cannot depend on `api/` or `tasks/` -- ❌ `infra/` cannot depend on any upper layer modules - ---- - -## State Management Refactor - -### Current Problem - -```rust -pub struct AppState { - account_service: Arc<AccountService>, - model_service: Arc<ModelService>, - // ... 10+ services -} -``` - -Every new feature requires modifying `AppState`, and frontend and backend share one large state. - -### Refactored Design - -```rust -// app/state.rs - -pub struct AppState { - pub core: Arc<CoreState>, - pub infra: Arc<InfraState>, -} - -pub struct CoreState { - // Command Handlers (write operations) - pub account_commands: Arc<AccountCommandHandler>, - pub model_commands: Arc<ModelCommandHandler>, - pub quota_commands: Arc<QuotaCommandHandler>, - - // Query Handlers (read operations) - pub account_queries: Arc<AccountQueryHandler>, - pub model_queries: Arc<ModelQueryHandler>, - - // Shared runtime state - pub account_pool: Arc<Mutex<AccountPool>>, - pub session_affinity: Arc<RwLock<SessionAffinityMap>>, -} - -pub struct InfraState { - pub db: SqlitePool, - pub codex_client: Arc<CodexClient>, - pub config: Arc<AppConfig>, - pub encryptor: Arc<SecretEncryptor>, -} - -impl AppState { - pub async fn new(config: AppConfig) -> Result<Self> { - let infra = InfraState::new(config).await?; - let core = CoreState::new(&infra).await?; - - Ok(Self { - core: Arc::new(core), - infra: Arc::new(infra), - }) - } -} -``` - -**Backend Task Usage:** - -```rust -// tasks/coordinator.rs - -pub struct TaskCoordinator { - core: Arc<CoreState>, - infra: Arc<InfraState>, -} - -impl TaskCoordinator { - pub fn new(app: &AppState) -> Self { - Self { - core: app.core.clone(), - infra: app.infra.clone(), - } - } - - pub async fn start_all(&self) -> Vec<TaskHandle> { - vec![ - self.start_refresh_task(), - self.start_quota_task(), - self.start_cleanup_task(), - ] - } - - fn start_refresh_task(&self) -> TaskHandle { - let commands = self.core.account_commands.clone(); - let queries = self.core.account_queries.clone(); - - tokio::spawn(async move { - // 刷新任务只需要 commands 和 queries,不需要整个 AppState - RefreshScheduler::new(commands, queries).run().await - }) - } -} -``` - ---- - -## Startup Flow Refactor - -### `main.rs` Simplified - -```rust -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // 1. 初始化日志 - init_tracing()?; - - // 2. 加载配置 - let config = load_config()?; - - // 3. 构建应用状态 - let app_state = AppState::new(config).await?; - - // 4. 启动后台任务 - let task_coordinator = TaskCoordinator::new(&app_state); - let task_handles = task_coordinator.start_all().await; - - // 5. 启动 HTTP 服务器 - let app = build_router(app_state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?; - - // 6. 优雅关闭 - tokio::select! { - result = axum::serve(listener, app) => { - result?; - } - _ = shutdown_signal() => { - // 停止所有后台任务 - task_coordinator.stop_all(task_handles).await; - } - } - - Ok(()) -} -``` - ---- - -## Migration Plan (Progressive) - -Since this is a large-scale refactor, we'll proceed in phases: - -### Phase 1: Foundation (1-2 days) -1. Create new directory structure: `core/`, `api/`, `tasks/`, `infra/` -2. Migrate `AppState` → `CoreState + InfraState` -3. Implement `AccountCommandHandler` and `AccountQueryHandler` -4. ✅ Ensure compilation passes, keep old code temporarily - -### Phase 2: HTTP Layer Split (2-3 days) -1. Split `admin/accounts.rs` (1697 lines) → `admin/accounts/*` (multiple small files) -2. Migrate to call `core/accounts/commands` and `queries` -3. ✅ Keep API contract unchanged, integration tests pass - -### Phase 3: Backend Task Independence (1-2 days) -1. Create `tasks/` directory structure -2. Migrate `scheduler/` → `tasks/` -3. Implement `TaskCoordinator` -4. ✅ Backend tasks operate data through `CoreState` - -### Phase 4: Cleanup and Optimization (1 day) -1. Delete old `service/` directory -2. Delete duplicate `codex/accounts/service/` -3. Update documentation and tests -4. ✅ All tests pass, code quality checks pass - ---- - -## Before and After Comparison - -### Adding New Feature: Batch Disable Accounts - -**Before Refactor:** -``` -1. Add handler in http/admin/accounts.rs (1697 → 1750 lines) -2. Add business logic in service/xxx.rs -3. Add data operation in codex/accounts/service/mutation.rs -4. May need to sync state in scheduler somewhere -→ Modify 4 files, unclear responsibilities -``` - -**After Refactor:** -``` -1. Add BatchDisableCommand in core/accounts/commands.rs -2. Implement handle() in AccountCommandHandler -3. Add HTTP handler in api/admin/accounts/batch.rs -→ Modify 3 files, each with clear responsibility -→ Frontend and backend automatically share the same logic -``` - ---- - -## Key Improvements - -1. **Frontend-Backend Separation** - - `api/` = Synchronous HTTP requests - - `tasks/` = Asynchronous background maintenance - - `core/` = Shared business logic - -2. **Clear Responsibilities** - - Commands: Write operations, with side effects, require validation - - Queries: Read operations, no side effects, simple and fast - - Pool: Runtime scheduling, no persistence - -3. **Unidirectional Dependencies** - ``` - api/ ──┐ - ├──→ core/ ──→ infra/ - tasks/──┘ - ``` - -4. **Large File Split** - - 1697 lines → multiple 100-300 line files - - Grouped by function (OAuth, Quota, Cookies) - -5. **State Management** - - `CoreState` = Business core (Command/Query Handlers) - - `InfraState` = Technical foundation (DB, Client, Encryptor) - ---- - -## Success Criteria - -1. ✅ All existing tests pass -2. ✅ No file exceeds 800 lines -3. ✅ `api/` and `tasks/` have no direct dependencies on each other -4. ✅ Adding a new feature only requires modifying 2-3 files with clear responsibilities -5. ✅ Code coverage remains above 80% - ---- - -## Risks and Mitigation - -**Risk 1: Breaking Changes During Migration** -- Mitigation: Phase-based migration, keep old code until new code is verified - -**Risk 2: Performance Regression** -- Mitigation: Benchmark before and after, ensure no significant degradation - -**Risk 3: Test Complexity Increase** -- Mitigation: Use trait mocking, write unit tests for `core/` independently - ---- - -## Next Steps - -1. ✅ Design document approved -2. → Create implementation plan (use writing-plans skill) -3. → Execute Phase 1: Foundation -4. → Execute Phase 2: HTTP Layer Split -5. → Execute Phase 3: Backend Task Independence -6. → Execute Phase 4: Cleanup and Optimization - ---- - -**Status:** Ready for implementation diff --git a/docs/superpowers/specs/2026-06-14-backend-naming-architecture-design.md b/docs/superpowers/specs/2026-06-14-backend-naming-architecture-design.md deleted file mode 100644 index 8be8c8a2a..000000000 --- a/docs/superpowers/specs/2026-06-14-backend-naming-architecture-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Backend Naming Architecture Design - -## Goal - -将后端目录整理成一名一义的结构,为 `/admin` Web 控制台预留承载面,并把现有管理 JSON API 收束到 `/api/admin` 的后端契约层。 - -## Scope - -本次设计只覆盖 Rust 后端目录、模块命名、路由边界和测试命名。前端工程由独立目录和独立约定承载,后端只提供 `src/web` 作为构建产物的挂载位置。 - -## Route Boundaries - -```text -/admin/* Web 控制台入口,由 src/web 承载 -/api/admin/* 管理 API,由 src/admin/api 承载 -/v1/* OpenAI-compatible API,由 src/codex/serving 承载 -/auth/openai/callback OpenAI OAuth 回调 -/debug/* 本地诊断 -/health 存活检查 -``` - -`admin` 是管理域,`web` 是浏览器承载面,`codex` 是 Codex 上游内核,`platform` 是跨领域系统能力。任何目录名都应表达唯一责任,避免 `http`、`auth`、`api_key` 这类多义名泄漏到领域边界。 - -## Target Source Tree - -```text -src/ - main.rs - lib.rs - - runtime/{mod.rs, bootstrap.rs, router.rs, state.rs} - runtime/tasks/{mod.rs, coordinator.rs, types.rs} - - web/{mod.rs, router.rs, assets.rs, shell.rs, security.rs} - - admin/{mod.rs, settings.rs} - admin/api/{mod.rs, router.rs, response.rs, session.rs, diagnostics.rs, settings.rs, models.rs, usage.rs} - admin/api/accounts/{mod.rs, list.rs, create.rs, import.rs, export.rs, lifecycle.rs, quota.rs, cookies.rs, oauth.rs, health.rs} - admin/api/client_keys/{mod.rs, list.rs, create.rs, import.rs, export.rs, lifecycle.rs} - admin/api/logs/{mod.rs, query.rs, detail.rs, state.rs} - admin/session/{mod.rs, service.rs, repository.rs} - admin/client_keys/{mod.rs, service.rs} - admin/tasks/{mod.rs, session_cleanup.rs} - - codex/{mod.rs} - codex/accounts/{mod.rs, model.rs, jwt.rs, pool.rs, lifecycle.rs, cloudflare_challenge.rs, usage_snapshots.rs} - codex/accounts/cookies/{mod.rs, jar.rs, repository.rs} - codex/accounts/repository/{mod.rs, accounts.rs, tokens.rs, leases.rs, quotas.rs, usage.rs} - codex/accounts/service/{mod.rs, cookies.rs, health.rs, import.rs, lifecycle.rs, quota.rs, refresh.rs, pool_sync.rs} - - codex/models/{mod.rs, catalog.rs, repository.rs, service.rs} - - codex/gateway/{mod.rs, conversation_identity.rs, installation_id.rs} - codex/gateway/fingerprint/{mod.rs, model.rs, repository.rs, update_checker.rs, updater.rs} - codex/gateway/oauth/{mod.rs, client.rs, codex_cli.rs, refresh.rs, token.rs} - codex/gateway/protocol/{mod.rs, schema.rs, error.rs, openai_to_codex.rs, codex_to_openai.rs} - codex/gateway/transport/{mod.rs, http_client.rs, headers.rs, rate_limits.rs, sse.rs, usage_events.rs} - codex/gateway/transport/websocket/{mod.rs, codec.rs, pool.rs} - - codex/events/{mod.rs, event.rs, repository.rs, service.rs} - codex/usage/{mod.rs, service.rs} - codex/tasks/{mod.rs, model_refresh.rs, quota_refresh.rs, token_refresh.rs} - - codex/serving/{mod.rs, chat.rs, responses.rs, diagnostics.rs} - codex/serving/http/{mod.rs, router.rs, auth.rs, chat.rs, responses.rs, models.rs, diagnostics.rs, errors.rs} - codex/serving/dispatch/{mod.rs, affinity.rs, routing.rs, fallback.rs, limits.rs, account_refresh.rs, stream.rs, stream_audit.rs, usage.rs} - - platform/{mod.rs} - platform/crypto/{mod.rs, secret_box.rs} - platform/http/{mod.rs, auth.rs, health.rs, request_id.rs} - platform/identity/{mod.rs, admin_session.rs, client_key.rs, client_key_repository.rs, error.rs} - platform/storage/{mod.rs, db.rs, paths.rs, schema.sql} - platform/logging/{mod.rs, rotation.rs} - - config/{mod.rs, loader.rs, types.rs} - utils/{mod.rs, json.rs, pagination.rs} -``` - -## Naming Decisions - -`admin/http` becomes `admin/api` because the module exposes the management API contract, not generic HTTP utilities. - -`admin/auth` splits into `admin/session` and `admin/client_keys`. Admin login is session management; local `cpr_` keys are client credentials for `/v1/*`, not upstream provider keys. - -`api_keys` becomes `client_keys` where the code manages local client credentials. The route can remain `/api/admin/api-keys` for product compatibility, but Rust modules should state the stronger domain meaning. - -`codex/accounts/models` moves to `codex/models`. Model catalog and plan snapshots are Codex catalog concerns, not account-owned state. - -`codex/logs` becomes `codex/events`. The SQLite event stream is a business event store used by the admin console; process file logging belongs under `platform/logging`. - -`platform/http/middleware.rs` becomes `platform/http/request_id.rs` because it has one stable responsibility. - -## Migration Policy - -Implementation should be mechanical first: move files, update module declarations, update imports, then run formatting and tests. Business logic changes are out of scope unless required to preserve existing behavior under the new route boundary. - -The migration must keep `/v1/*` OpenAI-compatible behavior unchanged and keep admin session auth separate from local client API keys. diff --git a/docs/superpowers/specs/2026-06-14-logging-refactor-design.md b/docs/superpowers/specs/2026-06-14-logging-refactor-design.md deleted file mode 100644 index 685369e97..000000000 --- a/docs/superpowers/specs/2026-06-14-logging-refactor-design.md +++ /dev/null @@ -1,51 +0,0 @@ -# Logging Refactor Design - -**Date:** 2026-06-14 -**Status:** Approved - -## Goal - -Bring runtime logging in line with Rust community practice while the project is still in active refactor. The logging path should be simple, structured, safe to query, and free of compatibility code from the earlier custom rotation implementation. - -## Decisions - -- Use `tracing`, `tracing-subscriber`, `tracing-appender`, and `tower-http::trace::TraceLayer` as the runtime file logging stack. -- Remove the custom `RotatingLogWriter` and size-based `max_file_bytes` configuration. File logs rotate daily through `tracing-appender`, and retention is controlled by `retention_days`. -- Keep SQLite `EventLogRepository` and `LogService`. Those records power `/admin/logs` and business event queries; they are not the same concern as process file logs. -- HTTP tracing must create structured spans with `request_id`, `method`, and `uri`, then record completion with `status` and `latency_ms`. -- Log messages are written in Chinese. Technical terms such as `WebSocket`, `HTTP SSE`, `SQLite`, and `request_id` may remain in English when that is clearer. -- Structured field names remain English because they are machine-facing query keys for JSON logs and log systems such as Loki or Elasticsearch. -- Logs must not include access tokens, refresh tokens, cookies, full headers, or request/response bodies by default. - -## Runtime Shape - -`main.rs` initializes tracing once and keeps the non-blocking log guard alive for the whole process. The subscriber writes JSON lines to a daily rolling appender under `logging.directory`. - -Each HTTP request receives an `x-request-id` through the existing middleware. `TraceLayer` then attaches that request id to the request span and emits Chinese lifecycle messages without logging sensitive headers or bodies. - -Upstream Codex calls add focused spans around transport choices. Expected fallback, such as WebSocket being unavailable for a non-history request, is an `info` event. Recoverable persistence failures remain `warn`, and unrecoverable server failures remain `error`. - -## Configuration - -The retained logging configuration is: - -```yaml -logging: - directory: logs - retention_days: 14 - enabled: false - capacity: 2000 - capture_body: false -``` - -`retention_days` maps to the number of daily log files retained by `tracing-appender`. `enabled`, `capacity`, and `capture_body` continue to belong to the SQLite admin event log subsystem. - -`max_file_bytes` is removed instead of deprecated. This avoids a configuration field that appears to work but no longer maps to the community appender behavior. - -## Validation - -- Config loading rejects the removed `max_file_bytes` field. -- File logging uses the `codex-proxy-rs.<date>.log` naming produced by `tracing-appender`. -- HTTP trace output includes `request_id`, `method`, `uri`, `status`, and `latency_ms`. -- All source `tracing` messages are Chinese, with professional English terms preserved where appropriate. -- `cargo fmt --check`, `cargo test`, and `cargo clippy --all-targets --all-features --locked -- -D warnings` must pass before committing. diff --git a/docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md b/docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md deleted file mode 100644 index 05a274158..000000000 --- a/docs/superpowers/specs/2026-06-16-codex-desktop-ws-parity-design.md +++ /dev/null @@ -1,3088 +0,0 @@ -# Codex Desktop Responses WebSocket Parity Design - -Date: 2026-06-16 - -## Objective - -Reproduce the observable official Codex Desktop model request chain for Responses WebSocket 1:1 in `codex-proxy-rs`, using the official DMG bundled Rust binary as the target: - -`/tmp/codex-desktop-fingerprint/dmg/Codex Installer/Codex.app/Contents/Resources/codex` - -The target chain is the bundled `codex` Rust engine's `codex-api/src/endpoint/responses_websocket.rs`, not Electron `net.fetch` and not the webview/browser WebSocket path. - -## Scope - -This work covers: - -- Responses WebSocket connection behavior. -- WebSocket opening handshake bytes. -- TLS ClientHello and rustls configuration visible from the client side. -- Business headers used during the WebSocket upgrade. -- First-frame `response.create` JSON payload. -- WebSocket-to-HTTP SSE fallback behavior. -- HTTP SSE fallback dependency/config parity where it affects the downgrade path. - -This work does not cover Electron `net.fetch`, dictation/realtime WebSocket, app-server/local IPC WebSocket, remote-control WebSocket, or visual UI behavior. - -## Current Evidence - -Official Desktop evidence collected from the DMG: - -- App version: `26.609.71450`. -- Bundle version: `3965`. -- Bundle id: `com.openai.codex`. -- Bundled `Contents/Resources/codex` contains: - - `codex-api/src/endpoint/responses_websocket.rs` - - `codex-api/src/endpoint/responses.rs` - - `tokio-tungstenite` - - `tungstenite` - - `tokio-rustls-0.26.4` - - `rustls-0.23.36` - - `reqwest-0.12.28` - - `reqwest-0.13.4` - - `hyper-rustls-0.27.7` - - `response.create` - - `previous_response_id` - -Current `codex-proxy-rs` evidence: - -- `/v1/responses` defaults to `WebSocketPreferred` unless `force_http_sse` is set. -- `previous_response_id` forces `WebSocketRequired`. -- WebSocket opening is manually written as HTTP/1.1 upgrade. -- WebSocket opening currently uses `tokio-rustls` and `rustls 0.23.36`. -- WebSocket opening emits `Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits`. -- HTTP fallback currently uses `reqwest 0.12.28` without the `http2` feature. - -CodeGraph baseline: - -- `transport_for_request` is at `src/codex/gateway/transport/websocket/mod.rs:160`. -- `connect_with_original_opening_handshake` is at `src/codex/gateway/transport/websocket/opening.rs:59`. -- `create_response_via_websocket` is at `src/codex/gateway/transport/websocket/mod.rs:184`. -- `CodexBackendClient::create_response` dispatches WS-first then HTTP fallback at `src/codex/gateway/transport/http_client.rs:162`. -- CodeGraph index was initialized on 2026-06-16 and can be refreshed with `codegraph sync .`. - -## Design - -### Audit-First Workflow - -The implementation must not make behavioral parity changes from static guesses alone. Each change needs an audit record that states: - -- observed current `codex-proxy-rs` behavior, -- observed official `codex` behavior or the reason direct observation is not available, -- exact difference, -- proposed code change, -- verification command. - -All audit records are appended to the "Analysis Journal" section of this document before the related code change is made. - -### Local WebSocket Audit Harness - -Add a local audit harness that accepts a test WebSocket target and records: - -- TLS ClientHello summary: SNI, ALPN, cipher suites, supported groups, signature algorithms, TLS extensions, JA4-style stable summary when available. -- HTTP/1.1 upgrade raw bytes up to the blank line. -- Header order and casing. -- `Sec-WebSocket-Key` position and generated-key behavior. -- `Sec-WebSocket-Extensions`. -- First WebSocket data frame payload. - -The harness should run locally and must not require real OpenAI credentials. It may use a local certificate for TLS observation, or a passive capture path if the implementation proves simpler and more reliable. - -### Official Binary Sampling - -Use the official bundled binary as the target reference. The first phase is to determine the smallest practical invocation path that makes it open a `responses_websocket` connection to a controlled endpoint. - -Acceptable sampling paths, in priority order: - -1. Run the official `codex` binary with environment/config overrides that point API base URL to the local audit harness. -2. If direct override is unavailable, intercept DNS/proxy locally while preserving the target URL shape. -3. If live execution is blocked, extract more static evidence from the binary and mark the missing observation explicitly in the Analysis Journal. - -### `codex-proxy-rs` Sampling - -Add an internal audit mode gated by configuration or environment variable. It should record local-only artifacts for the current implementation: - -- opening request bytes from `opening_request_bytes`, -- ordered business headers passed to the opening request, -- `response.create` JSON before it is sent, -- selected transport mode and fallback decision, -- error classification when fallback occurs. - -The audit mode must redact tokens, cookies, account ids, and request body content that can contain user data. Structural fields, header names, and redacted sentinel values may be logged. - -### Parity Changes - -Once both sides are sampled, apply minimal changes in this order: - -1. TLS and dependency parity: - - confirm rustls provider and features, - - align `tokio-rustls` / `tokio-tungstenite` behavior where practical, - - enable `reqwest` `http2` for HTTP fallback. -2. WebSocket opening parity: - - request line, - - fixed opening headers, - - business header order and casing, - - extension negotiation. -3. Payload parity: - - JSON field order where the serializer path controls it, - - default fields, - - `client_metadata`, - - `prompt_cache_key`, - - `previous_response_id`. -4. Fallback parity: - - network/opening failures may fall back when the request is only `WebSocketPreferred`, - - upstream WS availability statuses may fall back, - - `previous_response_id` never falls back, - - quota/auth/business errors do not fall back, - - early terminal/closed-before-terminal behavior follows the official sample. - -### Verification - -Required verification commands after implementation: - -- `cargo fmt --all --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- Focused tests for WebSocket default routing and fallback behavior. -- A sample audit run that produces both `rs` and official reference artifacts, or a documented blocker in the Analysis Journal. - -## Progress Log - -- 2026-06-16: User selected target A: official DMG bundled `Contents/Resources/codex` Rust `responses_websocket` chain. -- 2026-06-16: User selected approach 1: dual-end audit comparison before parity changes. -- 2026-06-16: CodeGraph initialized in `/home/zyy/Codes/codex-proxy-rs`; index contains 285 files, 3,933 nodes, and 12,834 edges. -- 2026-06-16: Design document created. No production code has been changed yet. -- 2026-06-16: Spec self-review completed; no incomplete placeholders or scope contradictions remain. -- 2026-06-17: Resumed implementation work from the durable design doc. CodeGraph reports the index is up to date with 285 files, 3,933 nodes, and 12,834 edges. Next step is to write the implementation plan before changing production code. -- 2026-06-17: Implementation plan written at `docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md`. `git diff --check` passed for the documentation checkpoint. -- 2026-06-17: Documentation checkpoint committed as `0dbcbc9 docs: plan codex desktop websocket parity`. Task 2 is starting with a failing test for redacted WebSocket opening audit snapshots. -- 2026-06-17: Task 2 implementation completed locally. Added redacted opening audit snapshot API, preserved existing opening byte behavior, and passed focused WebSocket tests plus targeted Clippy. -- 2026-06-17: Task 2 committed as `4bc1b6d feat: add websocket opening audit snapshot`. Task 3 is starting with CodeGraph inspection of the `response.create` payload path. -- 2026-06-17: Task 3 implementation completed locally. Added redacted payload audit snapshot API and passed focused payload/WebSocket tests plus targeted Clippy. -- 2026-06-17: Task 3 committed as `0e56cb1 feat: add websocket payload audit snapshot`. Task 4 is starting with inspection of existing diagnostics/config patterns for an explicit audit artifact gate. -- 2026-06-17: Task 4 implementation completed locally. Added `CODEX_PROXY_WS_AUDIT_DIR` gated JSON artifact writing for redacted rs WebSocket opening/payload attempts and ignored `.codex-ws-audit/`. -- 2026-06-17: Task 4 committed as `e869260 feat: write redacted websocket audit artifacts`. Task 5 is starting with a local capture harness for actual rs opening bytes and first WebSocket frame. -- 2026-06-17: Task 5 implementation completed locally. Added a local WebSocket capture harness test and generated redacted rs sample artifact at `.codex-ws-audit/rs-current-capture.json`. -- 2026-06-17: Task 5 committed as `95221cf test: add websocket capture harness`. Task 6 is starting with official bundled `codex` binary existence/help/config inspection. -- 2026-06-17: Task 6 inspection completed locally. Official bundled binary exists but is macOS arm64 Mach-O and cannot be executed on this Linux host; static evidence was extracted with `strings` instead. -- 2026-06-17: Task 6 committed as `55ec710 docs: record official codex websocket sampling`. Task 7 is starting with a structured parity diff helper for redacted audit artifacts. -- 2026-06-17: Task 7 implementation completed locally. Added structured `websocket_parity_diff` support for redacted audit artifacts and classified current rs versus official static evidence. -- 2026-06-17: Task 7 committed as `4fdbd45 feat: add websocket parity diff report`. Task 8 is starting to enable `reqwest` `http2` for HTTP SSE fallback parity. -- 2026-06-17: Task 8 committed as `e96cab2 fix: enable http2 for codex fallback client`. Task 9 is starting with CodeGraph-assisted review of WebSocket opening/payload evidence. -- 2026-06-17: Task 9 committed as `e4ffdd5 fix: align codex desktop websocket opening`. Task 10 is starting with official-source downgrade evidence review. - -## Analysis Journal - -### 2026-06-16 Initial Chain Assessment - -The earlier Electron `net.fetch` fingerprint remains useful for Desktop HTTP fetches but is not the target for model Responses parity. The official bundled `codex` binary includes `responses_websocket`, `tokio-tungstenite`, and `rustls`, which makes it the correct target for `/v1/responses` WebSocket parity. - -Current `codex-proxy-rs` already follows the same broad transport choice: Responses defaults to WebSocket, `previous_response_id` forces WebSocket, and only preferred WebSocket requests may fall back to HTTP SSE. The remaining work is not to change the high-level direction; it is to prove and align the low-level opening, TLS, payload, and downgrade details. - -### 2026-06-16 Current Known Gaps - -The HTTP fallback path lacks `reqwest` `http2`, so fallback does not yet match the bundled Rust HTTP stack closely enough. The WebSocket path uses `rustls` with the `ring` provider, while the official binary contains both `aws-lc-rs` and `ring` symbols; the active provider must be verified by observation or stronger binary evidence. Header and payload order are currently modeled from local assumptions and must be compared against official samples. - -### 2026-06-17 Resume Notes - -Command/context touched: loaded the Rust best-practices guidance, read this design doc, checked an existing implementation-plan format, and ran `codegraph status .`. - -Observed result: no production implementation exists yet for the audit harness, official sampling, rs sampling, or parity diff. The repository status was clean at resume time. - -Next decision: create `docs/superpowers/plans/2026-06-17-codex-desktop-ws-parity.md` with test-first implementation tasks and use it as the execution checklist. - -Verification status: documentation-only checkpoint; no Rust tests required yet. - -### 2026-06-17 Implementation Plan Checkpoint - -Command/code area touched: created the implementation plan and ran `git diff --check`. - -Observed result: the plan now defines the audit-first sequence, including redacted WebSocket opening snapshots, payload snapshots, local rs capture, official bundled binary sampling, structured parity diffing, and evidence-backed behavior changes. Whitespace verification passed. - -Next decision: commit the documentation checkpoint, then start Task 2 by writing a failing test for redacted WebSocket opening audit snapshots. - -Verification status: `git diff --check` passed; no production Rust code has changed. - -### 2026-06-17 Task 2 Start - -Command/code area touched: will edit `src/codex/gateway/transport/websocket/opening.rs` and focused WebSocket tests to expose an audit-only snapshot of the opening request. - -Observed result: current opening logic serializes request bytes privately through `opening_request_bytes`, with fixed WebSocket headers, `Sec-WebSocket-Key`, and ordered business headers. No redacted structured audit representation exists yet. - -Next decision: add a failing test first for header order preservation and sensitive header redaction, then implement the smallest audit helper without changing emitted opening bytes. - -Verification status: pending focused test failure. - -### 2026-06-17 Task 2 Failing Test - -Command/code area touched: added `websocket_opening_audit_snapshot_should_redact_sensitive_headers` to `tests/codex_gateway/websocket.rs` and ran `cargo test --test codex_gateway websocket_opening_audit_snapshot_should_redact_sensitive_headers`. - -Observed result: the test failed to compile with unresolved import `websocket_opening_audit_snapshot`, which is the expected red state for the new audit API. - -Next decision: implement `OpeningAuditSnapshot` and `websocket_opening_audit_snapshot` near `opening_request_bytes`, sharing the same ordered header source used by actual opening byte serialization. - -Verification status: expected failing test captured. - -### 2026-06-17 Task 7 Implementation Result - -Command/code area touched: modified `src/codex/gateway/transport/websocket/audit.rs`, `src/codex/gateway/transport/websocket/mod.rs`, and `tests/codex_gateway/websocket.rs`. - -Observed result: `websocket_parity_diff` now compares redacted audit artifacts structurally and reports differences as JSON values with stable paths. Covered paths include `transport_mode`, `fallback_allowed`, `opening.request_line`, `opening.header_order`, `opening.sec_websocket_extensions`, `payload.top_level_keys`, and `error.classification`. The focused test verifies that opening header order changes are reported without string-only artifact comparison. - -Current classification from available evidence: - -- `already compatible`: current rs and official static evidence both target the ChatGPT/Codex Responses WebSocket path. Official strings include `https://chatgpt.com/backend-api/codex`, `responses_websocket`, `responses_websockets=2026-02-06`, and `permessage-deflate`; current rs sample uses `GET /codex/responses HTTP/1.1`, `OpenAI-Beta: responses_websockets=2026-02-06`, and `Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits`. -- `already compatible`: current rs WebSocket stack dependency versions match the official static evidence for the critical Rust layer: `tokio-rustls-0.26.4`, `rustls-0.23.36`, and `tokio-tungstenite`. -- `observe more`: exact official opening header order/casing, dynamic `Sec-WebSocket-Key` placement, first-frame JSON key order, TLS ClientHello/ALPN/cipher ordering, and terminal/error/fallback edge cases still require an official live artifact or a macOS execution environment. -- `must change`: HTTP SSE fallback still lacks the `reqwest` `http2` feature in current rs, while the official binary contains `hyper-rustls-0.27.7` and `reqwest-0.12.28`/`0.13.4`; Task 8 will make the minimal fallback dependency parity change and verify focused fallback tests. - -Next decision: commit Task 7, then start Task 8 by enabling `reqwest` `http2` for the HTTP SSE fallback path with tests first or focused fallback verification. - -Verification status: `cargo test --test codex_gateway websocket_parity_diff_should_report_header_order_changes`, `cargo test --test codex_gateway websocket`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `cargo fmt --all --check` passed. Full official artifact comparison remains unavailable on this Linux host because the official target is macOS arm64 Mach-O. - -### 2026-06-17 Task 8 Start - -Command/code area touched: will inspect `Cargo.toml`, `src/codex/gateway/transport/http_client.rs`, and focused HTTP/fallback tests before changing dependency features. - -Observed result: Task 7 classified fallback dependency parity as `must change`: current rs uses `reqwest =0.12.28` without the `http2` feature, while official static evidence includes `hyper-rustls-0.27.7` and `reqwest-0.12.28`/`0.13.4`. - -Next decision: enable the `reqwest` `http2` feature with minimal dependency churn, then run focused HTTP client and serving fallback/SSE tests. - -Verification status: pending dependency inspection and focused tests. - -### 2026-06-17 Task 8 TDD Setup - -Command/code area touched: inspected `Cargo.toml`, `src/codex/gateway/transport/http_client.rs`, and existing focused tests under `tests/codex_gateway/http_client.rs`, `tests/codex_serving/responses_http_sse.rs`, and `tests/codex_serving/upstream_fallback.rs`. - -Observed result: `build_reqwest_client(false)` currently leaves protocol negotiation to `reqwest`, and `build_reqwest_client(true)` remains explicitly `http1_only()` for tests that capture raw HTTP/1.1 header order. The missing parity item is the disabled `reqwest` `http2` feature, not a client-construction option change. - -Next decision: add a focused compile-time test that references a `reqwest` HTTP/2-only builder API before enabling the feature. The expected red state is a compile error showing that the method is unavailable without `reqwest/http2`. - -Verification status: red test confirmed with `cargo test --test codex_gateway reqwest_http2_feature_should_be_enabled_for_fallback_parity`; compile failed with `E0599` because `ClientBuilder::http2_prior_knowledge` is unavailable. - -### 2026-06-17 Task 8 Feature Change - -Command/code area touched: will update the `reqwest` dependency feature list in `Cargo.toml`. - -Observed result: the red test proves current dependency features do not expose HTTP/2-specific builder APIs. Existing fallback request behavior is covered by `tests/codex_gateway/http_client.rs`, `tests/codex_serving/responses_http_sse.rs`, and `tests/codex_serving/upstream_fallback.rs`. - -Next decision: add `"http2"` to `reqwest` features only; do not force HTTP/2 in `build_reqwest_client(false)` and do not alter `build_reqwest_client(true)` because raw header-order tests intentionally require HTTP/1.1. - -Verification status: `cargo test --test codex_gateway reqwest_http2_feature_should_be_enabled_for_fallback_parity` passed after the feature change; `Cargo.lock` only added `h2` to the `reqwest` package dependencies; `cargo check --locked` passed. - -### 2026-06-17 Task 8 Focused Verification Start - -Command/code area touched: will run focused HTTP client and serving fallback suites after enabling `reqwest/http2`. - -Observed result: the parity change is currently limited to `Cargo.toml`, `Cargo.lock`, and the new feature exposure test in `tests/codex_gateway/http_client.rs`. - -Next decision: run `cargo test --test codex_gateway http_client`, `cargo test --test codex_serving responses_http_sse`, and `cargo test --test codex_serving upstream_fallback`, followed by formatting/lint checks. - -Verification status: focused suites passed: -- `cargo test --test codex_gateway http_client`: 8 passed. -- `cargo test --test codex_serving responses_http_sse`: 28 passed. -- `cargo test --test codex_serving upstream_fallback`: 27 passed. - -Next decision: run `cargo fmt --all --check`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `git diff --check`, then commit Task 8 if clean. - -### 2026-06-17 Task 8 Final Verification - -Command/code area touched: finalized `Cargo.toml`, `Cargo.lock`, `tests/codex_gateway/http_client.rs`, and Task 8 documentation. - -Observed result: `reqwest =0.12.28` now enables `"http2"` with `default-features = false`. `Cargo.lock` only records the resulting `h2` dependency under `reqwest`. The fallback client construction remains unchanged: normal fallback can negotiate protocol through `reqwest`, while explicit `force_http11` test paths still call `http1_only()`. - -Next decision: commit Task 8 as `fix: enable http2 for codex fallback client`, then continue Task 9 without changing WebSocket opening/payload behavior unless stronger official evidence is available. - -Verification status: passed `cargo test --test codex_gateway reqwest_http2_feature_should_be_enabled_for_fallback_parity`, `cargo check --locked`, `cargo test --test codex_gateway http_client`, `cargo test --test codex_serving responses_http_sse`, `cargo test --test codex_serving upstream_fallback`, `cargo fmt --all --check`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `git diff --check`. - -### 2026-06-17 Task 9 Start - -Command/code area touched: will initialize/sync CodeGraph and inspect WebSocket opening/payload code plus current redacted audit artifact before making any behavior change. - -Observed result: Task 7's parity classification left WebSocket opening and payload diffs in `observe more`, not `must change`, because official live opening bytes and first-frame payload are unavailable on this Linux host. Task 8 handled the only current `must change` item: HTTP SSE fallback `reqwest/http2`. - -Next decision: use CodeGraph to map the WebSocket opening/payload call path, re-run the local audit capture, and only modify `src/codex/gateway/transport/websocket/*` if a supported `must change` diff is found. If no such diff exists, close Task 9 with a documented no-op decision instead of inventing parity changes. - -Verification status: pending CodeGraph index and local audit refresh. - -### 2026-06-17 Task 9 Payload Evidence - -Command/code area touched: ran CodeGraph exploration for `websocket_request_body`, `connect_with_original_opening_handshake`, and `websocket_parity_diff`; refreshed `.codex-ws-audit/rs-current-capture.json` with `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes`; fetched official `openai/codex` source files `codex-api/src/endpoint/responses_websocket.rs`, `codex-api/src/common.rs`, and `core/src/client.rs`. - -Observed result: official source path and symbols match the bundled binary evidence (`codex-api/src/endpoint/responses_websocket.rs`, `responses_websocket`, `response_create_client_metadata`, `build_ws_client_metadata`). Official `ResponsesWebsocketConnection::stream_request` serializes the first frame with `serde_json::to_string(ResponsesWsRequest)`, where `ResponsesWsRequest` is `#[serde(tag = "type")]` and `ResponseCreateWsRequest` field order is `model`, `instructions`, `previous_response_id`, `input`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`, `store`, `stream`, `include`, `service_tier`, `prompt_cache_key`, `text`, `generate`, `client_metadata`. Current rs builds a `serde_json::Value` object and sends `payload.to_string()`, which sorts object keys under the current `serde_json` configuration and omits default empty/null fields such as `tools`, `reasoning`, and `include`. - -Next decision: classify payload wire serialization as `must change`; add a failing test that asserts official first-frame key order and default field presence before changing `src/codex/gateway/transport/websocket/codec.rs`. - -Verification status: red tests confirmed: -- `cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content` failed because current `top_level_keys` are sorted as `serde_json::Value` object keys instead of official struct order. -- `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes` failed because the current wire payload omits default `tools: []`, `reasoning: null`, and `include: []` fields. - -### 2026-06-17 Task 9 Payload Implementation Start - -Command/code area touched: will modify `src/codex/gateway/transport/websocket/codec.rs` and `src/codex/gateway/transport/websocket/mod.rs`. - -Observed result: the opening handshake remains supported by existing source/static evidence, but first-frame payload serialization has a concrete official-source delta. - -Next decision: add an internal serializable `ResponsesWsRequest`/`ResponseCreateWsRequest` shape matching official field order, send `serde_json::to_string(...)` output on the WebSocket, and keep audit redaction derived from the same shape. - -Verification status: implementation completed locally. Added official-order WebSocket payload serialization, switched the send path to the serialized text, updated audit `top_level_keys`, and classified payload encoding failures as non-fallback local errors. - -### 2026-06-17 Task 9 Payload Verification - -Command/code area touched: modified `src/codex/gateway/transport/websocket/codec.rs`, `src/codex/gateway/transport/websocket/mod.rs`, `src/codex/gateway/transport/websocket/audit.rs`, `src/codex/gateway/transport/http_client.rs`, `tests/codex_gateway/websocket.rs`, and `tests/fixtures/responses/golden/reasoning_replay_request.json`. - -Observed result: refreshed `.codex-ws-audit/rs-current-capture.json` now reports first-frame `top_level_keys` in official order and includes default `tools`, `reasoning`, and `include` fields. Opening bytes remain unchanged from the previous local capture. - -Next decision: run final formatting/lint checks and commit Task 9 as `fix: align codex desktop websocket opening`, with the note that the actual code change is payload serialization parity; opening remains unchanged because no supported opening `must change` diff was found. - -Verification status: passed `cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content`, `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes`, `cargo test --test codex_gateway websocket`, `cargo test --test codex_serving responses_websocket`, and `cargo test --test codex_serving upstream_fallback`. - -### 2026-06-17 Task 9 Final Verification - -Command/code area touched: finalized WebSocket payload serialization parity and Task 9 documentation. - -Observed result: no opening-byte code change was made because official live opening order remains unavailable and no supported `must change` opening diff was found. The implemented delta is first-frame `response.create` wire serialization parity against official source and bundled-binary symbols. - -Next decision: commit Task 9, then continue to Task 10 downgrade semantics review. - -Verification status: passed `cargo fmt --all --check`, `cargo clippy --test codex_gateway --locked -- -D warnings`, `cargo test --test codex_gateway http_client`, and `git diff --check` in addition to the focused WebSocket/serving suites above. - -### 2026-06-17 Task 10 Start - -Command/code area touched: inspected current gateway and serving WebSocket fallback classifiers, then fetched official `openai/codex` `core/src/client.rs` downgrade path. - -Observed result: official `ModelClientSession::stream_responses_websocket` returns `WebsocketStreamOutcome::FallbackToHttp` only when WebSocket connection setup returns `ApiError::Transport(TransportError::Http { status: StatusCode::UPGRADE_REQUIRED, .. })`. HTTP 401 follows auth recovery, and other connect/stream errors return errors rather than switching to HTTP. This supersedes the earlier plan assumption that generic network/opening failure may fall back. - -Current rs difference: `websocket_error_allows_http_sse_fallback` and `websocket_stream_error_allows_http_sse_fallback` allow transport errors, open timeout, empty response, and HTTP 404/405/501 to downgrade to HTTP SSE. That is broader than the official source-backed behavior. - -Next decision: add/update failing tests so ordinary WS transport failure does not fall back, HTTP 426 still falls back for preferred non-history requests, `previous_response_id` remains WebSocket-only, and business/quota/auth errors do not trigger HTTP SSE fallback. - -Verification status: red/guard tests confirmed: -- `cargo test --test codex_gateway ordinary_response_should_not_fallback_to_http_sse_when_websocket_transport_fails` failed because current rs attempted an HTTP SSE request after a WebSocket transport close, then returned a reqwest connection-refused error. -- `cargo test --test codex_gateway ordinary_response_should_fallback_to_http_sse_when_websocket_upgrade_required` passed, confirming current HTTP 426 downgrade behavior already exists. - -### 2026-06-17 Task 10 Fallback Implementation Start - -Command/code area touched: will update fallback classifiers in `src/codex/gateway/transport/http_client.rs` and `src/codex/serving/dispatch/mod.rs`. - -Observed result: both classifiers currently allow more downgrade cases than official source supports. - -Next decision: make HTTP SSE downgrade return true only for upstream HTTP 426 `Upgrade Required`; preserve account-level fallback/retry handling for quota/auth/business errors. - -Verification status: implementation completed locally. Gateway and serving streaming fallback classifiers now allow HTTP SSE downgrade only for upstream HTTP 426. HTTP SSE account fallback tests were updated to set `use_websocket: false` explicitly instead of relying on mock WebSocket 404 fallback. - -### 2026-06-17 Task 10 Verification - -Command/code area touched: modified `src/codex/gateway/transport/http_client.rs`, `src/codex/serving/dispatch/mod.rs`, `tests/codex_gateway/websocket.rs`, and `tests/codex_serving/upstream_fallback.rs`. - -Observed result: generic WebSocket transport failure now surfaces as a WebSocket transport error without an HTTP SSE retry; HTTP 426 `Upgrade Required` still downgrades to HTTP SSE for preferred non-history requests. Existing WebSocket business/quota/auth error tests continue to avoid HTTP SSE fallback and use account-level fallback/error handling where applicable. - -Next decision: run final formatting/lint checks and commit Task 10 as `fix: align codex desktop websocket fallback`. - -Verification status: passed `cargo test --test codex_gateway ordinary_response_should_not_fallback_to_http_sse_when_websocket_transport_fails`, `cargo test --test codex_gateway ordinary_response_should_fallback_to_http_sse_when_websocket_upgrade_required`, `cargo test --test codex_gateway websocket`, `cargo test --test codex_serving responses_websocket`, and `cargo test --test codex_serving upstream_fallback`. - -### 2026-06-17 Task 10 Final Verification - -Command/code area touched: finalized WebSocket-to-HTTP fallback parity and Task 10 documentation. - -Observed result: Task 10 leaves transport selection defaults intact but narrows actual downgrade execution to official-source HTTP 426 only. HTTP SSE account fallback tests now opt into HTTP transport explicitly with `use_websocket: false`. - -Next decision: commit Task 10, then run Task 11 final verification and summary. - -Verification status: passed `cargo fmt --all --check`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `git diff --check`. - -### 2026-06-17 Task 5 Implementation Result - -Command/code area touched: modified `tests/codex_gateway/websocket.rs` to add `capture_codex_websocket_exchange`, a raw local WebSocket capture harness, and `websocket_capture_harness_should_record_opening_bytes`. - -Observed result: the harness starts a local TCP WebSocket endpoint, captures the actual HTTP/1.1 opening bytes from `codex-proxy-rs`, completes the WebSocket upgrade manually, decodes the masked first client text frame, sends a terminal `response.completed` frame, and writes `.codex-ws-audit/rs-current-capture.json`. The sample artifact records the request line, ordered headers, `Sec-WebSocket-Extensions`, `Sec-WebSocket-Key` offset, `Authorization` offset, redacted first-frame `response.create` payload shape, and a TLS note stating that local WSS capture is not used because the production client validates against native system roots. - -Observed current rs sample: request line is `GET /codex/responses HTTP/1.1`; fixed opening headers appear as `Host`, `Connection`, `Upgrade`, `Sec-WebSocket-Version`, `Sec-WebSocket-Extensions`, `Sec-WebSocket-Key` before business headers; extension offer is `permessage-deflate; client_max_window_bits`; `Sec-WebSocket-Key` appeared before `Authorization`; first frame type is `response.create`. - -Next decision: commit Task 5, then start Task 6 by checking the official bundled `codex` binary and looking for a practical local endpoint/proxy override path. - -Verification status: `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes`, `cargo test --test codex_gateway websocket`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `cargo fmt --all --check` passed. The sample artifact was inspected and did not contain private prompt, private instructions, prompt cache key, or thread metadata values. - -### 2026-06-17 Task 6 Start - -Command/code area touched: will inspect `/tmp/codex-desktop-fingerprint/dmg/Codex Installer/Codex.app/Contents/Resources/codex` with non-mutating commands first. - -Observed result: current rs artifact exists locally at `.codex-ws-audit/rs-current-capture.json`, so the next missing reference is the official bundled binary sample or a documented blocker. - -Next decision: confirm the binary exists and run help/config discovery without live credentials. Search static strings for API base URL, proxy, and Responses WebSocket override hints before attempting any execution against a local harness. - -Verification status: pending official binary inspection. - -### 2026-06-17 Task 6 Official Binary Inspection Result - -Command/code area touched: inspected `/tmp/codex-desktop-fingerprint/dmg/Codex Installer/Codex.app/Contents/Resources/codex` with `test -x`, `file`, direct `--help`, `sha256sum`, `stat`, and targeted `strings | rg` searches for Responses WebSocket, provider base URL, config keys, proxy, TLS, and dependency evidence. - -Observed result: the target binary exists and has executable mode, but `file` reports `Mach-O 64-bit arm64 executable`. Running it on this Linux host fails with `cannot execute binary file: Exec format error`, so direct local harness sampling and local proxy/DNS interception cannot be performed from this environment. - -Static official evidence extracted: - -- SHA-256: `2bc151dfd04a12dcc13650cb0ed67fb5399ff5250ef8579fcdc482e8b3a6ea5b`. -- Size: `213429648` bytes. -- Target provider/base strings include `https://chatgpt.com/backend-api`, `https://chatgpt.com/backend-api/codex`, `chatgpt_base_url`, `openai_base_url`, and `supports_websockets`. -- Responses WebSocket strings include `codex-api/src/endpoint/responses_websocket.rs`, `responses_websocket`, `responses_websockets=2026-02-06`, `websocket request:`, `failed to build websocket URL:`, and `permessage-deflate`. -- HTTP fallback/dependency strings include `reqwest-0.12.28`, `reqwest-0.13.4`, and `hyper-rustls-0.27.7`. -- TLS/WebSocket dependency strings include `tokio-rustls-0.26.4`, `rustls-0.23.36`, `tokio-tungstenite`, and `tokio_tungstenite`. -- Network/proxy strings include `network-proxy`, `HTTP_PROXY`, `HTTPS_PROXY`, `WSS_PROXY`, `ALL_PROXY`, and `allow_upstream_proxy`, but live validation is blocked by the Mach-O format. - -Next decision: commit this official sampling blocker and static evidence, then start Task 7 by adding a structured parity diff helper that can compare the current rs artifact against an official artifact when available, or against this documented static substitute for fields that static evidence can prove. - -Verification status: official live sampling is blocked on this Linux host by executable format; static evidence extraction completed and the blocker is documented. - -### 2026-06-17 Task 7 Start - -Command/code area touched: will add a focused parity diff test and helper around existing `WebSocketAuditArtifact`, `OpeningAuditSnapshot`, and `PayloadAuditSnapshot`. - -Observed result: current rs artifact capture and official static evidence are recorded, but there is no structured diff helper yet to classify stable differences. String-only artifact comparison would be too noisy because ports and dynamic WebSocket keys vary. - -Next decision: write `websocket_parity_diff_should_report_header_order_changes` first, then implement a structured diff helper that compares transport mode, fallback eligibility, opening header order, extension value, and payload top-level key order. - -Verification status: pending failing diff test. - -### 2026-06-17 Task 7 Failing Test - -Command/code area touched: added `websocket_parity_diff_should_report_header_order_changes` to `tests/codex_gateway/websocket.rs` and ran `cargo test --test codex_gateway websocket_parity_diff_should_report_header_order_changes`. - -Observed result: the test failed to compile because `websocket_parity_diff` does not exist yet. This is the expected red state for the structured diff helper. - -Next decision: implement `WebSocketParityDiff` and `WebSocketParityDifference` in the WebSocket audit module, comparing transport mode, fallback flag, opening request line/header order/extensions, payload top-level keys, and optional error classification. - -Verification status: expected failing test captured. - -### 2026-06-17 Task 4 Implementation Result - -Command/code area touched: added `src/codex/gateway/transport/websocket/audit.rs`; modified `.gitignore`, `src/codex/gateway/transport/websocket/codec.rs`, `src/codex/gateway/transport/websocket/mod.rs`, `src/codex/gateway/transport/websocket/opening.rs`, `src/codex/gateway/transport/websocket/pool.rs`, and `tests/codex_gateway/websocket.rs`. - -Observed result: WebSocket audit artifact writing is disabled unless `CODEX_PROXY_WS_AUDIT_DIR` is set. When enabled, the runtime writes a pretty JSON artifact before the first `response.create` frame is sent. The artifact includes `transport_mode`, `fallback_allowed`, redacted opening snapshot, redacted payload snapshot, and optional error snapshot fields. Local default audit output under `.codex-ws-audit/` is ignored by git. - -Next decision: commit Task 4, then start Task 5 by building a local capture harness that records actual opening bytes and first-frame payload without real OpenAI credentials. - -Verification status: `cargo test --test codex_gateway websocket_audit_artifact_should_require_explicit_gate`, `cargo test --test codex_gateway websocket`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `cargo fmt --all --check` passed. The first format check reported rustfmt ordering/line wrapping; `cargo fmt --all` corrected it and the follow-up check passed. - -### 2026-06-17 Task 5 Start - -Command/code area touched: read Rust testing/error-handling guidance and existing `tests/codex_gateway/websocket.rs` helpers: `read_http_upgrade_request`, `read_client_websocket_frame`, `websocket_accept_key`, and `assert_headers_appear_in_order`. - -Observed result: existing test helpers can already read raw HTTP/1.1 WebSocket opening bytes and decode the masked client first frame. A stable local WSS/TLS integration test is not practical with the current production client because `connect_stream` builds a rustls client from native system roots only; a local self-signed certificate would not validate without adding test-only trust injection to production code. The first Task 5 artifact will therefore capture HTTP/WS bytes and record this TLS limitation explicitly; TLS ClientHello capture remains a follow-up observation path rather than a unit-test prerequisite. - -Next decision: write a failing integration test named `websocket_capture_harness_should_record_opening_bytes` around a not-yet-implemented local capture helper, then implement the helper and produce a redacted sample artifact under `.codex-ws-audit/`. - -Verification status: pending failing capture harness test. - -### 2026-06-17 Task 5 Failing Test - -Command/code area touched: added `websocket_capture_harness_should_record_opening_bytes` to `tests/codex_gateway/websocket.rs` and ran `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes`. - -Observed result: the test failed to compile because `capture_codex_websocket_exchange` does not exist yet. This is the expected red state for the local capture harness. - -Next decision: implement a small test harness that starts a local raw WebSocket endpoint, captures the actual HTTP/1.1 upgrade bytes and first client text frame, returns offsets for `Sec-WebSocket-Key` and `Authorization`, and writes a redacted JSON sample to `.codex-ws-audit/rs-current-capture.json`. - -Verification status: expected failing test captured. - -### 2026-06-17 Task 3 Implementation Result - -Command/code area touched: modified `src/codex/gateway/transport/websocket/codec.rs`, `src/codex/gateway/transport/websocket/mod.rs`, and `tests/codex_gateway/websocket.rs`. - -Observed result: `PayloadAuditSnapshot` records `top_level_keys` from the same `serde_json::Value` object that becomes the first `response.create` text frame. The redacted audit body preserves non-sensitive transport structure and redacts `instructions`, `input`, `previous_response_id`, `prompt_cache_key`, `client_metadata`, and `tools`. - -Next decision: commit Task 3, then start Task 4 by inspecting existing diagnostic/config patterns before choosing an explicit audit artifact gate. - -Verification status: `cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content`, `cargo test --test codex_gateway websocket`, `cargo clippy --test codex_gateway --locked -- -D warnings`, and `cargo fmt --all --check` passed. The first format check reported only re-export ordering; `cargo fmt --all` corrected it and the follow-up check passed. - -### 2026-06-17 Task 4 Start - -Command/code area touched: will inspect `src/config`, runtime diagnostics, and existing environment/config flag patterns before adding any audit artifact writer. - -Observed result: opening and payload audit snapshots now exist as in-memory redacted structures, but there is no opt-in artifact writer and no durable local sample for current rs behavior. - -Next decision: choose the smallest explicit gate, likely an environment variable if that matches existing local diagnostics better than persistent config, then write a failing test proving the writer is disabled by default and redacts output when enabled. - -Verification status: pending config/diagnostics inspection. - -### 2026-06-17 Task 4 Config/Diagnostics Inspection - -Command/code area touched: searched config, diagnostics, environment-variable usage, runtime trace tests, and existing stream audit code with `rg`; read `src/config/types.rs`, `src/config/loader.rs`, `src/codex/serving/dispatch/stream_audit.rs`, and WebSocket connection metadata paths. - -Observed result: app configuration is YAML-first and strict for several sections. Existing `stream_audit` is business-event/usage auditing, not a local artifact writer. Environment variables are used sparingly for local/runtime concerns such as `CODEX_HOME`, making an explicit local-only audit directory environment variable a narrower fit than adding persistent config. - -Next decision: use `CODEX_PROXY_WS_AUDIT_DIR` as the explicit production gate and add `.codex-ws-audit/` to `.gitignore`. Tests will exercise a pure `None`/`Some(dir)` gate helper instead of mutating process environment. - -Verification status: inspection complete; failing artifact writer test pending. - -### 2026-06-17 Task 4 Failing Test - -Command/code area touched: added `websocket_audit_artifact_should_require_explicit_gate` to `tests/codex_gateway/websocket.rs` and ran `cargo test --test codex_gateway websocket_audit_artifact_should_require_explicit_gate`. - -Observed result: the test failed to compile because `write_websocket_audit_artifact_for_dir`, `WebSocketAuditArtifact`, and `WebSocketAuditErrorSnapshot` do not exist yet. This is the expected red state for the artifact writer. - -Next decision: add a WebSocket audit module with `CODEX_PROXY_WS_AUDIT_DIR` as the production gate, a pure directory-gated writer for tests, and runtime wiring that writes opening/payload artifacts only when the gate is present. - -Verification status: expected failing test captured. - -### 2026-06-17 Task 2 Implementation Result - -Command/code area touched: modified `src/codex/gateway/transport/websocket/opening.rs`, `src/codex/gateway/transport/websocket/mod.rs`, and `tests/codex_gateway/websocket.rs`. - -Observed result: `OpeningAuditSnapshot` now records `request_line` and ordered headers. `opening_request_bytes` and audit snapshot generation share the same `opening_request_head` source, so the audit view follows the actual request-line/header order. The snapshot redacts `authorization`, `chatgpt-account-id`, `cookie`, `x-client-request-id`, `x-codex-installation-id`, `session_id`, `x-codex-window-id`, `x-codex-turn-state`, `x-codex-turn-metadata`, and `x-codex-parent-thread-id`. - -Next decision: commit Task 2, then start Task 3 by tracing the `response.create` payload construction path with CodeGraph and writing a failing payload audit snapshot test. - -Verification status: `cargo test --test codex_gateway websocket_opening_audit_snapshot_should_redact_sensitive_headers`, `cargo test --test codex_gateway websocket_handshake_should_offer_original_permessage_deflate_extension`, `cargo fmt --all --check`, `cargo test --test codex_gateway websocket`, and `cargo clippy --test codex_gateway --locked -- -D warnings` all passed. - -### 2026-06-17 Task 3 Start - -Command/code area touched: will inspect `websocket_request_body` and the call site that sends the first `response.create` WebSocket text frame. - -Observed result: Task 2 gives a redacted opening snapshot but there is still no structured audit view for the first WebSocket payload. - -Next decision: use CodeGraph to locate payload construction, then write a failing test that records top-level JSON key order and redacts user prompt/input content. - -Verification status: pending CodeGraph inspection and failing payload audit test. - -### 2026-06-17 Task 3 CodeGraph Inspection - -Command/code area touched: ran `codegraph node websocket_request_body` and `codegraph explore websocket_request_body create_response_via_websocket_stream_inner`. - -Observed result: the first WebSocket text frame is sent from `create_response_via_websocket_stream_inner` as `Message::Text(websocket_request_body(request).to_string().into())`. Payload construction is centralized in `src/codex/gateway/transport/websocket/codec.rs`, where `websocket_request_body` builds the `response.create` JSON `Value`. - -Next decision: add the audit snapshot beside `websocket_request_body` so the runtime payload and audit view share the same source value. The test should assert top-level key order from the actual JSON object iteration and redact user prompt/input content. - -Verification status: CodeGraph inspection completed; failing test pending. - -### 2026-06-17 Task 3 Failing Test - -Command/code area touched: added `websocket_payload_audit_snapshot_should_redact_user_content` to `tests/codex_gateway/websocket.rs` and ran `cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content`. - -Observed result: the test failed to compile with unresolved import `websocket_payload_audit_snapshot`, which is the expected red state for the new payload audit API. - -Next decision: implement `PayloadAuditSnapshot` beside `websocket_request_body`, expose it through the WebSocket module, and redact prompt/input/metadata/id-bearing fields while preserving top-level key order. - -Verification status: expected failing test captured. - -## Update Discipline - -This document is the durable context for the task. Before starting any substantial analysis or code edit, append a short entry to either Progress Log or Analysis Journal. After each implementation step, append: - -- command or code area touched, -- observed result, -- next decision, -- verification status. - -If context is compacted, resume from this document before continuing. - -### 2026-06-17 Task 11 Verification Continuation - -Command/code area touched: resumed from the Task 11 handoff, inspected `git status`, the focused serving/gateway tests, and the HTTP SSE request bodies in `tests/codex_serving/responses_http_sse.rs` and `tests/codex_serving/upstream_errors.rs`. - -Observed result: focused WebSocket checks had already passed, while the remaining full-suite failures are legacy HTTP SSE tests that relied on mock WebSocket 404/404-like transport failure downgrading to HTTP SSE. After Task 10, that downgrade path is intentionally narrowed to official-source HTTP 426 only, so HTTP SSE behavior tests must opt into HTTP transport with `use_websocket: false`. - -Next decision: update only those HTTP SSE test request bodies to make transport selection explicit. Do not loosen runtime fallback classification. - -Verification status: pending focused `codex_serving` rerun and then full verification commands. - -### 2026-06-17 Task 11 HTTP SSE Test Fix Result - -Command/code area touched: updated HTTP SSE-focused request bodies in `tests/codex_serving/responses_http_sse.rs` and `tests/codex_serving/upstream_errors.rs` to include `use_websocket: false`. This keeps the tests on the HTTP SSE transport they are intended to exercise after Task 10 narrowed WebSocket fallback to HTTP 426. - -Observed result: `cargo test --test codex_serving responses_http_sse` passed 28 tests. `cargo test --test codex_serving upstream_errors` passed 5 tests. - -Next decision: run full `cargo test`, formatting, Clippy, `git diff --check`, and final status. - -Verification status: focused serving tests passing; full verification pending. - -### 2026-06-17 Task 11 Final Verification - -Command/code area touched: ran the final verification set after the HTTP SSE test transport fixes: -`cargo test --test codex_serving responses_http_sse`, -`cargo test --test codex_serving upstream_errors`, -`cargo test`, -`cargo fmt --all --check`, -`cargo clippy --all-targets --all-features --locked -- -D warnings`, -`git diff --check`, -`cargo test --test codex_gateway websocket`, and -`cargo test --test codex_serving responses_websocket`. - -Observed result: all commands passed. Full `cargo test` passed the library/unit tests, all integration suites, and doc tests. WebSocket-focused coverage passed 30 gateway tests and 26 serving tests. HTTP SSE-focused coverage passed 28 serving behavior tests and 5 upstream error tests after those tests explicitly selected `use_websocket: false`. - -Next decision: commit the remaining documentation and test updates. Local audit artifacts remain under ignored `.codex-ws-audit/`; `.gitignore` contains `/.codex-ws-audit/`, so environment-specific capture output is not committed. - -Verification status: final local verification complete. The official bundled `Codex.app` binary is still a macOS arm64 Mach-O and cannot be executed on this Linux host, so official live TLS/opening sampling remains unavailable here; parity decisions are based on local redacted capture plus official `openai/codex` source/static evidence documented above. - -### 2026-06-17 Task 12 Ping/Pong Source Parity Start - -Command/code area touched: resumed after Task 11 with a clean worktree, confirmed CodeGraph index is up to date, and re-fetched official `openai/codex` `codex-api/src/endpoint/responses_websocket.rs`, `codex-api/src/common.rs`, and `core/src/client.rs`. - -Observed result: official `ResponsesWebsocketConnection` wraps `WebSocketStream` in a `WsStream` pump. In that pump, `Ok(Message::Ping(payload))` immediately sends `Message::Pong(payload)`, `Pong` is ignored, and only Text/Binary/Close/Frame are forwarded to the response consumer. Current rs response loops ignore non-text messages through `websocket_message_text(message) == None`; this does not explicitly send the Pong from our code path and is weaker than official source-backed behavior. - -Next decision: add a gateway WebSocket test where the mock upstream sends Ping and waits for matching Pong before sending `response.completed`. The expected red state is a timeout/failure before implementing explicit Pong handling. - -Verification status: source-backed difference identified; failing test pending. - -### 2026-06-17 Task 12 Ping/Pong Observation and Binary Frame Gap - -Command/code area touched: added and ran `cargo test --test codex_gateway websocket_should_reply_to_server_ping_before_completed_event`. - -Observed result: the test passed without code changes. Although current rs does not explicitly match official `Ok(Message::Ping(payload)) => send(Pong(payload))` source, `tokio-tungstenite` currently flushes the Pong in this path, so no observable Ping/Pong parity change is justified from this test. - -Next decision: keep the Ping/Pong regression test and target a stronger source-backed delta in the same official loop: official `run_websocket_response_stream` returns an error for `Message::Binary(_)` with `unexpected binary websocket event`, while current rs tries to UTF-8 decode binary messages in `websocket_message_text` and may treat binary JSON as valid response events. - -Verification status: Ping/Pong observable parity covered; binary-frame failing test pending. - -### 2026-06-17 Task 12 Binary Frame Implementation Result - -Command/code area touched: added `websocket_binary_response_frame_should_error_like_official_client` to `tests/codex_gateway/websocket.rs`, then modified `src/codex/gateway/transport/websocket/mod.rs`, `src/codex/gateway/transport/websocket/codec.rs`, and `src/codex/gateway/transport/http_client.rs`. - -Observed result: the failing test first failed to compile because `CodexWebSocketError::UnexpectedBinaryEvent` did not exist. Implementation added that error, changed `websocket_message_text` to return `Err(UnexpectedBinaryEvent)` for `Message::Binary(_)`, discards the active socket on binary frames, forwards the error on streaming paths, and marks the error as ineligible for HTTP SSE downgrade. This matches official source behavior where binary websocket events are rejected instead of being parsed as UTF-8 response events. - -Next decision: run focused WebSocket gateway/serving checks, formatting, Clippy, and whitespace checks before committing Task 12. - -Verification status: `cargo test --test codex_gateway websocket_` passed 31 tests, including Ping/Pong and binary-frame parity; `cargo test --test codex_serving responses_websocket` passed 26 tests. - -### 2026-06-17 Task 12 Final Verification - -Command/code area touched: ran `cargo fmt --all`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, `cargo test --test codex_gateway websocket_`, `cargo test --test codex_serving responses_websocket`, `cargo fmt --all --check`, `git diff --check`, and full `cargo test`. - -Observed result: all verification commands passed. Full `cargo test` passed unit tests, all integration suites, and doc tests. The gateway WebSocket suite now includes 31 filtered tests and the full gateway suite includes 65 tests after adding Ping/Pong and binary-frame parity coverage. - -Next decision: commit Task 12 as a source-backed observable parity increment. Remaining large uncertainty is still official live TLS/opening sampling, blocked locally by the macOS arm64 binary format. - -Verification status: ready to commit. - -### 2026-06-17 Task 31 Response Failed Retry-After Message Audit Start - -Command/code area touched: starting comparison of official `try_parse_retry_after` for `response.failed` `rate_limit_exceeded` errors against local HTTP/WebSocket upstream retry-after extraction. - -Observed result: pending. Official `process_responses_event` parses retry delays embedded in `response.error.message`, including strings like `Please try again in 11.054s`, `Try again in 35 seconds`, and `try again in 28ms`, but only when the error code is `rate_limit_exceeded`. Local `retry_after_seconds_from_body` currently reads structured `resets_in_seconds` or `resets_at` only, so WebSocket `response.failed` rate-limit frames without those fields lose the upstream delay and fall back to generic account retry defaults. - -Next decision: add focused gateway tests for WebSocket `response.failed` `rate_limit_exceeded` messages with official retry-after wording, then implement message parsing in the shared upstream body retry-after extraction path without broadening parsing to non-rate-limit codes. - -Verification status: red test pending. - -### 2026-06-17 Task 31 Response Failed Retry-After Message Implementation Result - -Command/code area touched: added gateway WebSocket coverage in `tests/codex_gateway/websocket.rs`; introduced shared body retry-after parsing in `src/codex/gateway/transport/retry_after.rs`; switched `http_client.rs` and `websocket/{codec.rs,mod.rs}` to the shared helper. - -Observed result: the focused WebSocket test first failed with `retry_after_seconds: None` for a `response.failed` frame carrying `{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 11.054s."}`. The implementation now preserves existing structured `resets_in_seconds` / `resets_at` parsing and then parses official-shaped message delays only for `rate_limit_exceeded` (`code`, with local `type` fallback). Because the proxy's public retry-after field is `Option<u64>` seconds rather than official `Duration`, positive fractional seconds and millisecond delays are rounded up to at least one second; e.g. `11.054s` becomes `12`, and `28ms` becomes `1`. - -Focused verification passed: -- `cargo test retry_after_seconds_from_body` -- `cargo test --test codex_gateway websocket_response_failed_` - -Next decision: run gateway WebSocket and serving WebSocket suites, formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused tests passed; broader verification pending. - -### 2026-06-17 Task 31 Final Verification - -Command/code area touched: ran Task 31 verification after formatting and shared retry-after parser changes. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 47 tests, including `response.failed` rate-limit message retry-after parsing and the non-rate-limit guard. Serving WebSocket coverage remained at 28 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo fmt --all` -- `cargo test retry_after_seconds_from_body` -- `cargo test --test codex_gateway websocket_response_failed_` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 31 as `fix: parse websocket rate limit retry delays`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 32 Malformed Completed Frame Audit Start - -Command/code area touched: starting comparison of official `process_responses_event` `response.completed` parsing against local WebSocket terminal-frame handling. - -Observed result: pending. Official `process_responses_event` deserializes `response.completed.response` into `ResponseCompleted`, where `id` is required. If deserialization fails, official returns `ApiError::Stream("failed to parse ResponseCompleted: ...")`; it does not emit a successful completed event. Local WebSocket handling currently treats any `type: "response.completed"` frame as terminal and encodes it as downstream SSE without validating the present `response` object, so a malformed completed frame can be returned as success. - -Next decision: add a focused gateway WebSocket test with `response.completed` missing `response.id`, then implement minimal validation for present completed response objects before treating the frame as terminal success. To avoid broad fixture churn, this task will validate the required `id` field rather than fully validating every optional usage subfield. - -Verification status: red test pending. - -### 2026-06-17 Task 32 Malformed Completed Frame Implementation Result - -Command/code area touched: added `websocket_malformed_completed_response_should_error_like_official_client` in `tests/codex_gateway/websocket.rs`; added `response_completed_parse_error` in `src/codex/gateway/transport/websocket/codec.rs`; wired it into both first-frame and forwarding loops in `websocket/mod.rs`; updated HTTP SSE fallback eligibility in `http_client.rs`. - -Observed result: the focused test first failed because local returned successful SSE for a `response.completed` frame whose `response` object lacked the required `id`; the body was `event: response.completed` and local usage extraction still produced token usage. Implementation now rejects a present `response.completed.response` without a non-empty string `id`, returns `failed to parse ResponseCompleted: missing field \`id\``, discards the WebSocket, and does not fall back to HTTP SSE. This matches the official source-backed requirement for the required `ResponseCompleted.id` field while intentionally leaving optional usage-field strictness unchanged to avoid broad mock churn. - -Focused verification passed: -- `cargo test --test codex_gateway websocket_malformed_completed_response_should_error_like_official_client` - -Next decision: run gateway WebSocket and serving WebSocket suites, formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 32 Final Verification - -Command/code area touched: ran Task 32 verification after formatting and malformed completed-frame validation changes. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 48 tests, including malformed `response.completed` rejection. Serving WebSocket coverage remained at 28 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_malformed_completed_response_should_error_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 32 as `fix: reject malformed websocket completed frames`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 33 Completed Usage Shape Audit Start - -Command/code area touched: continuing the Task 32 `response.completed` validation audit by comparing official `ResponseCompletedUsage` shape against local WebSocket completed-frame validation and local test fixtures. - -Observed result: pending. Official `ResponseCompleted` allows `usage` to be absent, but when `usage` is present its `ResponseCompletedUsage` requires `input_tokens`, `output_tokens`, and `total_tokens`; nested `input_tokens_details.cached_tokens` and `output_tokens_details.reasoning_tokens` are also required when their detail objects are present. Task 32 only rejected missing `response.id`, while local `extract_sse_usage` still accepts incomplete usage by deriving `total_tokens` from input plus output. That leaves a concrete mismatch: a WebSocket `response.completed` with `usage` missing `total_tokens` can still be returned as success locally, while official would emit `failed to parse ResponseCompleted: ...`. - -Next decision: add a focused gateway WebSocket test for `response.completed` with `usage` missing `total_tokens`, then replace the minimal `id` check with official-shaped completed-response deserialization. Local upstream mocks that represent successful official frames should include `total_tokens`. - -Verification status: red test pending. - -### 2026-06-17 Task 33 Completed Usage Shape Implementation Result - -Command/code area touched: added `websocket_completed_response_with_incomplete_usage_should_error_like_official_client` in `tests/codex_gateway/websocket.rs`; replaced local `response.completed` minimal `id` validation in `src/codex/gateway/transport/websocket/codec.rs` with official-shaped `ResponseCompleted` / `ResponseCompletedUsage` deserialization; updated WebSocket success fixtures and helpers to include official `total_tokens`. - -Observed result: the focused test first failed because local returned successful SSE and synthesized usage total for a WebSocket `response.completed` frame whose `usage` lacked `total_tokens`. Implementation now rejects such frames with `failed to parse ResponseCompleted: ...`, while successful WebSocket fixtures now carry `total_tokens` so they match official completed-response shape. The existing missing-`id` malformed completed test remains covered by the same deserialization path. - -Focused verification passed: -- `cargo test --test codex_gateway completed_response` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` - -Next decision: run formatting, diff check, Clippy, and full `cargo test`, then commit Task 33 if clean. - -Verification status: focused and WebSocket suite tests passed; broader verification pending. - -### 2026-06-17 Task 33 Final Verification - -Command/code area touched: ran Task 33 verification after formatting, official-shaped completed-response deserialization, and fixture updates for successful WebSocket completed frames. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 49 tests, including malformed completed `id` and incomplete `usage` rejection. Serving WebSocket coverage remained at 28 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. One runtime startup fixture initially failed because its successful WebSocket completed mock omitted `total_tokens`; the fixture now includes official-shaped usage and the focused runtime test passes. - -Commands: -- `cargo fmt --all` -- `cargo test --test codex_gateway completed_response` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo test --test runtime app_state_should_restore_session_affinity_from_sqlite` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 33 as `fix: validate websocket completed usage shape`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 34 Response-Less Completed Frame Audit Start - -Command/code area touched: comparing official `process_responses_event` `response.completed` branch against local WebSocket terminal event handling. - -Observed result: pending. Official only returns `ResponseEvent::Completed` inside `if let Some(resp_val) = event.response`; a `response.completed` event without a `response` object falls through to `Ok(None)` and is not considered completion. Local WebSocket handling currently treats any `type: "response.completed"` frame as terminal and returns it as successful SSE, because `response_completed_parse_error` returns `None` when `response` is absent and `is_terminal_websocket_event` then marks it complete. - -Next decision: add a focused gateway WebSocket test where upstream sends `{"type":"response.completed"}` then closes. The expected local parity behavior is that this frame is ignored as non-completing and the close surfaces as a WebSocket before-completed error, not a successful response. - -Verification status: red test pending. - -### 2026-06-17 Task 34 Response-Less Completed Frame Implementation Result - -Command/code area touched: added `websocket_completed_without_response_should_not_finish_like_official_client` in `tests/codex_gateway/websocket.rs`; added `response_completed_missing_response` in `src/codex/gateway/transport/websocket/codec.rs`; wired first-frame and forwarding loops in `websocket/mod.rs` to skip response-less completed frames. - -Observed result: the focused test first failed because local returned successful SSE for `{"type":"response.completed"}`. Implementation now ignores that frame as non-completing, so the subsequent WebSocket close surfaces as `ClosedByServerBeforeCompleted`, matching the official branch where `response.completed` without `event.response` falls through to `Ok(None)` rather than `ResponseEvent::Completed`. - -Focused verification passed: -- `cargo test --test codex_gateway websocket_completed_without_response_should_not_finish_like_official_client` - -Next decision: run gateway WebSocket and serving WebSocket suites, formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 34 Final Verification - -Command/code area touched: ran Task 34 verification after formatting and response-less completed-frame skip changes. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 50 tests, including response-less completed-frame rejection by ignoring the frame and surfacing the following close as `ClosedByServerBeforeCompleted`. Serving WebSocket coverage remained at 28 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_completed_without_response_should_not_finish_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 34 as `fix: ignore websocket completed frames without response`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 25 Completed Event Side-Channel Audit Start - -Command/code area touched: starting comparison of official `run_websocket_response_stream` handling for `response.completed` side-channel fields (`openai_model`, `model_verification_status`, and turn moderation metadata) against local WebSocket forwarding, usage extraction, and session-affinity metadata handling. - -Observed result: pending. Official WebSocket code emits several Desktop-internal `ResponseEvent` variants before passing the parsed event through `process_responses_event`; local proxy currently forwards raw SSE-compatible frames and separately extracts completed-response metadata for usage/session affinity. This task will determine whether any missing completed-event side channel is observable in the proxy's API contract or should remain documented as Desktop-internal only. - -Next decision: inspect official `ResponsesStreamEvent` parsing and local completed-response metadata extraction paths, then add a failing test only if the difference affects local downstream behavior without inventing new external API fields. - -Verification status: analysis in progress. - -### 2026-06-17 Task 25 Completed Event Side-Channel Audit Result - -Command/code area touched: inspected official `ResponsesStreamEvent::response_model`, `model_verifications`, and `turn_moderation_metadata`; official WebSocket event loop side-channel emission; official core consumers for `ResponseEvent::ServerModel`, `ModelVerifications`, `TurnModerationMetadata`, and `ServerReasoningIncluded`; local WebSocket forwarding and serving completed-response metadata extraction. - -Observed result: no local code change is justified for this task. Official `openai-model` / `x-openai-model`, `openai_verification_recommendation`, and `openai_chatgpt_moderation_metadata` are emitted as Desktop-internal `ResponseEvent` side channels and consumed by the official core for model mismatch warnings, account verification UI, turn moderation presentation, and related state. They are not emitted as raw SSE frames. Local proxy has no corresponding internal Desktop UI/session consumer and its public contract is OpenAI-compatible SSE passthrough plus local usage/session-affinity extraction. Adding synthetic SSE frames or response fields for these side channels would change the proxy's external API rather than improve 1:1 behavior inside the current boundary. - -Next decision: leave these completed-event side channels documented as Desktop-internal and not actionable for the proxy API surface. Continue with the next source-backed audit target that can affect local behavior. - -Verification status: no code change; documentation-only audit result pending commit. - -### 2026-06-17 Task 26 WebSocket Receive Idle Timeout Audit Start - -Command/code area touched: starting comparison of official per-event WebSocket receive timeout in `run_websocket_response_stream` against local `create_response_via_websocket_stream_inner` and `forward_websocket_as_sse` loops. - -Observed result: official wraps every `ws_stream.next()` with `tokio::time::timeout(idle_timeout, ...)` and returns `ApiError::Stream("idle timeout waiting for websocket")` when the server stops sending events before `response.completed`. Local WebSocket loops currently await `.next()` directly after a successful handshake/request send, so an upstream that accepts the request and then stays silent can hang the local caller indefinitely. - -Next decision: add a focused gateway test using Tokio virtual time for a silent upstream WebSocket, then add a local receive idle timeout that returns a WebSocket stream error instead of hanging. Keep the timeout scoped to WebSocket event receive behavior and avoid changing opening-byte parity. - -Verification status: red test pending. - -### 2026-06-17 Task 26 WebSocket Receive Idle Timeout Implementation Result - -Command/code area touched: added `websocket_silent_upstream_should_timeout_like_official_client` in `tests/codex_gateway/websocket.rs`; updated local WebSocket receive loops in `src/codex/gateway/transport/websocket/mod.rs`; updated WebSocket fallback classification in `src/codex/gateway/transport/http_client.rs`. - -Observed result: the focused test first failed to compile because `CodexWebSocketError::ReceiveIdleTimeout` did not exist. Implementation adds a local receive idle timeout around both first-frame and forwarding-loop WebSocket reads. A silent upstream now returns `CodexWebSocketError::ReceiveIdleTimeout { timeout: 20s }` with display text `idle timeout waiting for websocket`, matching the official error string for this condition. The timeout error is not eligible for HTTP SSE downgrade, so a stuck WebSocket request surfaces as an upstream transport failure instead of silently changing transport. - -Verification status: `cargo test --test codex_gateway websocket_silent_upstream_should_timeout_like_official_client` passed. Broader verification pending. - -### 2026-06-17 Task 26 WebSocket Receive Idle Timeout Verification Result - -Command/code area touched: verified receive idle timeout handling with focused gateway coverage, the gateway WebSocket filtered suite, serving WebSocket suite, formatting, diff whitespace checks, Clippy, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 42 tests, including the silent-upstream receive timeout case. The serving `responses_websocket` suite remained at 27 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_silent_upstream_should_timeout_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 26 as a source-backed WebSocket receive timeout parity increment. Continue auditing official WebSocket behavior that can affect local callers; live macOS Desktop TLS/opening capture remains a separate unproven artifact. - -Verification status: ready to commit. - -### 2026-06-17 Task 27 WebSocket Error Connection Lifecycle Audit Start - -Command/code area touched: starting comparison of official stream-error connection lifecycle against local pooled WebSocket handling for classified upstream error frames. - -Observed result: official `ResponsesWebsocketConnection::stream_request` calls `run_websocket_response_stream`; if it returns any error, the spawned task takes the locked stream out of the connection and drops it so the connection is not reused after a terminal stream error. Local `classify_ws_error_frame` errors currently discard only `connection_fatal` cases and call `active.finish()` for most upstream error frames, returning the socket to the local pool. That can reuse a connection after `response.failed` / wrapped upstream errors, which diverges from official Desktop behavior. - -Next decision: add a focused pooled WebSocket test where the first request receives a classified upstream error and the second request for the same conversation must open a new WebSocket instead of reusing the errored one. Then make all classified WebSocket upstream errors discard the active connection. - -Verification status: red test pending. - -### 2026-06-17 Task 27 WebSocket Error Connection Lifecycle Implementation Result - -Command/code area touched: added `websocket_pooled_upstream_error_should_discard_connection_like_official_client` in `tests/codex_gateway/websocket.rs`; updated classified upstream error handling in `src/codex/gateway/transport/websocket/mod.rs`; removed now-unused `connection_fatal` state from `src/codex/gateway/transport/websocket/codec.rs`; updated serving WebSocket recovery tests in `tests/codex_serving/responses_websocket.rs` so recovery attempts after stream errors occur on fresh upstream WebSocket connections. - -Observed result: the focused test first failed because a pooled WebSocket that returned `response.failed` with `rate_limit_exceeded` was returned to the pool; the next request for the same conversation reused that errored connection and eventually hit the receive idle timeout instead of opening a fresh WebSocket. Implementation now discards the active WebSocket for every classified upstream error frame in both first-frame and forwarding loops, matching official behavior where any `run_websocket_response_stream` error causes the connection stream to be taken and dropped. Existing serving recovery tests initially assumed same-connection retry after upstream recovery errors; their upstream mocks now accept a new WebSocket for the recovered request, preserving recovery coverage while matching official connection lifecycle. - -Verification status: `cargo test --test codex_gateway websocket_pooled_upstream_error_should_discard_connection_like_official_client` and `cargo test --test codex_serving responses_websocket` passed. Broader verification pending. - -### 2026-06-17 Task 27 WebSocket Error Connection Lifecycle Verification Result - -Command/code area touched: verified WebSocket error connection discard behavior with focused gateway coverage, the gateway WebSocket filtered suite, serving WebSocket suite, formatting, diff whitespace checks, Clippy, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 43 tests, including pooled upstream-error discard behavior. The serving `responses_websocket` suite remains at 27 passing tests, with recovery retries now modeled on fresh WebSocket connections after stream errors. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_pooled_upstream_error_should_discard_connection_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 27 as a source-backed WebSocket lifecycle parity increment. Continue auditing remaining official WebSocket behavior, including send timeout parity if it can be tested without fragile socket backpressure assumptions. - -Verification status: ready to commit. - -### 2026-06-17 Task 28 WebSocket Send Idle Timeout Audit Start - -Command/code area touched: starting comparison of official `send_websocket_request` timeout handling against local WebSocket request-frame send. - -Observed result: official wraps `ws_stream.send(Message::Text(request_text.into()))` in `tokio::time::timeout(idle_timeout, ...)` and maps timeout to `ApiError::Stream("idle timeout sending websocket request")`. Local `create_response_via_websocket_stream_inner` currently awaits `active.websocket.send(Message::Text(...))` directly, so a stuck send future has no local timeout boundary. This is distinct from Task 26 receive idle timeout. - -Next decision: add a focused virtual-time unit test for a pending send future, then route production WebSocket request sends through a helper that maps send timeout to a dedicated WebSocket error and keeps it ineligible for HTTP SSE fallback. - -Verification status: red test pending. - -### 2026-06-17 Task 28 WebSocket Send Idle Timeout Implementation Result - -Command/code area touched: added `timeout_websocket_send_should_fail_when_send_future_stalls` in `src/codex/gateway/transport/websocket/mod.rs`; added `CodexWebSocketError::SendIdleTimeout`; routed production request-frame sends through `timeout_websocket_send`; updated WebSocket fallback classification in `src/codex/gateway/transport/http_client.rs`. - -Observed result: the focused virtual-time test first failed because no send-timeout helper or `SendIdleTimeout` error variant existed. Implementation now wraps request-frame send futures in the same 20s WebSocket event timeout boundary used for receive polling and maps timeout to display text `idle timeout sending websocket request`, matching official `send_websocket_request`. The new timeout remains ineligible for HTTP SSE fallback. - -Verification status: `cargo test timeout_websocket_send_should_fail_when_send_future_stalls` passed. Broader verification pending. - -### 2026-06-17 Task 28 WebSocket Send Idle Timeout Verification Result - -Command/code area touched: verified WebSocket request send timeout handling with focused virtual-time unit coverage, the gateway WebSocket filtered suite, serving WebSocket suite, formatting, diff whitespace checks, Clippy, and the full test suite. - -Observed result: all verification commands passed. Unit coverage now includes pending send futures mapping to `SendIdleTimeout`. The gateway WebSocket filtered suite remains at 43 passing tests and the serving `responses_websocket` suite remains at 27 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test timeout_websocket_send_should_fail_when_send_future_stalls` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 28 as a source-backed WebSocket send timeout parity increment. Continue auditing remaining official WebSocket behavior and keep live macOS Desktop TLS/opening capture separate from source-backed parity work. - -Verification status: ready to commit. - -### 2026-06-17 Task 29 Unknown Response Failed Error Audit Start - -Command/code area touched: starting comparison of official `process_responses_event` fallback handling for `response.failed` errors against local WebSocket error classification. - -Observed result: official `process_responses_event` returns an `ApiError` for every `response.failed` event with a `response` object. If `response.error` parses but its code is not one of the special cases, official maps it to `ApiError::Retryable { message, delay }`; it is not emitted as a successful response event. Local `classify_ws_error_frame` only maps known error codes, so an unknown `response.failed` code currently falls through to terminal SSE encoding and can be returned as a successful `event: response.failed` body. - -Next decision: add a focused gateway test for `response.failed` with an unknown error code, then classify unknown `response.failed` errors as the proxy's existing 503 upstream error equivalent for official retryable stream errors while preserving the original body. - -Verification status: red test pending. - -### 2026-06-17 Task 29 Unknown Response Failed Error Implementation Result - -Command/code area touched: added `websocket_unknown_response_failed_should_surface_as_503_like_official_retryable` in `tests/codex_gateway/websocket.rs`; updated fallback classification in `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: the focused test first failed because local returned an OK response body containing `event: response.failed` for an unknown `response.error.code`. Implementation now maps otherwise-unclassified `response.failed` frames to `StatusCode::SERVICE_UNAVAILABLE`, the proxy's existing upstream equivalent for official retryable/stream WebSocket failures, while preserving the original upstream JSON body. - -Verification status: `cargo test --test codex_gateway websocket_unknown_response_failed_should_surface_as_503_like_official_retryable` and `cargo test --test codex_gateway response_failed` passed. Broader verification pending. - -### 2026-06-17 Task 24 Response Failed Server Overload Audit Start - -Command/code area touched: starting comparison of official `response.failed` server overload handling against local WebSocket error classification. - -Observed result: official `process_responses_event` maps `response.failed` with error code `server_is_overloaded` or `slow_down` to `ApiError::ServerOverloaded`. Local `classify_ws_error_frame` currently does not classify those codes, so a WebSocket `response.failed` frame with `server_is_overloaded` can be encoded as a successful SSE `event: response.failed` body rather than surfacing as an upstream retryable/server-overloaded error. - -Next decision: add a focused gateway test for a first-frame `response.failed` with `server_is_overloaded`, then map `server_is_overloaded` and `slow_down` to the proxy's existing 503 upstream error equivalent. - -Verification status: red test pending. - -### 2026-06-17 Task 24 Response Failed Server Overload Implementation Result - -Command/code area touched: added `websocket_response_failed_server_overloaded_should_surface_as_503_like_official_client` in `tests/codex_gateway/websocket.rs`; updated `rotatable_error_status` in `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: the focused test first failed because local returned an OK response body containing `event: response.failed` for `server_is_overloaded`. Implementation now maps `server_is_overloaded` and `slow_down` to `StatusCode::SERVICE_UNAVAILABLE`, the proxy's existing upstream equivalent for official `ApiError::ServerOverloaded`, while preserving the original upstream JSON body. - -Verification status: `cargo test --test codex_gateway websocket_response_failed_server_overloaded_should_surface_as_503_like_official_client`, `cargo test --test codex_gateway response_failed`, and `cargo test --test codex_gateway connection_limit` passed. Broader verification pending. - -### 2026-06-17 Task 24 Response Failed Server Overload Verification Result - -Command/code area touched: verified server-overload `response.failed` handling with focused gateway coverage, related response-failed and connection-limit filters, the full gateway WebSocket filtered suite, serving WebSocket suite, Clippy, formatting, diff whitespace checks, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 41 tests, including `server_is_overloaded` handling. The serving `responses_websocket` suite remained at 27 passing tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_response_failed_server_overloaded_should_surface_as_503_like_official_client` -- `cargo test --test codex_gateway response_failed` -- `cargo test --test codex_gateway connection_limit` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo test` - -Next decision: commit Task 24 as a source-backed server-overload error parity increment. Continue auditing official WebSocket behavior that is observable locally, while keeping live macOS Desktop TLS/opening capture marked as unproven on this Linux host. - -Verification status: ready to commit. - -### 2026-06-17 Task 23 Unmapped Wrapped Error Event Audit Start - -Command/code area touched: starting comparison of official `type:error` frames that are parsed as wrapped WebSocket errors but do not map to an API error, especially success `status` values and missing status. - -Observed result: official `parse_wrapped_websocket_error_event` returns an event for any text frame with `type == "error"`, but `map_wrapped_websocket_error_event` returns `None` when status is missing or `StatusCode::is_success()`. The frame is then parsed as `ResponsesStreamEvent` kind `error`; `process_responses_event` has no `error` branch and returns `Ok(None)`, so the official loop waits for more events and eventually errors if the socket closes before `response.completed`. Local currently treats any typed `error` frame as a terminal SSE event after classification misses, so a payload like `{"type":"error","status":200,...}` can become a successful response body containing `event: error`. - -Next decision: add a focused gateway test for a success-status wrapped error frame followed by close, then skip unmapped `type:error` frames instead of forwarding them as successful terminal SSE. - -Verification status: red test pending. - -### 2026-06-17 Task 23 Unmapped Wrapped Error Event Implementation Result - -Command/code area touched: added `websocket_unmapped_success_status_error_should_not_return_successful_error_event_like_official_client` in `tests/codex_gateway/websocket.rs`; updated first-frame and forwarding loops in `src/codex/gateway/transport/websocket/mod.rs`. - -Observed result: the focused test first failed because local returned an OK response body containing `event: error` for a wrapped error with `status: 200`. Implementation now applies normal error classification first; if a typed `error` frame remains unmapped after classification and metadata/incomplete handling, it is skipped instead of encoded as SSE. The loop then waits for a later `response.completed` or returns the normal close-before-completed error, matching official `process_responses_event` behavior for unhandled kind `error`. During verification, the existing unanswered-function-call serving path exposed an important proxy recovery requirement: missing-status `type:error` frames with `code: invalid_request` must still classify as `400` so history-stripping recovery can run. The classifier now treats explicit success status as unmapped, while missing-status `invalid_request` remains a 400 upstream error. - -Verification status: `cargo test --test codex_gateway websocket_unmapped_success_status_error_should_not_return_successful_error_event_like_official_client` and `cargo test --test codex_serving v1_responses_non_stream_unanswered_function_call_should_strip_history_and_retry_same_account` passed. Broader verification pending. - -### 2026-06-17 Task 23 Unmapped Wrapped Error Event Verification Result - -Command/code area touched: verified unmapped `type:error` handling with focused gateway coverage, the serving unanswered-function-call recovery path, gateway WebSocket filtered suite, serving WebSocket suite, Clippy, formatting, diff whitespace checks, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 40 tests, including the success-status wrapped error case. The serving `responses_websocket` suite remained at 27 passing tests and specifically confirms missing-status `invalid_request` still triggers history stripping rather than hanging. The full `cargo test` run completed successfully. - -Commands: -- `cargo test --test codex_gateway websocket_unmapped_success_status_error_should_not_return_successful_error_event_like_official_client` -- `cargo test --test codex_serving v1_responses_non_stream_unanswered_function_call_should_strip_history_and_retry_same_account` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo test` - -Next decision: commit Task 23 as a source-backed unmapped error event parity increment. Continue auditing remaining official WebSocket behavior and preserve the documented distinction between proven source-backed parity and unproven live macOS Desktop TLS/opening capture. - -Verification status: ready to commit. - -### 2026-06-17 Task 22 Wrapped Connection Limit Error Audit Start - -Command/code area touched: starting comparison of official wrapped WebSocket `websocket_connection_limit_reached` handling against local wrapped error status precedence. - -Observed result: official `map_wrapped_websocket_error_event` checks `error.code == "websocket_connection_limit_reached"` before parsing `status` and returns `ApiError::Retryable` with the official fallback message. Local `classify_ws_error_frame` currently checks wrapped non-success `status` before error code classification; therefore a payload like `{"type":"error","status":400,"error":{"code":"websocket_connection_limit_reached"}}` is surfaced as upstream 400 instead of the proxy's existing 503/retryable connection-limit equivalent. The proxy already uses `StatusCode::SERVICE_UNAVAILABLE` for pooled `response.failed` connection-limit frames, so the wrapped error path should share that behavior and treat the connection as fatal. - -Next decision: add a focused gateway test for wrapped `websocket_connection_limit_reached` with `status: 400`, then give that code priority over wrapped status in local classification. - -Verification status: red test pending. - -### 2026-06-17 Task 22 Wrapped Connection Limit Error Implementation Result - -Command/code area touched: added `websocket_wrapped_connection_limit_should_use_retryable_503_precedence_like_official_client`; updated the existing one-shot connection-limit `response.failed` test to expect the same retryable-equivalent behavior; changed `classify_ws_error_frame` in `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: the focused wrapped test first failed because local surfaced status 400 from the wrapped error instead of giving `websocket_connection_limit_reached` priority. After moving error-code extraction ahead of wrapped status classification, both wrapped `type:error` and `response.failed` connection-limit frames map to `StatusCode::SERVICE_UNAVAILABLE` and mark the connection fatal. This matches the proxy's existing 503 retryable equivalent for official Desktop's `ApiError::Retryable` connection-limit path and avoids treating connection exhaustion as a client invalid request. - -Verification status: `cargo test --test codex_gateway connection_limit` passed the wrapped, one-shot, and pooled connection-limit coverage. Broader verification pending. - -### 2026-06-17 Task 22 Wrapped Connection Limit Error Verification Result - -Command/code area touched: verified connection-limit precedence with focused gateway coverage, the gateway WebSocket filtered suite, serving WebSocket suite, Clippy, formatting, diff whitespace checks, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 39 tests, including wrapped connection-limit status precedence and one-shot/pool `response.failed` connection-limit handling. The serving `responses_websocket` suite remained at 27 passing tests. The full `cargo test` run completed successfully. - -Commands: -- `cargo test --test codex_gateway connection_limit` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo test` - -Next decision: commit Task 22 as a source-backed connection-limit retryability parity increment. Continue auditing remaining official WebSocket behavior, with live official TLS/opening parity still unproven without a macOS Desktop capture artifact. - -Verification status: ready to commit. - -### 2026-06-17 Task 21 Wrapped Error Headers Retry-After Audit Start - -Command/code area touched: starting comparison of official wrapped WebSocket error `headers` handling against local `CodexWebSocketError::Upstream.retry_after_seconds`. - -Observed result: official `WrappedWebsocketErrorEvent` accepts a top-level `headers` object and `map_wrapped_websocket_error_event` converts those JSON headers into `TransportError::Http { headers: Some(...) }` along with status/body. Local `classify_ws_error_frame` currently preserves status and original body but drops wrapped `headers`; callers only compute `retry_after_seconds` from the body. If upstream sends `{"type":"error","status":429,"headers":{"retry-after":"37"},...}` without body reset fields, local returns `retry_after_seconds: None`. - -Next decision: add a focused gateway test for wrapped error `headers.retry-after`, then parse that header from wrapped error JSON and use it before body-derived retry-after. - -Verification status: red test pending. - -### 2026-06-17 Task 21 Wrapped Error Headers Retry-After Implementation Result - -Command/code area touched: added `websocket_wrapped_error_retry_after_header_should_be_preserved_like_official_client` in `tests/codex_gateway/websocket.rs`; added wrapped error header parsing in `src/codex/gateway/transport/websocket/codec.rs`; updated first-frame and forwarding-loop `CodexWebSocketError::Upstream` construction in `src/codex/gateway/transport/websocket/mod.rs`. - -Observed result: the focused test first failed with `retry_after_seconds: None` when upstream sent `{"type":"error","status":429,"headers":{"retry-after":"37"}}`. Implementation now parses top-level wrapped error `headers.retry-after` case-insensitively from string, number, or first array item, filters zero values, and uses that value before falling back to body-derived `resets_in_seconds` / `resets_at`. - -Verification status: `cargo test --test codex_gateway websocket_wrapped_error_retry_after_header_should_be_preserved_like_official_client` passed. Broader verification pending. - -### 2026-06-17 Task 21 Wrapped Error Headers Retry-After Verification Result - -Command/code area touched: verified wrapped error header retry-after preservation with focused gateway coverage, gateway WebSocket filtered suite, serving WebSocket suite, Clippy, formatting, diff whitespace checks, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 38 tests, including wrapped error status, mid-stream wrapped error status, and wrapped `headers.retry-after` handling. The serving `responses_websocket` suite remained at 27 passing tests. The full `cargo test` run completed successfully. - -Commands: -- `cargo test --test codex_gateway websocket_wrapped_error_retry_after_header_should_be_preserved_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo test` - -Next decision: commit Task 21 as a source-backed wrapped error header parity increment. Continue auditing remaining official WebSocket behavior, especially status alias coverage and Desktop-only internal events, without claiming live TLS parity until a macOS Desktop capture artifact exists. - -Verification status: ready to commit. - -### 2026-06-17 Task 20 Responses Event Filter Audit Start - -Command/code area touched: starting an audit of official `process_responses_event` branches that return `Ok(None)` or transform stream events before Codex core consumes them, comparing those paths against local WebSocket-to-SSE forwarding. - -Observed result: pending. Important constraint: this proxy intentionally exposes an OpenAI-compatible SSE surface, while official Desktop converts some events into internal `ResponseEvent` variants. The audit should only produce code changes when the difference is source-backed and externally observable as incorrect proxy behavior, not merely because Desktop suppresses an event internally. - -Next decision: enumerate official `Ok(None)` / transformed branches, map them to local codec/forwarder behavior, and identify whether any should be suppressed, transformed, or left as external SSE pass-through. - -Verification status: analysis in progress. - -### 2026-06-17 Task 20 Mid-Stream Wrapped Error Audit Finding - -Command/code area touched: compared official `run_websocket_response_stream` against local `create_response_via_websocket_stream_inner` and `forward_websocket_as_sse`. - -Observed result: official checks `parse_wrapped_websocket_error_event` and maps non-success `status` / `status_code` before parsing every WebSocket text frame. Local checks `classify_ws_error_frame` only while searching for the first externally forwarded event. After the first event has been emitted and `forward_websocket_as_sse` is running, a later wrapped error frame is currently terminal only because `is_terminal_websocket_event("error")` is true, but it is forwarded as `event: error` instead of surfacing as a stream error/upstream HTTP error body. This is externally observable and source-backed. - -Next decision: add a focused gateway streaming test where `response.created` is followed by `{"type":"error","status":400,...}`, confirm the current behavior is wrong, then reuse the existing classifier in the forwarding loop. - -Verification status: red test pending. - -### 2026-06-17 Task 20 Mid-Stream Wrapped Error Implementation Result - -Command/code area touched: added `websocket_midstream_wrapped_error_status_should_surface_as_upstream_error_like_official_client` in `tests/codex_gateway/websocket.rs`; updated `forward_websocket_as_sse` in `src/codex/gateway/transport/websocket/mod.rs`. - -Observed result: the focused test first failed because local returned an OK response body containing both `event: response.created` and `event: error` for a mid-stream wrapped error with `status: 400`. Implementation now applies `classify_ws_error_frame` to every forwarded WebSocket text frame after internal rate-limit capture and before SSE encoding. Classified errors are sent as `CodexWebSocketError::Upstream`, preserving the original JSON payload and `retry_after` extraction; connection-fatal classifications discard pooled connections while non-fatal errors finish the active turn. - -Verification status: `cargo test --test codex_gateway websocket_midstream_wrapped_error_status_should_surface_as_upstream_error_like_official_client` passed. Broader verification pending. - -### 2026-06-17 Task 20 Mid-Stream Wrapped Error Verification Result - -Command/code area touched: verified the mid-stream wrapped error fix with focused gateway coverage, gateway WebSocket filtered suite, serving WebSocket suite, Clippy, formatting, diff whitespace checks, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 37 tests, including first-frame and mid-stream wrapped error status handling. The serving `responses_websocket` suite remained at 27 passing tests. The full `cargo test` run completed successfully. - -Commands: -- `cargo test --test codex_gateway websocket_midstream_wrapped_error_status_should_surface_as_upstream_error_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo test` - -Next decision: commit Task 20 as a source-backed WebSocket mid-stream error parity increment. Continue remaining audit work with care around Desktop-internal `ResponseEvent` transforms versus this proxy's OpenAI-compatible SSE surface. - -Verification status: ready to commit. - -### 2026-06-17 Task 14 Invalid Text Event Source Parity Start - -Command/code area touched: re-fetched official `codex-api/src/endpoint/responses_websocket.rs`, explored local `websocket_message_text`, `websocket_event_type`, `websocket_sse_chunk`, `create_response_via_websocket_stream_inner`, and `forward_websocket_as_sse` with CodeGraph. - -Observed result: official `run_websocket_response_stream` handles `Message::Text(text)` by first mapping wrapped websocket error events, then `serde_json::from_str::<ResponsesStreamEvent>(&text)`. If parsing fails, it logs and `continue`s without forwarding anything to the consumer. Current rs extracts an optional `type` with `websocket_event_type`, then always calls `websocket_sse_chunk(&raw, event.as_deref())`; invalid text or text JSON without a type can therefore be emitted downstream as an unnamed SSE event. - -Next decision: add a gateway WebSocket test where the upstream sends an invalid text frame before `response.completed`; the invalid frame must not appear in the collected SSE body. Then change the WS event loop to skip text frames that do not parse to a typed event. - -Verification status: source-backed difference identified; failing test pending. - -### 2026-06-17 Task 14 Invalid Text Implementation Result - -Command/code area touched: added `websocket_invalid_text_frame_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs`, then updated `src/codex/gateway/transport/websocket/{mod.rs,codec.rs}`. - -Observed result: the failing test first proved current rs forwarded `not-json-from-upstream` as an unnamed SSE event. Implementation now skips text frames that do not parse to a JSON object with a string `type`, before building an SSE chunk. `websocket_sse_chunk` now requires a concrete event name, removing the anonymous event path. Wrapped error classification and internal rate-limit event handling still run before the skip. - -Next decision: run formatting, Clippy, focused WebSocket tests, and full suite before committing Task 14. - -Verification status: `cargo test --test codex_gateway websocket_invalid_text_frame_should_be_ignored_like_official_client` passed; `cargo test --test codex_gateway websocket_` passed 33 tests; `cargo test --test codex_serving responses_websocket` passed 26 tests. - -### 2026-06-17 Task 13 Close Frame Source Parity Start - -Command/code area touched: rechecked official `responses_websocket.rs` control-flow handling and local `src/codex/gateway/transport/websocket/{mod.rs,codec.rs}` after Task 12. - -Observed result: official `run_websocket_response_stream` treats `Message::Close(_)` as an immediate error with message `websocket closed by server before response.completed`; if the stream ends without a close frame it returns `stream closed before response.completed`. Current rs maps `Message::Close(_)` through `websocket_message_text(..) == Ok(None)`, so a first-frame close can become `EmptyResponse`, and a later close collapses into the generic `ClosedBeforeTerminal` path. - -Next decision: add a gateway WebSocket test that sends a server Close frame before any visible response event and expects a WebSocket close-before-completed error instead of `EmptyResponse`. Then update event handling to distinguish Close frames from ignored Ping/Pong/Frame messages. - -Verification status: source-backed difference identified; failing test pending. - -### 2026-06-17 Task 13 Close Frame Implementation Result - -Command/code area touched: added `websocket_close_frame_before_first_event_should_error_like_official_client` in `tests/codex_gateway/websocket.rs`, then updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and the WebSocket fallback classifier in `src/codex/gateway/transport/http_client.rs`. - -Observed result: the failing test first failed to compile because `CodexWebSocketError::ClosedByServerBeforeCompleted` did not exist. Implementation now maps `Message::Close(_)` to `ClosedByServerBeforeCompleted` with official-source message `websocket closed by server before response.completed`, and maps stream termination without a close frame to `StreamClosedBeforeCompleted` with message `stream closed before response.completed`. Both errors are non-HTTP-SSE-downgrade errors. During verification, a serving test showed that a pooled connection may receive the server close from the previous terminal turn on first read during reuse; the first-frame semantic-error path now follows existing stale pooled connection behavior and retries once with a one-shot connection before surfacing the error. - -Next decision: run formatting, Clippy, focused WebSocket tests, and full suite before committing Task 13. - -Verification status: `cargo test --test codex_gateway websocket_` passed 32 tests; `cargo test --test codex_serving responses_websocket` passed 26 tests. - -### 2026-06-17 Task 13 Final Verification - -Command/code area touched: ran `cargo fmt --all --check`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, `git diff --check`, `cargo test --test codex_gateway websocket_`, `cargo test --test codex_serving responses_websocket`, and full `cargo test`. - -Observed result: all commands passed. Full `cargo test` passed unit tests, all integration suites, and doc tests. The gateway WebSocket filtered suite now covers 32 tests, including binary-frame rejection, Ping/Pong observation, server Close frame before first visible event, and Close frame after partial streaming. - -Next decision: commit Task 13. After this, remaining 1:1 work should continue from source-backed differences or a macOS environment that can execute the official bundled binary for live TLS/opening sampling. - -Verification status: ready to commit. - -### 2026-06-17 Task 14 Final Verification - -Command/code area touched: ran `cargo fmt --all --check`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, `git diff --check`, `cargo test --test codex_gateway websocket_invalid_text_frame_should_be_ignored_like_official_client`, `cargo test --test codex_gateway websocket_`, `cargo test --test codex_serving responses_websocket`, and full `cargo test`. - -Observed result: all commands passed. The focused invalid-text test confirms upstream text that cannot be parsed into a typed `ResponsesStreamEvent` is ignored and not forwarded as anonymous SSE data. The gateway WebSocket filtered suite now covers 33 tests after adding invalid-text source parity. - -Next decision: commit Task 14 as a small source-backed WebSocket stream-loop parity increment, then continue with the next official-source-backed delta. Live official TLS/opening sampling is still unavailable on this Linux host because the bundled Desktop `codex` binary is macOS arm64 Mach-O. - -Verification status: ready to commit. - -### 2026-06-17 Task 15 ResponseEvent Side-Channel Audit Start - -Command/code area touched: starting a source-backed comparison of official `ResponseEvent` side-channel handling in `codex-api/src/endpoint/responses_websocket.rs` against local gateway WebSocket forwarding and serving behavior. - -Observed result: pending. The current hypothesis is that official Desktop consumes some typed WebSocket events internally before exposing stream events to the caller; if local proxy forwards those same side-channel events downstream as SSE, it may differ from Desktop behavior. This task will not change behavior unless the official source and local tests show an observable delta. - -Next decision: inspect official event loop and local event classification/forwarding paths, then add a failing test only if a concrete mismatch exists. - -Verification status: analysis in progress. - -### 2026-06-17 Task 15 Metadata Turn State Red Test - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` and `codex-api/src/endpoint/responses_websocket.rs`; added `websocket_metadata_event_should_update_turn_state_without_forwarding` to `tests/codex_gateway/websocket.rs`. - -Observed result: official WebSocket handling parses `response.metadata`, extracts `x-codex-turn-state` from its `headers`, and then `process_responses_event` returns `Ok(None)` for the metadata frame unless side-channel fields are emitted internally. Local gateway previously forwarded any typed event as SSE and only captured turn state from the WebSocket upgrade response headers. The new focused test fails with `left: None, right: Some("turn-from-metadata")`, proving local metadata turn state is dropped. - -Next decision: add a local metadata helper that captures `response.metadata.headers.x-codex-turn-state`, skip raw metadata forwarding, and carry the updated turn state through non-streaming and streaming completion paths. - -Verification status: red test confirmed. - -### 2026-06-17 Task 15 Metadata Turn State Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`, `src/codex/gateway/transport/http_client.rs`, `src/codex/serving/dispatch/{mod.rs,stream_audit.rs}`, `tests/codex_gateway/websocket.rs`, and `tests/codex_serving/responses_websocket.rs`. - -Observed result: local WebSocket handling now parses `response.metadata.headers.x-codex-turn-state` using the same string-or-first-array shape as official `json_value_as_string`, stores it in a shared turn-state update slot, and skips forwarding raw `response.metadata` frames as SSE. Non-streaming collection reads the final shared turn state after the body stream completes. Streaming serving audit also reads the final shared turn state at completion before recording response affinity, so later `previous_response_id` continuations can send the metadata-derived `x-codex-turn-state` on a new WebSocket opening. - -Verification status: `cargo test --test codex_gateway websocket_metadata_event_should_update_turn_state_without_forwarding` passed; `cargo test --test codex_serving v1_responses_websocket_stream_should_record_metadata_turn_state_for_continuation` passed. - -### 2026-06-17 Task 15 Final Verification - -Command/code area touched: ran `cargo fmt --all`, `cargo fmt --all --check`, `git diff --check`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, `cargo test --test codex_gateway websocket_`, `cargo test --test codex_serving responses_websocket`, and full `cargo test`. - -Observed result: all commands passed. The gateway WebSocket filtered suite now covers 34 tests, and the serving `responses_websocket` suite now covers 27 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Next decision: commit Task 15 metadata turn-state parity. Remaining source-backed audit items include custom CA / Rustls connector parity and any other official event-loop side channels that can be represented in this proxy without changing the external API contract. - -Verification status: ready to commit. - -### 2026-06-17 Task 16 Custom CA / Rustls Connector Audit Start - -Command/code area touched: starting comparison of official `maybe_build_rustls_client_config_with_custom_ca()` WebSocket TLS path against local `src/codex/gateway/transport/websocket/opening.rs` and HTTP client TLS configuration. - -Observed result: pending. Official `responses_websocket.rs` builds an explicit `Connector::Rustls` only when Codex custom CA configuration exists; otherwise it leaves tungstenite to use its default connector. The local opening path has a custom byte-level handshake implementation, so parity depends on whether it can honor the same configured CA source without changing the already-matched opening bytes. - -Next decision: inspect local TLS connector construction and global config surface. Add a failing test only if the local code exposes custom CA config that the WebSocket path ignores, or document as not actionable if no such configuration path exists. - -Verification status: analysis in progress. - -### 2026-06-17 Task 16 Custom CA Implementation Result - -Command/code area touched: added `src/codex/gateway/transport/custom_ca.rs` and `tests/fixtures/test-ca.pem`; updated `src/codex/gateway/transport/{mod.rs,http_client.rs,websocket/opening.rs}` and `src/codex/accounts/service/health.rs`. - -Observed result: official `codex-client` custom CA support uses `CODEX_CA_CERTIFICATE` first, falls back to `SSL_CERT_FILE`, treats empty values as unset, parses PEM `CERTIFICATE` bundles, starts WebSocket rustls from native roots, and adds the custom CA certificates. Local HTTP and WebSocket paths previously used rustls/native roots only. Local now has a shared custom CA helper with the same env precedence and no-empty-value behavior. The HTTP client builder uses the helper and caches by `(force_http11, selected custom CA env/path)` so connection-pool reuse is preserved without ignoring CA env changes. The byte-for-byte WebSocket opening path still writes its own HTTP/1.1 handshake, but its TLS connector now uses the shared custom CA rustls config when configured and otherwise keeps the native-root behavior. - -Verification status: `cargo test custom_ca` passed 5 custom CA unit tests. Full verification pending. - -### 2026-06-17 Task 16 Final Verification - -Command/code area touched: ran `cargo fmt --all`, `cargo fmt --all --check`, `git diff --check`, `cargo test custom_ca`, `cargo test --test codex_gateway http_client`, `cargo test --test codex_gateway websocket_`, `cargo test --test codex_serving responses_websocket`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, and full `cargo test`. - -Observed result: all commands passed after changing the reqwest client cache key to include the selected custom CA env/path. Full `cargo test` passed unit tests, integration suites, and doc tests. The custom CA unit coverage includes env precedence, fallback, empty-value handling, valid PEM rustls config construction, and invalid PEM error mapping. - -Next decision: commit Task 16 as official-source-backed TLS connector parity. Remaining 1:1 uncertainty is still live TLS ClientHello/opening sampling from the official macOS Desktop binary, which cannot be executed on this Linux host. - -Verification status: ready to commit. - -### 2026-06-17 Task 17 Response Error Event Audit Start - -Command/code area touched: starting comparison of official `process_responses_event` error-like event branches against local WebSocket terminal/error handling. - -Observed result: pending. Official `process_responses_event` turns `response.failed` into an `ApiError` and turns `response.incomplete` into an `ApiError::Stream("Incomplete response returned, reason: ...")`; these are not normal downstream response events in the Desktop API stream. Local gateway currently classifies `response.completed`, `response.failed`, and `error` as terminal, but `response.incomplete` is not terminal and may be forwarded before the connection eventually closes. This task will verify whether `response.incomplete` is a concrete parity gap that can be fixed without changing unrelated serving error semantics. - -Next decision: inspect local `is_terminal_websocket_event`, upstream failure parsing, and serving tests around `response.failed` / premature close; add a focused gateway test for `response.incomplete` if the mismatch is confirmed. - -Verification status: analysis in progress. - -### 2026-06-17 Task 17 Response Incomplete Implementation Result - -Command/code area touched: added `websocket_incomplete_event_should_error_like_official_client` in `tests/codex_gateway/websocket.rs`; updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `src/codex/gateway/transport/http_client.rs`. - -Observed result: official `process_responses_event` maps `response.incomplete` to `ApiError::Stream("Incomplete response returned, reason: ...")` immediately, without exposing the raw event to the Desktop stream consumer. Local previously treated `response.incomplete` as a regular typed SSE event and would only fail later when the socket closed before `response.completed`. The implementation now parses `/response/incomplete_details/reason` with an `unknown` fallback, returns `CodexWebSocketError::IncompleteResponse`, discards the active WebSocket, and keeps the error ineligible for HTTP SSE fallback. - -Verification status: `cargo test --test codex_gateway websocket_incomplete_event_should_error_like_official_client` passed. Full verification pending. - -### 2026-06-17 Task 17 Final Verification - -Command/code area touched: ran `cargo fmt --all`, `cargo fmt --all --check`, `git diff --check`, `cargo test --test codex_gateway websocket_incomplete_event_should_error_like_official_client`, `cargo test --test codex_gateway websocket_`, `cargo test --test codex_serving responses_websocket`, `cargo clippy --all-targets --all-features --locked -- -D warnings`, and full `cargo test`. - -Observed result: all commands passed. The gateway WebSocket filtered suite now covers 35 tests after adding `response.incomplete` parity. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Next decision: commit Task 17 as a source-backed error-event parity increment. Continue auditing official `process_responses_event` and WebSocket stream side channels for any remaining behavior that is observable locally and aligned with the proxy's external API contract. - -Verification status: ready to commit. - -### 2026-06-17 Task 18 Upgrade Header Side-Channel Audit Start - -Command/code area touched: starting comparison of official WebSocket upgrade response headers `openai-model`, `x-models-etag`, and `x-reasoning-included` against local WebSocket connection metadata and serving response handling. - -Observed result: pending. Official `ResponsesWebsocketConnection::stream_request` emits `ResponseEvent::ServerModel`, `ResponseEvent::ModelsEtag`, and `ResponseEvent::ServerReasoningIncluded(true)` before reading per-turn WebSocket frames when those values were present on the upgrade response. Local currently has visible handling for `x-codex-turn-state`, `set-cookie`, and rate-limit headers on the WebSocket handshake; this task will determine whether the missing side channels are observable or useful in this proxy's API surface. - -Next decision: inspect `CodexWebSocketConnectionMetadata`, gateway response structs, serving affinity/audit usage, and any existing model-etag/reasoning-included paths in HTTP SSE. - -Verification status: analysis in progress. - -### 2026-06-17 Task 18 Upgrade Header Side-Channel Audit Result - -Command/code area touched: inspected official `responses_websocket.rs`, official `sse/responses.rs`, local `CodexWebSocketConnectionMetadata`, and project-wide references to `openai-model`, `x-models-etag`, `x-reasoning-included`, `ServerModel`, `ModelsEtag`, and `ServerReasoningIncluded`. - -Observed result: official Desktop emits these upgrade headers as internal `ResponseEvent` variants consumed by core session/compact logic, not as raw SSE frames. Local proxy has no external response fields or serving-side consumer for these Desktop-only internal events; adding them as SSE would change the OpenAI-compatible surface rather than making the current proxy behavior more faithful. Existing local WebSocket metadata capture remains limited to externally useful values: `x-codex-turn-state`, `set-cookie`, and rate-limit headers. - -Next decision: do not add unsupported external fields for Task 18. Continue auditing for differences that are both official-source-backed and observable within the proxy's current API contract. - -Verification status: no code change justified. - -### 2026-06-17 Task 19 Wrapped Error Status Audit Start - -Command/code area touched: starting comparison of official wrapped WebSocket error handling (`{"type":"error","status":...}` and `status_code`) against local `classify_ws_error_frame`. - -Observed result: official `responses_websocket.rs` checks wrapped error events before normal `ResponsesStreamEvent` parsing and maps any non-success `status` / `status_code` to a transport HTTP error with the original payload as body. Local classification currently only maps a small set of error codes from `type:error` / `response.failed`; if a wrapped error has a status but no recognized code, it can be emitted downstream as `event: error` instead of surfacing as an upstream error. - -Next decision: add a focused gateway WebSocket test for a first text frame `{"type":"error","status":400,...}` and then extend local error classification to honor wrapped non-success status values. - -Verification status: red test pending. - -### 2026-06-17 Task 19 Wrapped Error Status Implementation Result - -Command/code area touched: added `websocket_wrapped_error_status_should_surface_as_upstream_error_like_official_client` in `tests/codex_gateway/websocket.rs`; updated `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: the focused test first failed because local returned an OK SSE body containing `event: error` for a wrapped error with `status: 400`. Implementation now checks `type == "error"` for `status` or `status_code`, maps any non-success status to `ClassifiedWebSocketError`, and keeps the original payload as the upstream error body through existing `CodexWebSocketError::Upstream` handling. - -Verification status: `cargo test --test codex_gateway websocket_wrapped_error_status_should_surface_as_upstream_error_like_official_client` passed. Full verification pending. - -### 2026-06-17 Task 19 Wrapped Error Status Verification Result - -Command/code area touched: verified Task 19 with focused gateway coverage, the gateway WebSocket filtered suite, serving WebSocket suite, Clippy, formatting, diff whitespace checks, and the full test suite. - -Observed result: all verification commands passed. The gateway WebSocket filtered suite now covers 36 tests, including the wrapped status error case. The serving `responses_websocket` suite remained at 27 passing tests. The full `cargo test` run completed successfully. - -Commands: -- `cargo test --test codex_gateway websocket_wrapped_error_status_should_surface_as_upstream_error_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo test` - -Next decision: commit Task 19 as a source-backed WebSocket error parity increment, then continue auditing remaining official Desktop WebSocket stream behavior that is observable through this proxy's API contract. - -Verification status: ready to commit. - -### 2026-06-17 Task 29 Invalid Reasoning Replay Recovery Follow-up - -Command/code area touched: inspected local serving recovery paths after unknown `response.failed` classification changed WebSocket `invalid_encrypted_content` from a successful SSE terminal event into `CodexClientError::Upstream`; updated `src/codex/serving/{responses.rs,dispatch/{stream.rs,fallback.rs,mod.rs}}` and `tests/codex_serving/responses_websocket.rs`. - -Observed result: classifying all unknown `response.failed` frames as upstream errors exposed a serving gap. `invalid_encrypted_content` was previously handled only through `CollectedResponse::Failed`, where `ResponsesSseFailure::invalid_reasoning_replay()` evicted cached encrypted reasoning replay. After WebSocket classification, the same frame arrived as `CodexClientError::Upstream`, so the replay cache was not evicted before recovery. A second issue was that the proxy's generic upstream 5xx retry helper treated the new 503 as retryable and resent the same bad replay before the service layer could inspect the error. - -Implementation result: `contains_invalid_encrypted_content_signal` is now reusable by the serving entrypoint and fallback classifier. Non-stream `/v1/responses` evicts reasoning replay when a `CodexClientError::Upstream` body carries the invalid encrypted content signal. The fallback classifier now treats this as request recovery with `invalidReasoningReplay`, reusing the existing `StripPreviousResponse` transition so the retry removes implicit resume state. Generic upstream 5xx retries now exclude invalid reasoning replay so the service-level eviction/recovery path runs first. - -Focused verification passed: -- `cargo test --test codex_serving v1_responses_websocket_should_evict_reasoning_replay_after_invalid_encrypted_content` -- `cargo test classify_upstream_recovery_action_should_strip_history_after_invalid_reasoning_replay` -- `cargo test is_retryable_upstream_5xx_should_exclude_invalid_reasoning_replay` - -Next decision: run the Task 29 broader verification set: gateway WebSocket filtered tests, serving WebSocket suite, formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused tests passed; broader verification pending. - -### 2026-06-17 Task 29 Final Verification - -Command/code area touched: ran the Task 29 verification suite after formatting and the invalid reasoning replay recovery follow-up. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 44 tests, including unknown `response.failed` classification. Serving WebSocket coverage passed with 27 tests, including same-client-request recovery from `invalid_encrypted_content` after replay eviction and implicit resume stripping. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 29 as `fix: classify unknown websocket response failures`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 30 Response Failed Special Error Classification Audit Start - -Command/code area touched: starting comparison of official `process_responses_event` special-case handling for `response.failed` errors against local WebSocket error classification and serving fallback semantics. - -Observed result: pending. Task 29 aligned the broad unknown `response.failed` fallback with official retryable behavior, but official also has explicit special cases before the retryable fallback, including context-window, quota, cyber policy, invalid prompt, and server overload classifications. Local WebSocket classification already handles some status-like/rotatable codes, but this task must verify whether any official special-case error is still misclassified in a way that affects this proxy's API surface or account/request recovery behavior. - -Next decision: inspect official `codex-api/src/sse/responses.rs` special-case predicates and compare them with local `rotatable_error_status`, HTTP SSE failure classification, and serving fallback tests. Add focused red tests only for source-backed observable differences. - -Verification status: analysis in progress. - -### 2026-06-17 Task 30 Response Failed Special Error Classification Implementation Result - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `response.failed` special predicates and local `src/codex/gateway/transport/websocket/codec.rs`; added `websocket_response_failed_special_codes_should_use_official_status_classes` in `tests/codex_gateway/websocket.rs` and `v1_responses_websocket_response_failed_quota_should_retry_fallback_account` in `tests/codex_serving/responses_websocket.rs`. - -Observed result: official maps `context_length_exceeded`, `insufficient_quota`, `usage_not_included`, `cyber_policy`, `invalid_prompt`, `server_is_overloaded`, and `slow_down` before the generic retryable fallback. Local WebSocket classification already handled overload, but `insufficient_quota`, `quota_exceeded`, `context_length_exceeded`, `invalid_prompt`, `cyber_policy`, and `usage_not_included` fell through to the Task 29 generic 503 mapping. This was observable: a WebSocket `response.failed` with `insufficient_quota` retried the same account as a generic 5xx instead of using the proxy's existing 402 quota-exhausted fallback behavior. - -Implementation result: local WebSocket error classification now maps quota codes (`insufficient_quota`, `quota_exceeded`, `quota_exhausted`, `payment_required`) to 402, `usage_not_included` to 429, and fatal request/content-policy style codes (`context_length_exceeded`, `invalid_prompt`, `cyber_policy`, `invalid_request`) to 400. Unknown `response.failed` remains mapped to 503 per Task 29. - -Focused verification passed: -- `cargo test --test codex_gateway websocket_response_failed_special_codes_should_use_official_status_classes` -- `cargo test --test codex_serving v1_responses_websocket_response_failed_quota_should_retry_fallback_account` - -Next decision: run gateway WebSocket and serving WebSocket suites, formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused tests passed; broader verification pending. - -### 2026-06-17 Task 30 Final Verification - -Command/code area touched: ran Task 30 verification after formatting the WebSocket special error classification changes. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 45 tests, including official special-code status mapping for `response.failed`. Serving WebSocket coverage passed with 28 tests, including fallback-account retry after a WebSocket `insufficient_quota` frame. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 30 as `fix: align websocket response failure special codes`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 35 Response Created Missing Response Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs`, official `codex-api/src/endpoint/responses_websocket.rs`, local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`, and `tests/codex_gateway/websocket.rs`. - -Observed result: official WebSocket handling parses each text frame as `ResponsesStreamEvent` before `process_responses_event`. In `process_responses_event`, the `response.created` branch returns `ResponseEvent::Created` only when `event.response.is_some()`; otherwise it returns `Ok(None)` and the WebSocket loop keeps waiting. Local gateway currently converts any typed frame that is not internal metadata/error/completed validation into an SSE chunk, so `{"type":"response.created"}` can become a successful downstream `event: response.created`. - -Red test result: added `websocket_created_without_response_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_created_without_response_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.created`. - -Next decision: add a small response-less-created predicate in the WebSocket codec and skip those frames in both first-frame and forwarding-loop paths, matching the official `Ok(None)` behavior. - -Verification status: expected red test captured. - -### 2026-06-17 Task 35 Response Created Missing Response Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now detects `{"type":"response.created"}` frames with no `response` field and skips them in both the first-frame path and the spawned forwarding loop. This mirrors official `process_responses_event`, where the `response.created` branch only returns `ResponseEvent::Created` when `event.response.is_some()` and otherwise falls through to `Ok(None)`. - -Focused verification passed: `cargo test --test codex_gateway websocket_created_without_response_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 35 Final Verification - -Command/code area touched: ran Task 35 verification after formatting the response-less `response.created` filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 51 tests, including the new response-less created frame test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_created_without_response_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 35 as `fix: ignore websocket created frames without response`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 36 Output Text Delta Missing Delta Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `process_responses_event` branches and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `response.output_text.delta` returns `ResponseEvent::OutputTextDelta(delta)` only when the parsed `ResponsesStreamEvent` has a top-level `delta` field. If `delta` is missing, the branch falls through to `Ok(None)` and the WebSocket loop keeps waiting. Local gateway currently forwards any typed non-internal frame as SSE, so `{"type":"response.output_text.delta"}` can be exposed downstream as a successful event. - -Red test result: added `websocket_output_text_delta_without_delta_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_output_text_delta_without_delta_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_text.delta`. - -Next decision: add a small delta-less output-text predicate in the WebSocket codec and skip those frames in both first-frame and forwarding-loop paths, matching the official `Ok(None)` behavior. - -Verification status: expected red test captured. - -### 2026-06-17 Task 36 Output Text Delta Missing Delta Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now detects `{"type":"response.output_text.delta"}` frames with no `delta` field and skips them in both the first-frame path and the spawned forwarding loop. This mirrors official `process_responses_event`, where `response.output_text.delta` only returns `ResponseEvent::OutputTextDelta` when `event.delta` exists and otherwise falls through to `Ok(None)`. - -Focused verification passed: `cargo test --test codex_gateway websocket_output_text_delta_without_delta_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 36 Final Verification - -Command/code area touched: ran Task 36 verification after formatting the delta-less `response.output_text.delta` filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 52 tests, including the new delta-less output-text frame test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_output_text_delta_without_delta_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 36 as `fix: ignore websocket text deltas without delta`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 37 Required Delta Fields Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `process_responses_event` delta branches and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `response.custom_tool_call_input.delta` only emits when both `delta` and either `item_id` or `call_id` exist. Official `response.reasoning_summary_text.delta` only emits when both `delta` and `summary_index` exist. Official `response.reasoning_text.delta` only emits when both `delta` and `content_index` exist. Missing any required field falls through to `Ok(None)`. Local gateway currently forwards any typed non-internal frame as SSE after the existing error/completed/created/output-text filters, so these malformed delta events can be exposed downstream. - -Red test result: added `websocket_delta_events_missing_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_delta_events_missing_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.custom_tool_call_input.delta`. - -Next decision: add a shared predicate that skips only these official-required-field failures in both first-frame and forwarding-loop paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 37 Required Delta Fields Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now detects malformed `response.custom_tool_call_input.delta`, `response.reasoning_summary_text.delta`, and `response.reasoning_text.delta` frames that lack the official required fields and skips them in both the first-frame path and the spawned forwarding loop. Normal frames with the required fields remain eligible for existing raw SSE forwarding. - -Focused verification passed: `cargo test --test codex_gateway websocket_delta_events_missing_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 37 Final Verification - -Command/code area touched: ran Task 37 verification after formatting the official-required-field delta filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 53 tests, including the new required-delta-fields test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_delta_events_missing_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 37 as `fix: ignore malformed websocket delta events`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 38 Output Item Missing Item Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `process_responses_event` branches for `response.output_item.done` and `response.output_item.added`, plus local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `response.output_item.done` and `response.output_item.added` only emit internal `ResponseEvent` values when `event.item` exists and `serde_json::from_value::<ResponseItem>(item_val)` succeeds. If `item` is missing, both branches fall through to `Ok(None)`. Local gateway currently forwards typed non-internal frames as raw SSE after the existing malformed-event filters, so item-less `output_item` frames can be exposed downstream. - -Red test result: added `websocket_output_item_events_without_item_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_output_item_events_without_item_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: add a small predicate that skips item-less `response.output_item.done` and `response.output_item.added` in both first-frame and forwarding-loop paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 38 Output Item Missing Item Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now detects item-less `response.output_item.done` and `response.output_item.added` frames and skips them in both the first-frame path and the spawned forwarding loop. This matches the official `Ok(None)` behavior when `event.item` is absent. Full `ResponseItem` parse-equivalence remains a separate audit item because this repository does not currently depend on the official `codex_protocol` model crate. - -Focused verification passed: `cargo test --test codex_gateway websocket_output_item_events_without_item_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 38 Final Verification - -Command/code area touched: ran Task 38 verification after formatting the item-less `output_item` filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 54 tests, including the new item-less output-item frame test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_output_item_events_without_item_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 38 as `fix: ignore websocket output item frames without item`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 39 Reasoning Summary Part Missing Summary Index Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `process_responses_event` branch for `response.reasoning_summary_part.added`, plus local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `response.reasoning_summary_part.added` only emits `ResponseEvent::ReasoningSummaryPartAdded` when `event.summary_index` exists. If `summary_index` is missing, the branch falls through to `Ok(None)` and the WebSocket loop keeps waiting. Local gateway currently forwards typed non-internal frames as raw SSE after the existing malformed-event filters, so `{"type":"response.reasoning_summary_part.added"}` can be exposed downstream. - -Red test result: added `websocket_reasoning_summary_part_added_without_summary_index_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_reasoning_summary_part_added_without_summary_index_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.reasoning_summary_part.added`. - -Next decision: add a narrow predicate that skips summary-index-less `response.reasoning_summary_part.added` frames in both first-frame and forwarding-loop paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 39 Reasoning Summary Part Missing Summary Index Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now detects `{"type":"response.reasoning_summary_part.added"}` frames with no integer `summary_index` and skips them in both the first-frame path and the spawned forwarding loop. This mirrors official `process_responses_event`, where `response.reasoning_summary_part.added` only returns `ResponseEvent::ReasoningSummaryPartAdded` when `event.summary_index` exists. - -Focused verification passed: `cargo test --test codex_gateway websocket_reasoning_summary_part_added_without_summary_index_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 39 Final Verification - -Command/code area touched: ran Task 39 verification after formatting the summary-index-less `response.reasoning_summary_part.added` filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 55 tests, including the new summary-index-less reasoning-summary-part test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_reasoning_summary_part_added_without_summary_index_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 39 as `fix: ignore websocket reasoning summary parts without index`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 40 Nullable Option Fields Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `ResponsesStreamEvent` and `process_responses_event`, official `codex-api/src/endpoint/responses_websocket.rs` frame loop, plus local `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: official `ResponsesStreamEvent` declares `response`, `item`, `delta`, `summary_index`, and related optional fields as `Option`, and the WebSocket loop deserializes text frames into that struct before calling `process_responses_event`. JSON `null` for these fields therefore becomes `None` and follows the same `Ok(None)` ignore path as a missing field. Local skip predicates still use field-presence checks for several already-audited cases, so `response.created` with `response: null`, `response.output_text.delta` with `delta: null`, `output_item` events with `item: null`, and `response.completed` with `response: null` can diverge from the official missing-field behavior. - -Red test result: added `websocket_null_option_fields_should_be_ignored_like_missing_fields_in_official_client` and ran `cargo test --test codex_gateway websocket_null_option_fields_should_be_ignored_like_missing_fields_in_official_client`. It failed as expected because local returned `CodexWebSocketError::InvalidCompletedResponse` for `response.completed` with `response: null` instead of ignoring it like official `Option<Value>::None`. - -Next decision: update the existing official-shaped skip predicates to treat null as absent only for these source-backed fields. - -Verification status: expected red test captured. - -### 2026-06-17 Task 40 Nullable Option Fields Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket skip predicates now treat JSON `null` as absent for the already source-backed optional fields `response`, `delta`, and `item`. This covers `response.created`, `response.completed`, `response.output_text.delta`, `response.output_item.done`, and `response.output_item.added` without expanding into wrong-type parse behavior, which remains a separate audit item. - -Focused verification passed: `cargo test --test codex_gateway websocket_null_option_fields_should_be_ignored_like_missing_fields_in_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 40 Final Verification - -Command/code area touched: ran Task 40 verification after formatting the nullable official `Option` field filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 56 tests, including the new null-valued option field test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_null_option_fields_should_be_ignored_like_missing_fields_in_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 40 as `fix: treat null websocket option fields as missing`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 41 Official Event Shape Parse Audit Start - -Command/code area touched: inspected official `codex-api/src/endpoint/responses_websocket.rs` WebSocket text-frame loop, official `codex-api/src/sse/responses.rs` `ResponsesStreamEvent`, plus local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official WebSocket handling runs `serde_json::from_str::<ResponsesStreamEvent>(&text)` before `process_responses_event`; if deserialization fails, it logs and `continue`s. This means typed frames with invalid official top-level field types, such as `{"type":"response.output_text.delta","delta":123}`, are ignored rather than forwarded. Local handling currently extracts the string `type` from raw JSON and can forward such typed frames as SSE unless an existing predicate catches them. - -Red test result: added `websocket_frames_with_invalid_official_event_shape_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_frames_with_invalid_official_event_shape_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_text.delta`. - -Next decision: add a local official-shape parse guard that skips only frames which fail the same top-level `ResponsesStreamEvent` deserialization. - -Verification status: expected red test captured. - -### 2026-06-17 Task 41 Official Event Shape Parse Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now validates typed text frames against a local `ResponsesStreamEvent` top-level shape before raw SSE forwarding. Frames that fail the same official top-level deserialization, such as `response.output_text.delta` with numeric `delta`, are skipped in both first-frame and forwarding-loop paths. Downstream official per-event parsing remains separate; for example, `response.completed.response` is still validated by the existing `ResponseCompleted` parse check. - -Focused verification passed: `cargo test --test codex_gateway websocket_frames_with_invalid_official_event_shape_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 41 Final Verification - -Command/code area touched: ran Task 41 verification after formatting the official event-shape parse guard. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 57 tests, including the new invalid official event shape test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_frames_with_invalid_official_event_shape_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 41 as `fix: ignore malformed websocket event shapes`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 42 Output Item Non-Object Item Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `process_responses_event` branches for `response.output_item.done` and `response.output_item.added`, official output-item tests in `codex-api/src/sse/responses.rs`, and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `output_item` branches only emit when `event.item` exists and `serde_json::from_value::<ResponseItem>(item_val)` succeeds. Official examples parse object-shaped items such as message and tool-search-call records. A primitive `item` value cannot satisfy the official `ResponseItem` enum shape, so official handling falls through to `Ok(None)`. Local handling now validates only top-level `ResponsesStreamEvent`, where `item: Value` accepts primitives, then forwards typed frames unless the item is missing/null. - -Scope note: this task only aligns the clear non-object `item` parse failure. Full official `ResponseItem` parse-equivalence remains separate because this repository still does not depend on the official `codex_protocol` model crate. - -Red test result: added `websocket_output_item_events_with_non_object_item_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_output_item_events_with_non_object_item_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip present non-object `item` values in both WebSocket forwarding paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 42 Output Item Non-Object Item Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose `item` field is present but not an object, in both first-frame and forwarding-loop paths. This matches the official behavior for primitive `item` values because official `serde_json::from_value::<ResponseItem>` cannot parse them and falls through to `Ok(None)`. - -Focused verification passed: `cargo test --test codex_gateway websocket_output_item_events_with_non_object_item_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 42 Final Verification - -Command/code area touched: ran Task 42 verification after formatting the non-object `output_item.item` filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 58 tests, including the new non-object output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_output_item_events_with_non_object_item_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 42 as `fix: ignore websocket output item frames with non-object items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 43 Output Item Missing Type Tag Audit Start - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs` `output_item` branches and official `codex-rs/protocol/src/models.rs` from git tree via `git show HEAD:codex-rs/protocol/src/models.rs`, plus local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `process_responses_event` only emits `OutputItemDone`/`OutputItemAdded` when `serde_json::from_value::<ResponseItem>(item_val)` succeeds. Official `ResponseItem` is declared as `#[serde(tag = "type", rename_all = "snake_case")]`; its `Other` variant is `#[serde(other)]`, so unknown string `type` values can still parse as `Other`, but missing `type` or non-string `type` cannot satisfy the tagged enum shape. Local handling currently forwards object-shaped `item` values even when `item.type` is absent or numeric. - -Red test result: added `websocket_output_item_events_with_invalid_item_type_tag_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_output_item_events_with_invalid_item_type_tag_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip `output_item` frames whose object `item.type` is absent or non-string in both WebSocket forwarding paths, without filtering unknown string `type` values. - -Verification status: expected red test captured. - -### 2026-06-17 Task 43 Output Item Missing Type Tag Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose object-shaped `item` has no string `type` tag, in both first-frame and forwarding-loop paths. Unknown string `type` values remain eligible for forwarding because official `ResponseItem` includes `#[serde(other)] Other`. - -Focused verification passed: `cargo test --test codex_gateway websocket_output_item_events_with_invalid_item_type_tag_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 43 Final Verification - -Command/code area touched: ran Task 43 verification after formatting the invalid `output_item.item.type` tag filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 59 tests, including the new invalid output-item type tag test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` initially exposed an unrelated transient failure in `codex::tasks::token_refresh::tests::do_refresh_inner_should_restore_refreshing_account_after_transient_failures`; that test passed when rerun directly, and the full suite passed on rerun. - -Commands: -- `cargo test --test codex_gateway websocket_output_item_events_with_invalid_item_type_tag_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` -- `cargo test codex::tasks::token_refresh::tests::do_refresh_inner_should_restore_refreshing_account_after_transient_failures` -- `cargo test` - -Next decision: commit Task 43 as `fix: ignore websocket output item frames with invalid type tags`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 44 Message Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `process_responses_event` still emits `OutputItemDone`/`OutputItemAdded` only after `serde_json::from_value::<ResponseItem>(item_val)` succeeds. For known `type: "message"` items, official `ResponseItem::Message` requires `role: String` and `content: Vec<ContentItem>`; missing or non-string `role`, and missing or non-array `content`, fail official parsing. Local filtering now validates the top-level event shape and item type tag, but still forwards known `message` items whose required fields are malformed. - -Next decision: add a focused gateway WebSocket red test using malformed `message` output items, then add a scoped local predicate for the required `message` fields. - -Red test result: added `websocket_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `message` output-item frames whose object `item.role` is not a string or whose `item.content` is not an array, in both WebSocket forwarding paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 44 Message Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "message"` item lacks a string `role` or array `content`, in both first-frame and forwarding-loop paths. Unknown string item types remain eligible for forwarding because official `ResponseItem` maps unknown tags to `Other`. - -Focused verification passed: `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 44 Final Verification - -Command/code area touched: ran Task 44 verification after formatting the malformed `message` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 60 tests, including the new malformed `message` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 44 as `fix: ignore malformed websocket message output items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 45 Function Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::FunctionCall` is a known tagged variant and requires `name: String`, `arguments: String`, and `call_id: String`; `id`, `namespace`, and `metadata` are optional. Because official WebSocket event processing emits output-item events only after `serde_json::from_value::<ResponseItem>(item_val)` succeeds, malformed `function_call` items with missing or non-string required fields are ignored. Local filtering currently validates only the item tag and the `message` required fields, so malformed known `function_call` items can still be forwarded. - -Next decision: add a focused gateway WebSocket red test using malformed `function_call` output items, then add a scoped local predicate for the required `function_call` fields. - -Red test result: added `websocket_function_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_function_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `function_call` output-item frames whose object `item.name`, `item.arguments`, or `item.call_id` is not a string, in both WebSocket forwarding paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 45 Function Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "function_call"` item lacks string `name`, `arguments`, or `call_id`, in both first-frame and forwarding-loop paths. Unknown string item types remain eligible for forwarding because official `ResponseItem` maps unknown tags to `Other`. - -Focused verification passed: `cargo test --test codex_gateway websocket_function_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 45 Final Verification - -Command/code area touched: ran Task 45 verification after formatting the malformed `function_call` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 61 tests, including the new malformed `function_call` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_function_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 45 as `fix: ignore malformed websocket function call output items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 46 Tool Search Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::ToolSearchCall` is a known tagged variant with optional `id`, optional `call_id`, optional `status`, required `execution: String`, and required `arguments: serde_json::Value`. Because `arguments` is `Value`, a present `null`, object, array, string, number, or bool can parse, but a missing `arguments` field cannot. Local filtering currently validates only `message` and `function_call` known-item required fields, so malformed known `tool_search_call` items with missing/non-string `execution` or missing `arguments` can still be forwarded. - -Next decision: add a focused gateway WebSocket red test using malformed `tool_search_call` output items, then add a scoped local predicate for the required `tool_search_call` fields. - -Red test result: added `websocket_tool_search_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_tool_search_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `tool_search_call` output-item frames whose object `item.execution` is not a string or whose `item.arguments` field is absent, in both WebSocket forwarding paths. Do not skip present `arguments: null` because official `serde_json::Value` accepts it. - -Verification status: expected red test captured. - -### 2026-06-17 Task 46 Tool Search Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "tool_search_call"` item lacks string `execution` or lacks the required `arguments` field, in both first-frame and forwarding-loop paths. Present `arguments: null` remains eligible because official `serde_json::Value` accepts null values. - -Focused verification passed: `cargo test --test codex_gateway websocket_tool_search_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 46 Final Verification - -Command/code area touched: ran Task 46 verification after formatting the malformed `tool_search_call` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 62 tests, including the new malformed `tool_search_call` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_tool_search_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 46 as `fix: ignore malformed websocket tool search output items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 47 Function Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::FunctionCallOutput` is a known tagged variant with required `call_id: String` and required `output: FunctionCallOutputPayload`; `metadata` is optional. `FunctionCallOutputPayload` serializes/deserializes its body as either plain text (`String`) or structured content items (`Vec<FunctionCallOutputContentItem>`), so a missing `output` field or a primitive/object output that is not string/array cannot parse. Local filtering currently validates `message`, `function_call`, and `tool_search_call` known-item fields, but still forwards malformed known `function_call_output` items. - -Next decision: add a focused gateway WebSocket red test using malformed `function_call_output` output items, then add a scoped local predicate for the required `function_call_output` fields and output wire shape. - -Red test result: added `websocket_function_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_function_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `function_call_output` output-item frames whose object `item.call_id` is not a string, whose `item.output` field is absent, or whose `item.output` is neither a string nor an array, in both WebSocket forwarding paths. - -Verification status: expected red test captured. - -### 2026-06-17 Task 47 Function Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "function_call_output"` item lacks string `call_id`, lacks `output`, or has an `output` value that is neither string nor array, in both first-frame and forwarding-loop paths. String output and array output remain eligible because they match official `FunctionCallOutputPayload` wire encoding. - -Focused verification passed: `cargo test --test codex_gateway websocket_function_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -Verification status: focused test passed; broader verification pending. - -### 2026-06-17 Task 47 Final Verification - -Command/code area touched: ran Task 47 verification after formatting the malformed `function_call_output` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 63 tests, including the new malformed `function_call_output` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_function_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 47 as `fix: ignore malformed websocket function output items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 48 Custom Tool Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree, official `codex-rs/codex-api/src/sse/responses.rs`, local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`, and `tests/codex_gateway/websocket.rs`; used `codegraph query` to confirm local predicate/import placement. - -Observed result: official `ResponseItem::CustomToolCall` is a known tagged variant with optional `id`, optional `status`, and required `call_id: String`, `name: String`, and `input: String`. Official WebSocket event processing emits `response.output_item.done` / `response.output_item.added` only when `serde_json::from_value::<ResponseItem>(item_val)` succeeds, so malformed known `custom_tool_call` items with missing or non-string required fields are ignored. Local filtering currently validates other known output-item variants but still forwards malformed known `custom_tool_call` items. - -Next decision: add a focused gateway WebSocket red test using malformed `custom_tool_call` output items, then add a scoped local predicate for the required `custom_tool_call` fields and wire it into both forwarding paths. - -Red test result: added `websocket_custom_tool_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_custom_tool_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `custom_tool_call` output-item frames whose object `item.call_id`, `item.name`, or `item.input` is not a string, in both WebSocket forwarding paths. - -### 2026-06-17 Task 48 Custom Tool Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "custom_tool_call"` item lacks string `call_id`, `name`, or `input`, in both first-frame and forwarding-loop paths. Optional official fields (`id`, `status`, `metadata`) remain unconstrained. - -Focused verification passed: `cargo test --test codex_gateway websocket_custom_tool_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 48 Final Verification - -Command/code area touched: ran Task 48 verification after formatting the malformed `custom_tool_call` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 64 tests, including the new malformed `custom_tool_call` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_custom_tool_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 48 as `fix: ignore malformed websocket custom tool calls`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 49 Custom Tool Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::CustomToolCallOutput` is a known tagged variant with required `call_id: String` and required `output: FunctionCallOutputPayload`; optional `name` and `metadata` are not part of this required-field pass. `FunctionCallOutputPayload` uses the same wire encoding as `function_call_output.output`, so `output` must parse as either a plain string or an array of structured content items. Local filtering already validates `function_call_output` payload shape and `custom_tool_call` required fields, but still forwards malformed known `custom_tool_call_output` items. - -Next decision: add a focused gateway WebSocket red test using malformed `custom_tool_call_output` output items, then add a scoped local predicate for required `call_id` and `output` wire shape in both forwarding paths. - -Red test result: added `websocket_custom_tool_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_custom_tool_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `custom_tool_call_output` output-item frames whose object `item.call_id` is not a string, whose `item.output` field is absent, or whose `item.output` is neither a string nor an array, in both WebSocket forwarding paths. - -### 2026-06-17 Task 49 Custom Tool Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "custom_tool_call_output"` item lacks string `call_id`, lacks `output`, or has an `output` value that is neither string nor array, in both first-frame and forwarding-loop paths. This matches the required-field and payload wire-shape behavior of official `ResponseItem::CustomToolCallOutput`. - -Focused verification passed: `cargo test --test codex_gateway websocket_custom_tool_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 49 Final Verification - -Command/code area touched: ran Task 49 verification after formatting the malformed `custom_tool_call_output` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 65 tests, including the new malformed `custom_tool_call_output` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_custom_tool_call_output_payload_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 49 as `fix: ignore malformed websocket custom tool outputs`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 50 Tool Search Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::ToolSearchOutput` is a known tagged variant with optional `call_id`, required `status: String`, required `execution: String`, and required `tools: Vec<serde_json::Value>`. Because `tools` is a vector, the field must be present as a JSON array; array elements may be any JSON value. Local filtering validates malformed `tool_search_call` items but still forwards malformed known `tool_search_output` items. - -Next decision: add a focused gateway WebSocket red test using malformed `tool_search_output` output items, then add a scoped local predicate for required `status`, `execution`, and array-shaped `tools` in both forwarding paths. - -Red test result: added `websocket_tool_search_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_tool_search_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `tool_search_output` output-item frames whose object `item.status` or `item.execution` is not a string, or whose `item.tools` is not an array, in both WebSocket forwarding paths. - -### 2026-06-17 Task 50 Tool Search Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "tool_search_output"` item lacks string `status`, lacks string `execution`, or lacks array-shaped `tools`, in both first-frame and forwarding-loop paths. `call_id` remains optional for this required-field pass. - -Focused verification passed: `cargo test --test codex_gateway websocket_tool_search_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 50 Final Verification - -Command/code area touched: ran Task 50 verification after formatting the malformed `tool_search_output` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 66 tests, including the new malformed `tool_search_output` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_tool_search_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 50 as `fix: ignore malformed websocket tool search outputs`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 51 Image Generation Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::ImageGenerationCall` is a known tagged variant with required `id: String`, required `status: String`, and required `result: String`; `revised_prompt` and `metadata` are optional. Official WebSocket event processing emits output-item events only after `serde_json::from_value::<ResponseItem>(item_val)` succeeds, so malformed known `image_generation_call` items with missing or non-string required fields are ignored. Local filtering currently does not validate this known item variant. - -Next decision: add a focused gateway WebSocket red test using malformed `image_generation_call` output items, then add a scoped local predicate for required `id`, `status`, and `result` in both forwarding paths. - -Red test result: added `websocket_image_generation_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_image_generation_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `image_generation_call` output-item frames whose object `item.id`, `item.status`, or `item.result` is not a string, in both WebSocket forwarding paths. - -### 2026-06-17 Task 51 Image Generation Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "image_generation_call"` item lacks string `id`, string `status`, or string `result`, in both first-frame and forwarding-loop paths. Optional `revised_prompt` and `metadata` remain unconstrained for this required-field pass. - -Focused verification passed: `cargo test --test codex_gateway websocket_image_generation_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 51 Final Verification - -Command/code area touched: ran Task 51 verification after formatting the malformed `image_generation_call` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 67 tests, including the new malformed `image_generation_call` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_image_generation_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 51 as `fix: ignore malformed websocket image generation calls`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 52 Compaction Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::Compaction` is a known tagged variant with alias `compaction_summary` and required `encrypted_content: String`; `metadata` is optional. Official WebSocket event processing emits output-item events only after `serde_json::from_value::<ResponseItem>(item_val)` succeeds, so known `compaction` or `compaction_summary` items with missing or non-string `encrypted_content` are ignored. Local filtering currently does not validate this known item variant. - -Next decision: add a focused gateway WebSocket red test using malformed `compaction` and `compaction_summary` output items, then add a scoped local predicate for required `encrypted_content` in both forwarding paths. - -Red test result: added `websocket_compaction_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_compaction_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `compaction` and `compaction_summary` output-item frames whose object `item.encrypted_content` is not a string, in both WebSocket forwarding paths. - -### 2026-06-17 Task 52 Compaction Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type` is `compaction` or `compaction_summary` and whose `item.encrypted_content` is not a string, in both first-frame and forwarding-loop paths. Optional `metadata` remains unconstrained for this required-field pass. - -Focused verification passed: `cargo test --test codex_gateway websocket_compaction_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 52 Final Verification - -Command/code area touched: ran Task 52 verification after formatting the malformed `compaction` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 68 tests, including the new malformed `compaction` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_compaction_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 52 as `fix: ignore malformed websocket compaction items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 53 Agent Message Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::AgentMessage` is a known tagged variant with required `author: String`, required `recipient: String`, and required `content: Vec<AgentMessageInputContent>`; `metadata` is optional. `AgentMessageInputContent` is itself a tagged enum with `input_text { text: String }` and `encrypted_content { encrypted_content: String }`, but this pass is scoped to the outer required fields already used by local output-item filtering. Official WebSocket event processing emits output-item events only after `serde_json::from_value::<ResponseItem>(item_val)` succeeds, so malformed known `agent_message` items with missing or non-string `author` / `recipient`, or non-array `content`, are ignored. Local filtering currently does not validate this known item variant. - -Next decision: add a focused gateway WebSocket red test using malformed `agent_message` output items, then add a scoped local predicate for required `author`, `recipient`, and array-shaped `content` in both forwarding paths. - -Red test result: added `websocket_agent_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_agent_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `agent_message` output-item frames whose object `item.author` or `item.recipient` is not a string, or whose `item.content` is not an array, in both WebSocket forwarding paths. - -### 2026-06-17 Task 53 Agent Message Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "agent_message"` item lacks string `author`, lacks string `recipient`, or lacks array-shaped `content`, in both first-frame and forwarding-loop paths. Optional `metadata` remains unconstrained for this required-field pass. - -Focused verification passed: `cargo test --test codex_gateway websocket_agent_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 53 Final Verification - -Command/code area touched: ran Task 53 verification after formatting the malformed `agent_message` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 69 tests, including the new malformed `agent_message` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_agent_message_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 53 as `fix: ignore malformed websocket agent messages`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 54 Reasoning Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::Reasoning` is a known tagged variant with `id: String` using `#[serde(default)]`, required `summary: Vec<ReasoningItemReasoningSummary>`, optional/defaulted `content: Option<Vec<ReasoningItemContent>>`, optional `encrypted_content: Option<String>`, and optional `metadata`. Missing `id` parses as an empty string, but present non-string/null `id` does not parse. Missing or null `content` / `encrypted_content` parse as `None`, but present non-array `content` or present non-string `encrypted_content` fails. Local output-item filtering currently does not validate the known `reasoning` variant at all. - -Next decision: add a focused gateway WebSocket red test using malformed `reasoning` output items, then add a scoped local predicate for the outer official parse shape in both forwarding paths. - -Red test result: added `websocket_reasoning_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_reasoning_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `reasoning` output-item frames whose object lacks array `summary`, has a present non-string `id`, has a present non-null non-array `content`, or has a present non-null non-string `encrypted_content`, in both WebSocket forwarding paths. - -### 2026-06-17 Task 54 Reasoning Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "reasoning"` item lacks array `summary`, has a present non-string `id`, has a present non-null non-array `content`, or has a present non-null non-string `encrypted_content`, in both first-frame and forwarding-loop paths. Missing `id`, missing/null `content`, and missing/null `encrypted_content` remain eligible because they match official serde behavior for this outer shape. - -Focused verification passed: `cargo test --test codex_gateway websocket_reasoning_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 54 Final Verification - -Command/code area touched: ran Task 54 verification after formatting the malformed `reasoning` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 70 tests, including the new malformed `reasoning` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_reasoning_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 54 as `fix: ignore malformed websocket reasoning items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 55 Message Content Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}`. - -Observed result: official `ResponseItem::Message.content` parses as `Vec<ContentItem>`. `ContentItem` is a `#[serde(tag = "type", rename_all = "snake_case")]` enum with no `serde(other)`: `input_text` requires string `text`, `output_text` requires string `text`, and `input_image` requires string `image_url` with optional `detail: ImageDetail`. `ImageDetail` accepts only lowercase `auto`, `low`, `high`, or `original`. Local filtering currently validates only that `message.content` is an array, so malformed nested content items can still be forwarded. - -Next decision: add a focused gateway WebSocket red test using malformed nested `message.content` entries, then extend the existing `message` output-item predicate to reject nested content items that cannot parse as official `ContentItem`. - -Red test result: added `websocket_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: extend the existing `message` output-item predicate to skip known `message` items when any nested `content` entry is not an object, lacks a supported string `type`, lacks required string fields for `input_text`, `output_text`, or `input_image`, or has a present invalid `detail` value. - -### 2026-06-17 Task 55 Message Content Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "message"` item has malformed nested `content` entries. The predicate now rejects non-object content entries, missing/unsupported content `type`, missing string `text` for `input_text` / `output_text`, missing string `image_url` for `input_image`, and present invalid `detail` values. Extra fields remain unconstrained, matching serde's default unknown-field behavior. - -Focused verification passed: `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 55 Final Verification - -Command/code area touched: ran Task 55 verification after formatting the malformed nested `message.content` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 71 tests, including the new malformed nested `message.content` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 55 as `fix: ignore malformed websocket message content items`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 56 Agent Message Content Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree and local `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: official `ResponseItem::AgentMessage.content` parses as `Vec<AgentMessageInputContent>`. `AgentMessageInputContent` is a `#[serde(tag = "type", rename_all = "snake_case")]` enum with no `serde(other)`: `input_text` requires string `text`, and `encrypted_content` requires string `encrypted_content`. Local filtering currently validates only that `agent_message.content` is an array, so malformed nested content items can still be forwarded. - -Next decision: add a focused gateway WebSocket red test using malformed nested `agent_message.content` entries, then extend the existing `agent_message` output-item predicate to reject nested content items that cannot parse as official `AgentMessageInputContent`. - -Red test result: added `websocket_agent_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_agent_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: extend the existing `agent_message` output-item predicate to skip known `agent_message` items when any nested `content` entry is not an object, lacks a supported string `type`, lacks string `text` for `input_text`, or lacks string `encrypted_content` for `encrypted_content`. - -### 2026-06-17 Task 56 Agent Message Content Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "agent_message"` item has malformed nested `content` entries. The predicate now rejects non-object content entries, missing/unsupported content `type`, missing string `text` for `input_text`, and missing string `encrypted_content` for `encrypted_content`. Extra fields remain unconstrained, matching serde's default unknown-field behavior. - -Focused verification passed: `cargo test --test codex_gateway websocket_agent_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 56 Final Verification - -Command/code area touched: ran Task 56 verification after adding malformed nested `agent_message.content` output-item filtering. The verification also covers the test-only token refresh scheduler timing stabilization added after a transient full-suite failure exposed virtual-time advancement racing the spawned refresh task. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 72 tests, including the new malformed nested `agent_message.content` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_agent_message_output_item_events_with_invalid_content_items_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 56 as `fix: ignore malformed websocket agent message content`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 57 Reasoning Nested Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src` and local `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: official `ResponseItem::Reasoning.summary` parses as `Vec<ReasoningItemReasoningSummary>`, where the only tagged variant is `summary_text { text: String }`. Official `ResponseItem::Reasoning.content` parses as `Option<Vec<ReasoningItemContent>>`, where present array entries must be tagged objects with `reasoning_text { text: String }` or `text { text: String }`. Both nested enums have no `serde(other)`, so non-object entries, missing/unsupported `type`, or missing/non-string `text` fail `serde_json::from_value::<ResponseItem>`. Local filtering currently validates that `summary` is an array and present `content` is an array, but it does not validate the nested entries, so malformed nested reasoning items can still be forwarded. - -Next decision: add a focused gateway WebSocket red test using malformed nested `reasoning.summary` and `reasoning.content` entries, then extend the existing `reasoning` output-item predicate to reject nested items that cannot parse as the official tagged enums. - -Red test result: added `websocket_reasoning_output_item_events_with_invalid_nested_items_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_reasoning_output_item_events_with_invalid_nested_items_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: extend the existing `reasoning` output-item predicate to skip known `reasoning` items when any nested `summary` entry is not `summary_text` with string `text`, or any present `content` entry is not `reasoning_text` / `text` with string `text`. - -### 2026-06-17 Task 57 Reasoning Nested Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "reasoning"` item has malformed nested `summary` or `content` entries. The predicate now rejects non-object nested entries, missing/unsupported nested `type`, missing string `text` for `summary_text`, and missing string `text` for `reasoning_text` / `text`. Missing/null `content` remains eligible because it matches official `Option<Vec<_>>` serde behavior; extra fields remain unconstrained. - -Focused verification passed: `cargo test --test codex_gateway websocket_reasoning_output_item_events_with_invalid_nested_items_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 57 Final Verification - -Command/code area touched: ran Task 57 verification after formatting the malformed nested `reasoning.summary` / `reasoning.content` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 73 tests, including the new malformed nested reasoning output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_reasoning_output_item_events_with_invalid_nested_items_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 57 as `fix: ignore malformed websocket reasoning content`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 58 Function Output Content Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src` and local `src/codex/gateway/transport/websocket/codec.rs`. - -Observed result: official `function_call_output.output` and `custom_tool_call_output.output` both parse as `FunctionCallOutputPayload`, whose array body is `Vec<FunctionCallOutputContentItem>`. `FunctionCallOutputContentItem` is a `#[serde(tag = "type", rename_all = "snake_case")]` enum with no `serde(other)`: `input_text` requires string `text`, `input_image` requires string `image_url` and optional `detail: ImageDetail`, and `encrypted_content` requires string `encrypted_content`. Local filtering currently validates only that `output` is a string or array, so malformed structured array entries can still be forwarded. - -Next decision: add a focused gateway WebSocket red test using malformed structured `function_call_output.output[]` and `custom_tool_call_output.output[]` entries, then extend the shared payload predicate to reject entries that cannot parse as official `FunctionCallOutputContentItem`. - -Red test result: added `websocket_function_output_payload_item_events_with_invalid_content_items_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_function_output_payload_item_events_with_invalid_content_items_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: extend both `function_call_output` and `custom_tool_call_output` payload predicates to reject array `output` entries that are non-object, have missing/unsupported `type`, lack string fields required by `input_text`, `input_image`, or `encrypted_content`, or have an invalid present image `detail`. - -### 2026-06-17 Task 58 Function Output Content Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "function_call_output"` or `item.type == "custom_tool_call_output"` item uses an array `output` with malformed structured content entries. String `output` remains eligible. Array entries now reject non-object items, missing/unsupported content `type`, missing string `text` for `input_text`, missing string `image_url` for `input_image`, invalid present `detail`, and missing string `encrypted_content` for `encrypted_content`. - -Focused verification passed: `cargo test --test codex_gateway websocket_function_output_payload_item_events_with_invalid_content_items_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 58 Final Verification - -Command/code area touched: ran Task 58 verification after formatting the malformed structured function/custom-tool output content item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 74 tests, including the new malformed structured function/custom-tool output content item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_function_output_payload_item_events_with_invalid_content_items_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 58 as `fix: ignore malformed websocket function output content`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 59 Local Shell Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, plus local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: official `ResponseItem::LocalShellCall` is a known tagged variant with optional/defaulted `id`, optional `call_id`, required `status: LocalShellStatus`, and required `action: LocalShellAction`. `LocalShellStatus` accepts only `completed`, `in_progress`, or `incomplete`. `LocalShellAction` is a tagged enum whose supported action is `exec`, with required `command: Vec<String>` and optional `timeout_ms`, `working_directory`, `env`, and `user` that still fail parsing when present with the wrong type. Local filtering currently does not validate `local_shell_call`, so malformed known items can still be forwarded even though official `serde_json::from_value::<ResponseItem>` would fall through to `Ok(None)`. - -Next decision: add a focused gateway WebSocket red test for malformed `local_shell_call` output items, then add a scoped local predicate for official `LocalShellCall` shape in both forwarding paths. - -Red test result: added `websocket_local_shell_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` and ran `cargo test --test codex_gateway websocket_local_shell_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. It failed as expected because `response.body` contained `event: response.output_item.done`. - -Next decision: skip known `local_shell_call` output-item frames whose `status` is missing or outside the official enum, whose `action` is not an object tagged as `exec`, whose `action.command` is missing/non-array/contains non-string entries, or whose present optional `id`, `call_id`, `timeout_ms`, `working_directory`, `env`, or `user` fields have non-serde-compatible types. - -### 2026-06-17 Task 59 Local Shell Call Output Item Shape Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: local WebSocket handling now skips `response.output_item.done` and `response.output_item.added` frames whose known `item.type == "local_shell_call"` item cannot parse as official `ResponseItem::LocalShellCall`. The predicate rejects missing/invalid `status`, missing/non-object `action`, unsupported `action.type`, missing/non-array/non-string `action.command` entries, and present optional `id`, `call_id`, `timeout_ms`, `working_directory`, `env`, or `user` fields with serde-incompatible types. Optional `metadata` remains unconstrained, matching the scope of the existing known-item passes. - -Focused verification passed: `cargo test --test codex_gateway websocket_local_shell_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client`. - -Next decision: run broader WebSocket gateway and serving WebSocket suites, then formatting, diff check, Clippy, and full `cargo test`. - -### 2026-06-17 Task 59 Final Verification - -Command/code area touched: ran Task 59 verification after formatting the malformed `local_shell_call` output-item filtering change. - -Observed result: all verification commands passed. Gateway WebSocket filtered coverage passed with 75 tests, including the new malformed `local_shell_call` output-item test. Serving WebSocket coverage passed with 28 tests. Full `cargo test` passed unit tests, integration suites, and doc tests. - -Commands: -- `cargo test --test codex_gateway websocket_local_shell_call_output_item_events_with_invalid_required_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 59 as `fix: ignore malformed websocket local shell calls`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 60 Web Search Call Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, plus local `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: official `ResponseItem::WebSearchCall` is a known tagged variant with optional/defaulted `id`, optional `status`, optional `action: WebSearchAction`, and optional `metadata`. Missing or null `id`, `status`, and `action` parse as `None`, but present non-string `id` / `status` fail. Present `action` must be null or an object with a string tag; known `search` actions accept optional string `query` and optional array-of-string `queries`, `open_page` accepts optional string `url`, and `find_in_page` accepts optional string `url` / `pattern`. `WebSearchAction` has `#[serde(other)] Other`, so unknown string action tags parse successfully. Local filtering currently does not validate `web_search_call`, so malformed known items can still be forwarded even though official `serde_json::from_value::<ResponseItem>` would fall through to `Ok(None)`. - -Next decision: add a focused gateway WebSocket red test for malformed `web_search_call` output items, then add a scoped local predicate for official `WebSearchCall` optional-field and action shape in both forwarding paths. - -### 2026-06-17 Task 60 Failing Test - -Command/code area touched: added `websocket_web_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_web_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `web_search_call` `response.output_item.done`, proving the current WebSocket forwarding path has no official-shaped `WebSearchCall` optional/action field validation. - -Next decision: add a `web_search_call` output-item predicate in `src/codex/gateway/transport/websocket/codec.rs`, reuse existing optional-field helpers where possible, and wire it into both forwarding loops in `mod.rs`. - -### 2026-06-17 Task 60 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs`, `src/codex/gateway/transport/websocket/mod.rs`, and the focused gateway WebSocket test. - -Observed result: added `web_search_call_output_item_event_invalid_required_fields` for `response.output_item.done|added` frames. It rejects present non-string `id` / `status`, invalid non-null/non-object `action`, missing/non-string action tags, and wrong typed known action fields for `search`, `open_page`, and `find_in_page`; unknown string action tags are still accepted to match official `#[serde(other)] Other`. The predicate is wired into both forwarding paths, and the focused test now passes. - -Verification status: focused Task 60 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 60 Final Verification - -Command/code area touched: ran Task 60 verification after formatting the malformed `web_search_call` output-item filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_web_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (76 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 60 as `fix: ignore malformed websocket web search calls`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 61 Context Compaction Output Item Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, `tests/codex_gateway/websocket.rs`, and CodeGraph query help after the Task 60 codegraph sync. - -Observed result: official `ResponseItem::ContextCompaction` is a known tagged variant with optional/defaulted `encrypted_content: Option<String>` and optional `metadata`. Missing or null `encrypted_content` parses as `None`, but a present non-string value fails official deserialization. Current local compaction filtering only checks `compaction` / `compaction_summary` required `encrypted_content`, so malformed known `context_compaction` output items can still be forwarded as successful SSE even though the official `ResponseItem` parse would fail and be ignored. - -Next decision: add a focused red test for malformed `context_compaction` output items, then extend the existing compaction output-item predicate to reject present non-string `encrypted_content` for `context_compaction`. - -### 2026-06-17 Task 61 Failing Test - -Command/code area touched: added `websocket_context_compaction_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_context_compaction_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `context_compaction` `response.output_item.done`, proving the existing compaction predicate only covers `compaction` / `compaction_summary` and not the official `ContextCompaction` optional field shape. - -Next decision: extend `compaction_output_item_event_invalid_required_fields` so `context_compaction.encrypted_content` is accepted when missing/null/string and rejected when present with any other type. - -### 2026-06-17 Task 61 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: the existing compaction output-item predicate now also handles `item.type == "context_compaction"` and rejects present non-string `encrypted_content` while preserving the official missing/null/string behavior for `Option<String>`. The focused Task 61 test now passes. - -Verification status: focused Task 61 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 61 Final Verification - -Command/code area touched: ran Task 61 verification after formatting the malformed `context_compaction` output-item filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_context_compaction_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (77 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 61 as `fix: ignore malformed websocket context compaction`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 62 Output Item Metadata Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, and current output-item filtering order in `src/codex/gateway/transport/websocket/mod.rs`. - -Observed result: official known `ResponseItem` variants share `metadata: Option<ResponseItemMetadata>`, and `ResponseItemMetadata` has optional/defaulted `turn_id: Option<String>`. Missing or null `metadata` parses as `None`; present object metadata parses when `turn_id` is missing/null/string and ignores unknown fields; present non-object metadata or non-string `metadata.turn_id` fails official deserialization. Local output-item filtering validates many variant-specific fields but does not currently validate the shared metadata shape, so an otherwise valid known output item with malformed `metadata` can still be forwarded even though official `serde_json::from_value::<ResponseItem>` would fail and be ignored. - -Next decision: add a focused red test for malformed output-item `metadata`, then add a shared output-item metadata predicate before variant-specific filters in both WebSocket forwarding paths. - -### 2026-06-17 Task 62 Failing Test - -Command/code area touched: added `websocket_output_item_events_with_invalid_metadata_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_output_item_events_with_invalid_metadata_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed output-item `metadata` on `response.output_item.done`, proving no shared official metadata-shape check exists before variant-specific forwarding. - -Next decision: add a shared `output_item_event_invalid_metadata` predicate that accepts missing/null metadata and object metadata with missing/null/string `turn_id`, rejects non-object metadata and non-string `turn_id`, then wire it into both forwarding paths. - -### 2026-06-17 Task 62 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/{codec.rs,mod.rs}` and `tests/codex_gateway/websocket.rs`. - -Observed result: added `output_item_event_invalid_metadata` for `response.output_item.done|added` frames. It rejects present non-object `metadata` and object metadata whose present `turn_id` is non-string, while preserving official missing/null metadata and missing/null/string `turn_id` behavior. The predicate is wired into both forwarding paths immediately after output-item tag validation, and the focused Task 62 test now passes. - -Verification status: focused Task 62 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 62 Final Verification - -Command/code area touched: ran Task 62 verification after formatting the malformed output-item metadata filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_output_item_events_with_invalid_metadata_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (78 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 62 as `fix: ignore malformed websocket output metadata`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 63 Message Optional Field Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, and CodeGraph results for `message_output_item_event_invalid_required_fields`. - -Observed result: official `ResponseItem::Message` has optional/defaulted `id: Option<String>` and optional/defaulted `phase: Option<MessagePhase>`. Missing or null `id` / `phase` parse as `None`; present non-string `id`, non-string `phase`, or unknown string `phase` fail official deserialization because `MessagePhase` only accepts `commentary` and `final_answer`. Current local message filtering validates required `role`, `content`, nested content items, and shared metadata, but does not validate these optional message fields, so malformed known `message` items can still be forwarded as successful SSE. - -Next decision: add a focused red test for malformed `message.id` and `message.phase`, then extend the message output-item predicate to reject present non-string `id` and present `phase` values outside official `MessagePhase`. - -### 2026-06-17 Task 63 Failing Test - -Command/code area touched: added `websocket_message_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `message` `response.output_item.done`, proving the existing message predicate does not cover official optional `id` / `phase` parse failures. - -Next decision: extend `message_output_item_event_invalid_required_fields` to reject present non-string `id`, present non-string `phase`, and present string `phase` values outside official `commentary` / `final_answer`. - -### 2026-06-17 Task 63 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: `message_output_item_event_invalid_required_fields` now rejects `message` output items with present non-string `id`, present non-string `phase`, or string `phase` outside official `MessagePhase` values `commentary` / `final_answer`, while preserving missing/null optional field behavior. The focused Task 63 test now passes. - -Verification status: focused Task 63 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 63 Final Verification - -Command/code area touched: ran Task 63 verification after formatting the malformed `message.id` / `message.phase` output-item filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_message_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (79 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 63 as `fix: ignore malformed websocket message optional fields`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 64 Function Call Optional Field Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, and current `function_call` output-item tests. - -Observed result: official `ResponseItem::FunctionCall` has optional/defaulted `id: Option<String>` and optional/defaulted `namespace: Option<String>` in addition to required `name`, `arguments`, and `call_id`. Missing or null `id` / `namespace` parse as `None`, but present non-string values fail official deserialization. Current local `function_call` filtering validates only required `name`, `arguments`, and `call_id`, so malformed optional fields can still be forwarded as successful SSE. - -Next decision: add a focused red test for malformed `function_call.id` and `function_call.namespace`, then extend the function-call output-item predicate to reject present non-string optional fields. - -### 2026-06-17 Task 64 Failing Test - -Command/code area touched: added `websocket_function_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_function_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `function_call` `response.output_item.done`, proving the existing function-call predicate does not cover official optional `id` / `namespace` parse failures. - -Next decision: extend `function_call_output_item_event_invalid_required_fields` to reject present non-string `id` and `namespace` while preserving missing/null optional field behavior. - -### 2026-06-17 Task 64 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: `function_call_output_item_event_invalid_required_fields` now rejects known `function_call` output items with present non-string `id` or `namespace`, while preserving official missing/null optional field behavior. Required `name`, `arguments`, and `call_id` validation remains unchanged. The focused Task 64 test now passes. - -Verification status: focused Task 64 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 64 Final Verification - -Command/code area touched: ran Task 64 verification after formatting the malformed `function_call.id` / `function_call.namespace` output-item filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_function_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (80 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 64 as `fix: ignore malformed websocket function call optional fields`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 65 Tool Search Call Optional Field Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, current `tool_search_call` output-item tests, and `codegraph status .`. - -Observed result: official `ResponseItem::ToolSearchCall` has optional/defaulted `id: Option<String>`, optional `call_id: Option<String>`, optional/defaulted `status: Option<String>`, required `execution: String`, and required present `arguments: serde_json::Value`. Missing or null `id` / `call_id` / `status` parse as `None`, but present non-string values fail official deserialization. Current local `tool_search_call` filtering validates only required `execution` and present `arguments`, so malformed optional fields can still be forwarded as successful SSE. - -Next decision: add a focused red test for malformed `tool_search_call.id`, `call_id`, and `status`, then extend the tool-search-call output-item predicate to reject present non-string optional fields. - -### 2026-06-17 Task 65 Failing Test - -Command/code area touched: added `websocket_tool_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_tool_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `tool_search_call` `response.output_item.done`, proving the existing tool-search-call predicate does not cover official optional `id` / `call_id` / `status` parse failures. - -Next decision: extend `tool_search_call_output_item_event_invalid_required_fields` to reject present non-string `id`, `call_id`, and `status` while preserving missing/null optional field behavior. - -### 2026-06-17 Task 65 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: `tool_search_call_output_item_event_invalid_required_fields` now rejects known `tool_search_call` output items with present non-string `id`, `call_id`, or `status`, while preserving official missing/null optional field behavior. Required `execution` validation and present `arguments` validation remain unchanged. The focused Task 65 test now passes. - -Verification status: focused Task 65 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 65 Final Verification - -Command/code area touched: ran Task 65 verification after formatting the malformed `tool_search_call.id` / `call_id` / `status` output-item filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_tool_search_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (81 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 65 as `fix: ignore malformed websocket tool search call optional fields`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 66 Custom Tool Call Optional Field Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, and current `custom_tool_call` output-item tests. - -Observed result: official `ResponseItem::CustomToolCall` has optional/defaulted `id: Option<String>`, optional/defaulted `status: Option<String>`, required `call_id: String`, required `name: String`, and required `input: String`. Missing or null `id` / `status` parse as `None`, but present non-string values fail official deserialization. Current local `custom_tool_call` filtering validates only required `call_id`, `name`, and `input`, so malformed optional fields can still be forwarded as successful SSE. - -Next decision: add a focused red test for malformed `custom_tool_call.id` and `status`, then extend the custom-tool-call output-item predicate to reject present non-string optional fields. - -### 2026-06-17 Task 66 Failing Test - -Command/code area touched: added `websocket_custom_tool_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_custom_tool_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `custom_tool_call` `response.output_item.done`, proving the existing custom-tool-call predicate does not cover official optional `id` / `status` parse failures. - -Next decision: extend `custom_tool_call_output_item_event_invalid_required_fields` to reject present non-string `id` and `status` while preserving missing/null optional field behavior. - -### 2026-06-17 Task 66 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: `custom_tool_call_output_item_event_invalid_required_fields` now rejects known `custom_tool_call` output items with present non-string `id` or `status`, while preserving official missing/null optional field behavior. Required `call_id`, `name`, and `input` validation remains unchanged. The focused Task 66 test now passes. - -Verification status: focused Task 66 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 66 Final Verification - -Command/code area touched: ran Task 66 verification after formatting the malformed `custom_tool_call.id` / `status` output-item filtering change. - -Observed result: all requested checks passed. - -Commands: -- `cargo test --test codex_gateway websocket_custom_tool_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (82 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 66 as `fix: ignore malformed websocket custom tool optional fields`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 67 Custom Tool Call Output Optional Field Shape Audit Start - -Command/code area touched: inspected official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, current `custom_tool_call_output` output-item tests, and `codegraph status .`. - -Observed result: official `ResponseItem::CustomToolCallOutput` has required `call_id: String`, optional/defaulted `name: Option<String>`, required `output: FunctionCallOutputPayload`, and optional metadata. Missing or null `name` parses as `None`, but a present non-string `name` fails official deserialization. Current local `custom_tool_call_output` filtering validates only required `call_id` and `output` payload shape, so malformed optional `name` can still be forwarded as successful SSE. - -Next decision: add a focused red test for malformed `custom_tool_call_output.name`, then extend the custom-tool-call-output predicate to reject present non-string `name`. - -### 2026-06-17 Task 67 Failing Test - -Command/code area touched: added `websocket_custom_tool_call_output_result_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` in `tests/codex_gateway/websocket.rs` and ran the focused test. - -Observed result: `cargo test --test codex_gateway websocket_custom_tool_call_output_result_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` fails as expected. Local still forwards malformed `custom_tool_call_output` `response.output_item.done`, proving the existing custom-tool-call-output predicate does not cover official optional `name` parse failures. - -Next decision: extend `custom_tool_call_output_payload_item_event_invalid_required_fields` to reject present non-string `name` while preserving missing/null optional field behavior. - -### 2026-06-17 Task 67 Implementation Result - -Command/code area touched: updated `src/codex/gateway/transport/websocket/codec.rs` and `tests/codex_gateway/websocket.rs`. - -Observed result: `custom_tool_call_output_payload_item_event_invalid_required_fields` now rejects known `custom_tool_call_output` output items with present non-string `name`, while preserving official missing/null optional field behavior. Required `call_id` validation and `output` payload validation remain unchanged. The focused Task 67 test now passes. - -Verification status: focused Task 67 red test is green; broader WebSocket, formatting, Clippy, and full-suite checks still pending. - -### 2026-06-17 Task 67 Final Verification - -Command/code area touched: ran Task 67 verification after formatting the malformed `custom_tool_call_output.name` output-item filtering change. During full-suite verification, `do_refresh_inner_should_restore_refreshing_account_after_transient_failures` repeatedly failed because the test helper could return before the paused-time retry loop reached the expected call count; the helper now advances virtual time while waiting and asserts that the expected count was reached. - -Observed result: all requested checks passed after the test helper stabilization. - -Commands: -- `cargo test --test codex_gateway websocket_custom_tool_call_output_result_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo test do_refresh_inner_should_restore_refreshing_account_after_transient_failures` -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (83 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Next decision: commit Task 67 as `fix: ignore malformed websocket custom tool output optional fields`, then mark the plan commit checkbox in a docs-only sync commit. - -Verification status: ready to commit. - -### 2026-06-17 Task 68 Full Remaining Difference Audit - -Command/code area touched: inspected official `codex-api/src/sse/responses.rs`, official `codex-api/src/endpoint/responses_websocket.rs`, official `codex-rs/protocol/src/models.rs` from git tree via `/tmp/openai-codex-src`, local `src/codex/gateway/transport/websocket/codec.rs`, local `tests/codex_gateway/websocket.rs`, and current plan/task history. - -Observed result: official `ResponsesStreamEvent` and local `ResponsesStreamEventShape` currently have the same top-level parse-sensitive fields: `type`, `headers`, `metadata`, `response`, `item`, `item_id`, `call_id`, `delta`, `summary_index`, and `content_index`. The remaining source-backed local parse gaps are therefore inside known `ResponseItem` variants, not in the top-level event shape. - -Remaining source-backed `must change` items: - -| Area | Official shape | Current local behavior | Action | -| --- | --- | --- | --- | -| `ResponseItem::ToolSearchOutput.call_id` | `Option<String>`; missing/null accepted, present non-string fails `ResponseItem` deserialization and official WebSocket skips the output-item event | Local `tool_search_output_item_event_invalid_required_fields` validates only required `status`, `execution`, and `tools`, so present non-string `call_id` can still be forwarded | Add focused red test and reject present non-string `call_id` | -| `ResponseItem::ImageGenerationCall.revised_prompt` | `Option<String>`; missing/null accepted, present non-string fails `ResponseItem` deserialization and official WebSocket skips the output-item event | Local `image_generation_call_output_item_event_invalid_required_fields` validates only required `id`, `status`, and `result`, so present non-string `revised_prompt` can still be forwarded | Add focused red test and reject present non-string `revised_prompt` | - -Already source-backed and covered locally: - -| Area | Coverage state | -| --- | --- | -| Top-level malformed `ResponsesStreamEvent` fields | `responses_stream_event_shape_parse_error` covers type mismatches for the official top-level fields before raw SSE forwarding | -| Missing/null top-level optional fields | Tasks 34-40 cover missing/null `response`, `delta`, `item`, and summary-index option behavior | -| Required delta fields | Task 37 covers `custom_tool_call_input.delta`, `reasoning_summary_text.delta`, and `reasoning_text.delta` required-field combinations | -| `response.reasoning_summary_part.added` | Task 39 covers required `summary_index` | -| `response.completed` parse shape | Tasks 32-34 cover missing/invalid `response`, required `id`, and required `usage.total_tokens` when `usage` is present | -| `response.failed`, `response.incomplete`, wrapped error frames | Tasks 17, 19-24, 29-31 cover official error classifications, retry-after handling, connection-limit precedence, unknown failures, and overload cases | -| Output-item `item` envelope | Tasks 38, 42, and 43 cover missing/null, non-object, and missing/non-string type tags | -| Shared output-item `metadata.turn_id` | Task 62 covers malformed metadata shape | -| `message` | Tasks 44, 55, and 63 cover required fields, nested content items, optional `id`, and optional `phase` enum shape | -| `agent_message` | Tasks 53 and 56 cover required fields and nested content items | -| `reasoning` | Tasks 54 and 57 cover defaulted `id`, required `summary`, optional `content`, optional `encrypted_content`, and nested items | -| `local_shell_call` | Task 59 covers optional `id`/`call_id`, enum `status`, `action.type == exec`, required `exec.command`, and typed optional `timeout_ms`, `working_directory`, `env`, and `user` | -| `function_call` | Tasks 45 and 64 cover required `name`, `arguments`, `call_id`, optional `id`, and optional `namespace` | -| `tool_search_call` | Tasks 46 and 65 cover required `execution`, present `arguments`, optional `id`, optional `call_id`, and optional `status` | -| `function_call_output` | Tasks 47 and 58 cover required `call_id`, string/array output payload, and structured output content items | -| `custom_tool_call` | Tasks 48 and 66 cover required `call_id`, `name`, `input`, optional `id`, and optional `status` | -| `custom_tool_call_output` | Tasks 49, 58, and 67 cover required `call_id`, optional `name`, string/array output payload, and structured output content items | -| `tool_search_output` required fields | Task 50 covers required `status`, `execution`, and `tools`; only optional `call_id` remains | -| `web_search_call` | Task 60 covers optional `id`, optional `status`, optional `action`, and known action optional fields | -| `image_generation_call` required fields | Task 51 covers required `id`, `status`, and `result`; only optional `revised_prompt` remains | -| `compaction` / `compaction_summary` / `context_compaction` | Tasks 52 and 61 cover required `encrypted_content` for compaction variants and optional `encrypted_content` for context compaction | -| `compaction_trigger` | Official known variant has no fields except optional metadata; shared metadata filtering is sufficient and no extra local predicate is needed | - -Not a proxy API surface / do not synthesize public SSE changes: - -| Area | Classification | -| --- | --- | -| Official `ResponseEvent::ServerModel`, `ModelsEtag`, `ServerReasoningIncluded`, `ModelVerifications`, and `TurnModerationMetadata` side channels | Desktop-internal `ResponseEvent` values. Local proxy has no matching Desktop UI/session consumer, and adding synthetic public SSE fields would change the OpenAI-compatible proxy surface. Prior Tasks 18 and 25 documented this as no-code. | -| Raw `response.metadata` forwarding | Official extracts side channels and returns `Ok(None)` for metadata frames. Local now captures `x-codex-turn-state` and skips raw metadata forwarding; remaining Desktop-only metadata side channels stay documented rather than exposed. | - -Still unproven without a macOS Desktop live capture: - -| Area | Current evidence | -| --- | --- | -| TLS ClientHello / ALPN / cipher / extension ordering | Official binary is macOS arm64 Mach-O and cannot run on this Linux host. Source/static evidence confirms rustls/tokio-tungstenite family and custom CA behavior, but live fingerprint parity is not proven. | -| Exact official opening header order/casing and dynamic `Sec-WebSocket-Key` placement | Local capture and audit artifacts exist; official live opening bytes are unavailable on this host. | -| Proxy/environment behavior of the shipped Desktop binary | Static strings show proxy env support, but live validation remains blocked by the Mach-O binary format. | - -Next decision: implement the two remaining source-backed `must change` items together or in immediate sequence: `tool_search_output.call_id` and `image_generation_call.revised_prompt`. Keep live TLS/opening parity explicitly unclaimed until a macOS capture artifact exists. - -Verification status: documentation-only full audit; no code changes in Task 68. - -### 2026-06-17 Task 69 Remaining Optional Output Item Fields - -Command/code area touched: inspected official `/tmp/openai-codex-src` `codex-rs/protocol/src/models.rs`, then prepared local WebSocket output-item filter tests for `tool_search_output.call_id` and `image_generation_call.revised_prompt`. - -Observed result: official `ResponseItem::ToolSearchOutput` has `call_id: Option<String>`, `status: String`, `execution: String`, and `tools: Vec<serde_json::Value>`. Official `ResponseItem::ImageGenerationCall` has `id: String`, `status: String`, `revised_prompt: Option<String>`, and `result: String`. Because these variants deserialize through Serde before official Desktop forwards output-item events, missing/null optional fields are accepted but present non-string values fail the variant parse and should be skipped. - -Expected local changes: add focused red tests for present non-string `tool_search_output.call_id` and `image_generation_call.revised_prompt`, then add the corresponding `optional_string_field_invalid` checks in the existing output-item predicates. - -Red test result: -- `cargo test --test codex_gateway websocket_tool_search_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` failed at `tests/codex_gateway/websocket.rs` because the response still contained `event: response.output_item.done`. -- `cargo test --test codex_gateway websocket_image_generation_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` failed at `tests/codex_gateway/websocket.rs` because the response still contained `event: response.output_item.done`. - -Verification status: red tests confirmed; implementation pending. - -Implementation result: local `tool_search_output_item_event_invalid_required_fields` now rejects present non-string `call_id`, and local `image_generation_call_output_item_event_invalid_required_fields` now rejects present non-string `revised_prompt`. Missing/null remains accepted because both use the existing `optional_string_field_invalid` helper. - -Focused green checks: -- `cargo test --test codex_gateway websocket_tool_search_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` -- `cargo test --test codex_gateway websocket_image_generation_call_output_item_events_with_invalid_optional_fields_should_be_ignored_like_official_client` - -Verification status: focused tests passed; broader verification pending. - -Broader verification commands: -- `cargo fmt --all` -- `cargo test --test codex_gateway websocket_` (85 passed) -- `cargo test --test codex_serving responses_websocket` (28 passed) -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Remaining source-backed WebSocket parse/filter delta: none found in the Task 68 matrix after this implementation. Remaining unclaimed areas are live TLS ClientHello/opening-byte parity and shipped Desktop proxy/env behavior, which still require a macOS Desktop capture artifact. - -Verification status: ready to commit. - -### 2026-06-17 Task 70 Linux Desktop Repack Live Chain Capture - -Command/code area touched: ran the Linux Desktop repack from `/home/zyy/桌面/Codes/codex-desktop-linux` (same real tree as `/home/zyy/Codes/codex-desktop-linux`) with `CODEX_CLI_PATH` pointed to a local wrapper in `.codex-ws-audit/task70-desktop/desktop-appserver-wrapper.sh`. The wrapper preserved the Desktop-launched `app-server --analytics-default-enabled` invocation, spawned the real Codex app-server, overrode only the model provider base URL to a local capture endpoint, and injected `thread/start` plus `turn/start` over stdio. Artifacts are ignored under `.codex-ws-audit/task70-desktop/`. - -Attribution result: `.codex-ws-audit/task70-desktop/desktop-wrapper.ndjson` records the wrapper parent as `/home/zyy/Codes/codex-desktop-linux/codex-app/electron ... --user-data-dir=/home/zyy/.local/state/codex-desktop/instances/port-5186/electron-user-data ...`, original argv `["app-server","--analytics-default-enabled"]`, real child `/home/zyy/.volta/bin/codex`, and successful injected `thread/start` / `turn/start` responses. This proves the sampled WebSocket request chain is `codex-desktop-linux` Electron -> `CODEX_CLI_PATH` wrapper -> official Codex app-server -> Responses WebSocket client. - -Captured WebSocket opening artifacts: -- `.codex-ws-audit/task70-desktop/desktop-ws-capture-001.json` -- `.codex-ws-audit/task70-desktop/desktop-ws-capture-002.json` - -Official Desktop-launched app-server opening request: -- Request line: `GET /backend-api/codex/responses HTTP/1.1` -- Header order/casing: `Host`, `Connection`, `Upgrade`, `Sec-WebSocket-Version`, `Sec-WebSocket-Key`, `chatgpt-account-id`, `authorization`, `user-agent`, `originator`, `openai-beta`, `x-codex-beta-features`, `x-client-request-id`, `session-id`, `thread-id`, `x-codex-window-id`, `x-codex-turn-metadata`, `sec-websocket-extensions` -- `Sec-WebSocket-Key` header index: `4` -- `sec-websocket-extensions`: `permessage-deflate; client_max_window_bits` -- The local capture endpoint responded with `HTTP/1.1 101 Switching Protocols`, `Upgrade: websocket`, `Connection: Upgrade`, and `Sec-WebSocket-Accept`. No upstream OpenAI response body was decrypted; mihomo does not expose decrypted HTTP/WebSocket payloads. - -Official Desktop-launched first WebSocket text frames: -- Capture 001 keys: `type`, `model`, `instructions`, `input`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`, `store`, `stream`, `include`, `prompt_cache_key`, `generate`, `client_metadata`; `generate` is `false`. -- Capture 002 keys: `type`, `model`, `instructions`, `input`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`, `store`, `stream`, `include`, `prompt_cache_key`, `client_metadata`. -- Redaction preserved top-level key order while replacing prompt/tool/client metadata payloads and account/auth/session identifiers. - -mihomo attribution result: controller `127.0.0.1:9090` with bearer key `123456` is reachable; `/configs` reported `mode=rule`, `mixed-port=7897`, and TUN enabled. A short direct Desktop launch wrote `.codex-ws-audit/task70-desktop/mihomo-desktop-connections.ndjson` and showed live `electron` connections from `/home/zyy/Codes/codex-desktop-linux/codex-app/electron` to `ab.chatgpt.com` and `chat.openai.com`, routed by `DomainSuffix` through the configured OpenAI rule chain. The same sample also showed official Codex child-process traffic to `ab.chatgpt.com`. This is process/route attribution only; it is not an HTTP header/body capture path. - -TLS ClientHello result: added `.codex-ws-audit/task70-desktop/capture-tls-clienthello.mjs` and launched the Desktop repack with `TASK70_BASE_URL=https://127.0.0.1:18772/backend-api/codex`. The Desktop-launched app-server produced `.codex-ws-audit/task70-desktop/tls-clienthello-001.json`, `tls-clienthello-002.json`, and `tls-clienthello-003.json`. Wrapper logs show the first two failed at `wss://127.0.0.1:18772/backend-api/codex/responses` with `tls handshake eof`; after that the client logged `falling back to HTTP`, so `tls-clienthello-003.json` is classified as the HTTP fallback TLS path, not the WSS opening path. - -WSS ClientHello stable fields from `tls-clienthello-001.json` / `tls-clienthello-002.json`: -- TLS record version `0x0301`; ClientHello legacy version `0x0303` -- Cipher suites: `0x1302`, `0x1301`, `0x1303`, `0xc02c`, `0xc02b`, `0xcca9`, `0xc030`, `0xc02f`, `0xcca8`, `0x00ff` -- Supported groups: `x25519`, `secp256r1`, `secp384r1` -- Signature algorithms: `0x0503`, `0x0403`, `0x0807`, `0x0806`, `0x0805`, `0x0804`, `0x0601`, `0x0501`, `0x0401` -- Supported versions: `TLS1.3`, `TLS1.2` -- Key share groups: `x25519` -- ALPN: empty in these WSS samples -- Extension set: `signature_algorithms`, `psk_key_exchange_modes`, `status_request`, `extended_master_secret`, `key_share`, `ec_point_formats`, `supported_groups`, `session_ticket`, `supported_versions` -- Extension ordering varied between WSS attempts, producing different JA3 MD5 values (`7ac5439bc736f74eb03aab08506671b6`, `bb8e1b0e5cc184cc85fff1d940590c19`) with the same cipher/group/version core. - -Comparison against current rs audit artifact `.codex-ws-audit/rs-parity-check-capture.json`: -- Current rs request line is `GET /codex/responses HTTP/1.1`; official Desktop app-server uses `GET /backend-api/codex/responses HTTP/1.1` when the provider base URL is `.../backend-api/codex`. -- Current rs header order/casing is `Host`, `Connection`, `Upgrade`, `Sec-WebSocket-Version`, `Sec-WebSocket-Key`, `Authorization`, `ChatGPT-Account-Id`, `originator`, `User-Agent`, browser-like `sec-ch-*` / `sec-fetch-*`, `Accept-Encoding`, `Accept-Language`, `OpenAI-Beta`, `x-openai-internal-codex-residency`, `x-client-request-id`, `Sec-WebSocket-Extensions`. -- Official Desktop app-server omits the browser-like `sec-ch-*`, `sec-fetch-*`, `Accept-Encoding`, `Accept-Language`, and `x-openai-internal-codex-residency` headers in this chain. -- Official Desktop app-server uses lowercase business/extension header names for `chatgpt-account-id`, `authorization`, `user-agent`, `openai-beta`, and `sec-websocket-extensions`, and orders account before authorization. -- Official Desktop app-server adds `x-codex-beta-features`, `session-id`, `thread-id`, `x-codex-window-id`, and `x-codex-turn-metadata`. -- Current rs first-frame audit sample only contained `include`, `input`, `instructions`, `model`, `parallel_tool_calls`, `reasoning`, `store`, `stream`, `tool_choice`, `tools`, `type`; official Desktop app-server includes `prompt_cache_key` and `client_metadata`, with prewarm also including `generate: false`. - -Next decision: treat Task 70 as evidence capture complete. Any code change should be a separate task that decides whether the proxy should mimic the Desktop app-server opening exactly or preserve the existing browser-style/header-synthesis behavior for its public proxy role. The highest-confidence deltas for a Desktop-app-server parity mode are path/base composition, header casing/order, removal of browser-like headers from this WS chain, and addition of the Codex session/window/turn metadata headers where the local data model has sources. - -Verification status: Desktop-originated opening, first-frame payload shape, mihomo process attribution, and WSS ClientHello artifacts are captured and redacted. Full upstream OpenAI response bodies were not captured because TLS decryption was not configured and mihomo exposes connection metadata rather than decrypted WebSocket payloads. - -### 2026-06-17 Task 71 live5 Complete Request Chain Alignment - -Command/code area touched: inspected `/home/zyy/桌面/Codes/codex-proxy-rs/.codex-ws-audit/live5/flows.all.txt` and focused on model WebSocket flows `#41`, `#44`, `#48`, `#59`, and `#60`. Flow `#44` had no response captured; flows `#41`, `#48`, `#59`, and `#60` carried WebSocket text frames. The live5 capture confirms the Task 70 Desktop app-server opening shape with a real full request chain rather than only local wrapper artifacts. - -Observed live5 request shape: -- Request line: `GET /backend-api/codex/responses`. -- Header order/casing: `Host`, `Connection`, `Upgrade`, `Sec-WebSocket-Version`, `Sec-WebSocket-Key`, `chatgpt-account-id`, `authorization`, `user-agent`, `originator`, `openai-beta`, optional `x-codex-beta-features`, `x-client-request-id`, `session-id`, `thread-id`, `x-codex-window-id`, `x-codex-turn-metadata`, `sec-websocket-extensions`. -- Browser-style headers (`sec-ch-*`, `sec-fetch-*`, `Accept-Encoding`, `Accept-Language`) and `x-openai-internal-codex-residency` were not present on the Desktop app-server WebSocket opening in the sampled flows. -- `sec-websocket-extensions` remained last with `permessage-deflate; client_max_window_bits`. -- First-frame payloads preserve official `response.create` struct ordering; prewarm-shaped requests can include `generate:false` before `client_metadata`. - -Red tests added and confirmed: -- `cargo test --test codex_gateway websocket_opening_audit_snapshot_should_redact_sensitive_headers` failed because local still emitted `Authorization`, `ChatGPT-Account-Id`, `User-Agent`, `OpenAI-Beta`, `session_id`, and older order. -- `cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content` failed because the audit key list omitted `generate`. -- `cargo test --test codex_serving v1_responses_should_use_websocket_upstream_by_default_while_serving_sse` failed because request body `generate:false` was parsed away and upstream saw `Null`. -- `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes` failed because the actual rs opening still used the old browser-style/header-synthesis shape. - -Implementation result: WebSocket opening serialization now follows the live Desktop app-server chain for the fields available in local context. It writes account before authorization, lowercases the business headers observed in live5, omits browser-style headers and `x-openai-internal-codex-residency` from this WebSocket opening path, maps local `session_id` to `session-id` and `thread-id`, keeps `x-codex-beta-features`, `x-codex-window-id`, and `x-codex-turn-metadata` when present, and writes lowercase `sec-websocket-extensions` last. The request body path now parses and forwards `generate` and includes it in payload audit key ordering. - -Focused green checks: -- `cargo test --test codex_gateway websocket_opening_audit_snapshot_should_redact_sensitive_headers` -- `cargo test --test codex_gateway websocket_payload_audit_snapshot_should_redact_user_content` -- `cargo test --test codex_gateway websocket_capture_harness_should_record_opening_bytes` -- `cargo test --test codex_serving v1_responses_should_use_websocket_upstream_by_default_while_serving_sse` -- `cargo test --test codex_gateway websocket_handshake_should_offer_original_permessage_deflate_extension` - -Next decision: Task 71 local request-shape alignment is complete. Remaining unclaimed item from live5 is full upstream response-body decryption; the available capture is enough for opening and first-frame request parity but not for decrypted production response payload comparison. - -Verification status: broader verification completed: -- `cargo test --test codex_gateway websocket_` -- `cargo test --test codex_serving responses_websocket` -- `cargo fmt --all` -- `cargo fmt --all --check` -- `git diff --check` -- `cargo clippy --all-targets --all-features --locked -- -D warnings` -- `cargo test` - -Full-suite verification initially reproduced the known `token_refresh` virtual-time helper race, where the spawned retry path could finish the first probe while the helper exhausted virtual-time advances before SQLite/blocking-pool work registered the second retry. The test-only helper now advances paused time in smaller steps and yields a small amount of real scheduler time between polls; the final `cargo test` run passed afterward. diff --git a/tests/architecture.rs b/tests/architecture.rs deleted file mode 100644 index d1197d036..000000000 --- a/tests/architecture.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod architecture { - mod accounts_boundary; - mod dependency_direction; - mod directory_shape; - mod forbidden_imports; - mod forbidden_legacy_paths; - mod gateway_boundary; - mod placeholder_implementations; - mod platform_boundary; - mod runtime_tasks_boundary; - mod server_startup; - mod test_migration; - mod workspace_scaffold; -} diff --git a/tests/architecture/accounts_boundary.rs b/tests/architecture/accounts_boundary.rs deleted file mode 100644 index be8fe372c..000000000 --- a/tests/architecture/accounts_boundary.rs +++ /dev/null @@ -1,15 +0,0 @@ -use codex_proxy_core::{ - accounts::jwt::{jwt_expiry, JwtExpiry}, - auth::oauth::{RefreshPolicy, RefreshTrigger}, - gateway::conversation::{build_conversation_identity, ConversationIdentity}, -}; - -#[test] -fn core_exports_account_domain_helpers() { - let _expiry_type = std::any::type_name::<JwtExpiry>(); - let _identity_type = std::any::type_name::<ConversationIdentity>(); - let _refresh_policy_type = std::any::type_name::<RefreshPolicy>(); - let _refresh_trigger_type = std::any::type_name::<RefreshTrigger>(); - let _jwt_expiry_fn = jwt_expiry; - let _identity_fn = build_conversation_identity; -} diff --git a/tests/architecture/admin_boundary.rs b/tests/architecture/admin_boundary.rs deleted file mode 100644 index 8c5e00928..000000000 --- a/tests/architecture/admin_boundary.rs +++ /dev/null @@ -1,12 +0,0 @@ -use codex_proxy_core::admin::{ - auth::AdminAuthService, client_keys::ClientKeyService, settings::SettingsService, -}; -use codex_proxy_server::admin_api::router::router; - -#[test] -fn admin_exports_control_plane_modules() { - let _admin_auth_type = std::any::type_name::<AdminAuthService>(); - let _api_key_type = std::any::type_name::<ClientKeyService>(); - let _settings_type = std::any::type_name::<SettingsService>(); - let _router_fn = router; -} diff --git a/tests/architecture/codex_activity_boundary.rs b/tests/architecture/codex_activity_boundary.rs deleted file mode 100644 index 487f7da8e..000000000 --- a/tests/architecture/codex_activity_boundary.rs +++ /dev/null @@ -1,13 +0,0 @@ -use codex_proxy_core::{ - events::{model::EventLog, service::EventLogService}, - usage::service::UsageService, -}; -use codex_proxy_runtime::tasks::model_refresh::ModelRefreshTask; - -#[test] -fn codex_exports_activity_modules() { - let _event_type = std::any::type_name::<EventLog>(); - let _log_service_type = std::any::type_name::<EventLogService>(); - let _usage_service_type = std::any::type_name::<UsageService>(); - let _model_refresher_type = std::any::type_name::<ModelRefreshTask>(); -} diff --git a/tests/architecture/dependency_direction.rs b/tests/architecture/dependency_direction.rs deleted file mode 100644 index eef00f637..000000000 --- a/tests/architecture/dependency_direction.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::{collections::BTreeMap, fs, path::PathBuf}; - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -#[test] -fn workspace_dependencies_should_follow_architecture_direction() { - let manifests = workspace_dependency_map(); - - assert_no_workspace_deps(&manifests, "core"); - assert_no_workspace_deps(&manifests, "platform"); - assert_no_workspace_deps(&manifests, "assets"); - assert_forbidden_deps(&manifests, "adapters", &["runtime", "server"]); - assert_forbidden_deps(&manifests, "runtime", &["server"]); - assert_forbidden_deps(&manifests, "xtask", &["assets"]); -} - -fn workspace_dependency_map() -> BTreeMap<String, Vec<String>> { - let root = repository_root(); - [ - "core", "adapters", "runtime", "server", "platform", "assets", "xtask", - ] - .into_iter() - .map(|crate_name| { - let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml"); - let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|_| { - panic!("expected readable manifest at {}", manifest_path.display()) - }); - (crate_name.to_string(), workspace_deps(&manifest)) - }) - .collect() -} - -fn workspace_deps(manifest: &str) -> Vec<String> { - let mut deps = Vec::new(); - let mut in_dependencies = false; - - for line in manifest.lines() { - let trimmed = line.trim(); - if trimmed.starts_with('[') { - in_dependencies = trimmed == "[dependencies]"; - continue; - } - if !in_dependencies { - continue; - } - for crate_name in [ - "codex-proxy-core", - "codex-proxy-adapters", - "codex-proxy-runtime", - "codex-proxy-server", - "codex-proxy-platform", - "codex-proxy-assets", - ] { - if trimmed.starts_with(crate_name) { - deps.push(crate_name.strip_prefix("codex-proxy-").unwrap().to_string()); - } - } - } - - deps -} - -fn assert_no_workspace_deps(manifests: &BTreeMap<String, Vec<String>>, crate_name: &str) { - let deps = manifests - .get(crate_name) - .unwrap_or_else(|| panic!("expected manifest entry for {crate_name}")); - assert!( - deps.is_empty(), - "{crate_name} must not depend on workspace crates, found {deps:?}" - ); -} - -fn assert_forbidden_deps( - manifests: &BTreeMap<String, Vec<String>>, - crate_name: &str, - forbidden: &[&str], -) { - let deps = manifests - .get(crate_name) - .unwrap_or_else(|| panic!("expected manifest entry for {crate_name}")); - let violations = deps - .iter() - .filter(|dep| forbidden.contains(&dep.as_str())) - .cloned() - .collect::<Vec<_>>(); - - assert!( - violations.is_empty(), - "{crate_name} has forbidden workspace dependencies: {violations:?}" - ); -} diff --git a/tests/architecture/directory_shape.rs b/tests/architecture/directory_shape.rs deleted file mode 100644 index 4110e6c4b..000000000 --- a/tests/architecture/directory_shape.rs +++ /dev/null @@ -1,368 +0,0 @@ -use std::{ - collections::BTreeSet, - fs, - path::{Path, PathBuf}, -}; - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -#[test] -fn rust_source_tree_should_match_architecture_whitelist() { - let root = repository_root(); - let expected = expected_rust_source_files(); - let actual = actual_rust_source_files(&root); - - assert_eq!( - actual, - expected, - "Rust source tree must match docs/architecture.md exactly.\nmissing:\n{}\nextra:\n{}", - format_missing(&expected, &actual), - format_extra(&expected, &actual) - ); -} - -#[test] -fn rust_source_directories_should_match_architecture_whitelist() { - let root = repository_root(); - let expected = expected_rust_source_directories(); - let actual = actual_rust_source_directories(&root); - - assert_eq!( - actual, - expected, - "Rust source directories must match docs/architecture.md exactly.\nmissing:\n{}\nextra:\n{}", - format_missing(&expected, &actual), - format_extra(&expected, &actual) - ); -} - -#[test] -fn workspace_members_should_match_architecture_crates() { - let cargo_toml = fs::read_to_string(repository_root().join("Cargo.toml")) - .expect("expected root Cargo.toml to be readable"); - - assert!( - cargo_toml.contains("members = [\"crates/*\"]"), - "root workspace should keep members = [\"crates/*\"]" - ); -} - -fn actual_rust_source_files(root: &Path) -> BTreeSet<String> { - let mut files = BTreeSet::new(); - collect_rust_source_files(&root.join("src"), root, &mut files); - let crates_dir = root.join("crates"); - let entries = fs::read_dir(&crates_dir).expect("expected crates directory to be readable"); - for entry in entries.flatten() { - let path = entry.path().join("src"); - collect_rust_source_files(&path, root, &mut files); - } - files -} - -fn actual_rust_source_directories(root: &Path) -> BTreeSet<String> { - let mut directories = BTreeSet::new(); - collect_rust_source_directories(&root.join("src"), root, &mut directories); - let crates_dir = root.join("crates"); - let entries = fs::read_dir(&crates_dir).expect("expected crates directory to be readable"); - for entry in entries.flatten() { - let path = entry.path().join("src"); - collect_rust_source_directories(&path, root, &mut directories); - } - directories -} - -fn collect_rust_source_files(dir: &Path, root: &Path, files: &mut BTreeSet<String>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_rust_source_files(&path, root, files); - continue; - } - - let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else { - continue; - }; - - if extension == "rs" || extension == "sql" { - let relative = path - .strip_prefix(root) - .expect("source file should be below repository root") - .to_string_lossy() - .replace('\\', "/"); - files.insert(relative); - } - } -} - -fn collect_rust_source_directories(dir: &Path, root: &Path, directories: &mut BTreeSet<String>) { - if !dir.is_dir() { - return; - } - - let relative = dir - .strip_prefix(root) - .expect("source directory should be below repository root") - .to_string_lossy() - .replace('\\', "/"); - directories.insert(relative); - - let entries = fs::read_dir(dir).expect("expected source directory to be readable"); - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_rust_source_directories(&path, root, directories); - } - } -} - -fn format_missing(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { - expected - .difference(actual) - .cloned() - .collect::<Vec<_>>() - .join("\n") -} - -fn format_extra(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { - actual - .difference(expected) - .cloned() - .collect::<Vec<_>>() - .join("\n") -} - -fn expected_rust_source_files() -> BTreeSet<String> { - [ - "crates/core/src/lib.rs", - "crates/core/src/error.rs", - "crates/core/src/admin/mod.rs", - "crates/core/src/admin/ports.rs", - "crates/core/src/admin/auth.rs", - "crates/core/src/admin/client_keys.rs", - "crates/core/src/admin/settings.rs", - "crates/core/src/accounts/mod.rs", - "crates/core/src/accounts/model.rs", - "crates/core/src/accounts/ports.rs", - "crates/core/src/accounts/service.rs", - "crates/core/src/accounts/pool.rs", - "crates/core/src/accounts/lifecycle.rs", - "crates/core/src/accounts/cloudflare.rs", - "crates/core/src/accounts/cookies.rs", - "crates/core/src/accounts/jwt.rs", - "crates/core/src/accounts/usage.rs", - "crates/core/src/auth/mod.rs", - "crates/core/src/auth/ports.rs", - "crates/core/src/auth/oauth.rs", - "crates/core/src/auth/session.rs", - "crates/core/src/models/mod.rs", - "crates/core/src/models/model.rs", - "crates/core/src/models/ports.rs", - "crates/core/src/models/catalog.rs", - "crates/core/src/models/service.rs", - "crates/core/src/events/mod.rs", - "crates/core/src/events/model.rs", - "crates/core/src/events/ports.rs", - "crates/core/src/events/service.rs", - "crates/core/src/usage/mod.rs", - "crates/core/src/usage/model.rs", - "crates/core/src/usage/ports.rs", - "crates/core/src/usage/service.rs", - "crates/core/src/protocol/mod.rs", - "crates/core/src/protocol/openai/mod.rs", - "crates/core/src/protocol/openai/chat.rs", - "crates/core/src/protocol/openai/responses.rs", - "crates/core/src/protocol/openai/models.rs", - "crates/core/src/protocol/openai/errors.rs", - "crates/core/src/protocol/codex/mod.rs", - "crates/core/src/protocol/codex/chat.rs", - "crates/core/src/protocol/codex/responses.rs", - "crates/core/src/protocol/codex/events.rs", - "crates/core/src/protocol/codex/sse.rs", - "crates/core/src/protocol/codex/websocket.rs", - "crates/core/src/protocol/codex/schema.rs", - "crates/core/src/gateway/mod.rs", - "crates/core/src/gateway/ports.rs", - "crates/core/src/gateway/fingerprint.rs", - "crates/core/src/gateway/conversation.rs", - "crates/core/src/gateway/installation.rs", - "crates/core/src/serving/mod.rs", - "crates/core/src/serving/chat.rs", - "crates/core/src/serving/responses.rs", - "crates/core/src/serving/errors.rs", - "crates/core/src/serving/routing.rs", - "crates/core/src/serving/fallback.rs", - "crates/core/src/serving/affinity.rs", - "crates/core/src/serving/quota.rs", - "crates/core/src/serving/implicit_resume.rs", - "crates/core/src/serving/reasoning_replay.rs", - "crates/core/src/serving/stream.rs", - "crates/core/src/serving/recovery.rs", - "crates/core/src/serving/usage.rs", - "crates/adapters/src/lib.rs", - "crates/adapters/src/sqlite/mod.rs", - "crates/adapters/src/sqlite/accounts.rs", - "crates/adapters/src/sqlite/account_tokens.rs", - "crates/adapters/src/sqlite/account_usage.rs", - "crates/adapters/src/sqlite/refresh_leases.rs", - "crates/adapters/src/sqlite/cookies.rs", - "crates/adapters/src/sqlite/events.rs", - "crates/adapters/src/sqlite/models.rs", - "crates/adapters/src/sqlite/session_affinity.rs", - "crates/adapters/src/sqlite/admin_sessions.rs", - "crates/adapters/src/sqlite/client_keys.rs", - "crates/adapters/src/codex/mod.rs", - "crates/adapters/src/codex/client.rs", - "crates/adapters/src/codex/models.rs", - "crates/adapters/src/codex/fingerprint.rs", - "crates/adapters/src/codex/websocket/mod.rs", - "crates/adapters/src/codex/websocket/connect.rs", - "crates/adapters/src/codex/websocket/pool.rs", - "crates/adapters/src/codex/websocket/deflate.rs", - "crates/adapters/src/codex/websocket/opening.rs", - "crates/adapters/src/oauth/mod.rs", - "crates/adapters/src/oauth/openai.rs", - "crates/runtime/src/lib.rs", - "crates/runtime/src/bootstrap.rs", - "crates/runtime/src/state.rs", - "crates/runtime/src/services.rs", - "crates/runtime/src/repositories.rs", - "crates/runtime/src/upstream.rs", - "crates/runtime/src/config.rs", - "crates/runtime/src/tasks/mod.rs", - "crates/runtime/src/tasks/coordinator.rs", - "crates/runtime/src/tasks/token_refresh.rs", - "crates/runtime/src/tasks/quota_refresh.rs", - "crates/runtime/src/tasks/model_refresh.rs", - "crates/runtime/src/tasks/cookie_cleanup.rs", - "crates/runtime/src/tasks/session_cleanup.rs", - "crates/runtime/src/tasks/session_affinity_cleanup.rs", - "crates/runtime/src/tasks/fingerprint_update.rs", - "crates/server/src/main.rs", - "crates/server/src/lib.rs", - "crates/server/src/router.rs", - "crates/server/src/error/mod.rs", - "crates/server/src/error/admin.rs", - "crates/server/src/error/openai.rs", - "crates/server/src/middleware/mod.rs", - "crates/server/src/middleware/request_id.rs", - "crates/server/src/middleware/trace.rs", - "crates/server/src/middleware/auth.rs", - "crates/server/src/middleware/cors.rs", - "crates/server/src/admin_api/mod.rs", - "crates/server/src/admin_api/router.rs", - "crates/server/src/admin_api/response.rs", - "crates/server/src/admin_api/session.rs", - "crates/server/src/admin_api/settings.rs", - "crates/server/src/admin_api/diagnostics.rs", - "crates/server/src/admin_api/models.rs", - "crates/server/src/admin_api/usage.rs", - "crates/server/src/admin_api/accounts/mod.rs", - "crates/server/src/admin_api/accounts/list.rs", - "crates/server/src/admin_api/accounts/create.rs", - "crates/server/src/admin_api/accounts/import.rs", - "crates/server/src/admin_api/accounts/import_cli.rs", - "crates/server/src/admin_api/accounts/export.rs", - "crates/server/src/admin_api/accounts/lifecycle.rs", - "crates/server/src/admin_api/accounts/quota.rs", - "crates/server/src/admin_api/accounts/cookies.rs", - "crates/server/src/admin_api/accounts/oauth.rs", - "crates/server/src/admin_api/accounts/health.rs", - "crates/server/src/admin_api/client_keys/mod.rs", - "crates/server/src/admin_api/client_keys/list.rs", - "crates/server/src/admin_api/client_keys/create.rs", - "crates/server/src/admin_api/client_keys/import.rs", - "crates/server/src/admin_api/client_keys/export.rs", - "crates/server/src/admin_api/client_keys/lifecycle.rs", - "crates/server/src/admin_api/logs/mod.rs", - "crates/server/src/admin_api/logs/query.rs", - "crates/server/src/admin_api/logs/detail.rs", - "crates/server/src/admin_api/logs/state.rs", - "crates/server/src/openai_api/mod.rs", - "crates/server/src/openai_api/router.rs", - "crates/server/src/openai_api/auth.rs", - "crates/server/src/openai_api/chat.rs", - "crates/server/src/openai_api/responses.rs", - "crates/server/src/openai_api/models.rs", - "crates/server/src/openai_api/diagnostics.rs", - "crates/server/src/openai_api/error.rs", - "crates/server/src/openai_api/sse.rs", - "crates/platform/src/lib.rs", - "crates/platform/src/config/mod.rs", - "crates/platform/src/config/loader.rs", - "crates/platform/src/config/types.rs", - "crates/platform/src/crypto/mod.rs", - "crates/platform/src/crypto/secret_box.rs", - "crates/platform/src/crypto/hash.rs", - "crates/platform/src/identity/mod.rs", - "crates/platform/src/identity/admin_password.rs", - "crates/platform/src/identity/client_key.rs", - "crates/platform/src/storage/mod.rs", - "crates/platform/src/storage/sqlite.rs", - "crates/platform/src/storage/schema.sql", - "crates/platform/src/storage/paths.rs", - "crates/platform/src/logging/mod.rs", - "crates/platform/src/logging/rotation.rs", - "crates/platform/src/json/mod.rs", - "crates/platform/src/json/pagination.rs", - "crates/assets/src/lib.rs", - "crates/assets/src/router.rs", - "crates/assets/src/headers.rs", - "crates/xtask/src/main.rs", - "crates/xtask/src/build_web.rs", - "crates/xtask/src/check_architecture.rs", - "crates/xtask/src/release.rs", - ] - .into_iter() - .map(String::from) - .collect() -} - -fn expected_rust_source_directories() -> BTreeSet<String> { - [ - "crates/core/src", - "crates/core/src/admin", - "crates/core/src/accounts", - "crates/core/src/auth", - "crates/core/src/models", - "crates/core/src/events", - "crates/core/src/usage", - "crates/core/src/protocol", - "crates/core/src/protocol/openai", - "crates/core/src/protocol/codex", - "crates/core/src/gateway", - "crates/core/src/serving", - "crates/adapters/src", - "crates/adapters/src/sqlite", - "crates/adapters/src/codex", - "crates/adapters/src/codex/websocket", - "crates/adapters/src/oauth", - "crates/runtime/src", - "crates/runtime/src/tasks", - "crates/server/src", - "crates/server/src/error", - "crates/server/src/middleware", - "crates/server/src/admin_api", - "crates/server/src/admin_api/accounts", - "crates/server/src/admin_api/client_keys", - "crates/server/src/admin_api/logs", - "crates/server/src/openai_api", - "crates/platform/src", - "crates/platform/src/config", - "crates/platform/src/crypto", - "crates/platform/src/identity", - "crates/platform/src/storage", - "crates/platform/src/logging", - "crates/platform/src/json", - "crates/assets/src", - "crates/xtask/src", - ] - .into_iter() - .map(String::from) - .collect() -} diff --git a/tests/architecture/forbidden_imports.rs b/tests/architecture/forbidden_imports.rs deleted file mode 100644 index 75e01324c..000000000 --- a/tests/architecture/forbidden_imports.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::{fs, path::Path}; - -#[test] -fn source_imports_should_respect_layer_boundaries() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let mut offenders = Vec::new(); - - collect_forbidden_imports(root, &mut offenders); - - assert!( - offenders.is_empty(), - "forbidden imports or source names found:\n{}", - offenders.join("\n") - ); -} - -fn collect_forbidden_imports(root: &Path, offenders: &mut Vec<String>) { - for crate_name in ["core", "adapters", "runtime", "platform", "server"] { - let src = root.join("crates").join(crate_name).join("src"); - collect_crate_imports(crate_name, &src, offenders); - } -} - -fn collect_crate_imports(crate_name: &str, dir: &Path, offenders: &mut Vec<String>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if path.file_name().and_then(|name| name.to_str()) == Some("tests") { - continue; - } - if crate_name == "core" - && path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| matches!(name, "repository" | "transport")) - { - offenders.push(path.display().to_string()); - } - collect_crate_imports(crate_name, &path, offenders); - continue; - } - - let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if crate_name == "core" && file_name.ends_with("_repository.rs") { - offenders.push(path.display().to_string()); - } - if crate_name == "server" && file_name == "facade.rs" { - offenders.push(path.display().to_string()); - } - - let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else { - continue; - }; - if extension != "rs" { - continue; - } - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - for forbidden in forbidden_patterns(crate_name) { - if content.contains(forbidden) { - offenders.push(format!("{} contains `{forbidden}`", path.display())); - } - } - } -} - -fn forbidden_patterns(crate_name: &str) -> &'static [&'static str] { - match crate_name { - "core" => &[ - "use axum::", - "axum::", - "use sqlx::", - "sqlx::", - "use reqwest::", - "reqwest::", - "use tokio_tungstenite::", - "tokio_tungstenite::", - "use tungstenite::", - "tungstenite::", - "use rustls::", - "rustls::", - "use tokio_rustls::", - "tokio_rustls::", - "std::fs", - "std::env", - "dirs::", - "std::path", - "PathBuf", - "Path::", - ], - "adapters" | "platform" => &["use axum::", "axum::"], - "runtime" => &[ - "use axum::", - "axum::", - "use sqlx::", - "sqlx::", - "use reqwest::", - "reqwest::", - "use tokio_tungstenite::", - "tokio_tungstenite::", - "use tungstenite::", - "tungstenite::", - ], - "server" => &[ - "use sqlx::", - "sqlx::", - "use reqwest::", - "reqwest::", - "use tokio_tungstenite::", - "tokio_tungstenite::", - "use tungstenite::", - "tungstenite::", - ], - _ => &[], - } -} diff --git a/tests/architecture/forbidden_legacy_paths.rs b/tests/architecture/forbidden_legacy_paths.rs deleted file mode 100644 index 76ece8770..000000000 --- a/tests/architecture/forbidden_legacy_paths.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::{fs, path::PathBuf}; - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -#[test] -fn source_tree_should_not_reference_legacy_gateway_oauth_module() { - let root = repository_root(); - let mut offenders = Vec::new(); - - for relative_dir in ["src", "tests"] { - let dir = root.join(relative_dir); - collect_legacy_oauth_references(&dir, &mut offenders); - } - - assert!( - offenders.is_empty(), - "legacy gateway::oauth references should be removed:\n{}", - offenders.join("\n") - ); -} - -fn collect_legacy_oauth_references(dir: &PathBuf, offenders: &mut Vec<String>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_legacy_oauth_references(&path, offenders); - continue; - } - - let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else { - continue; - }; - if extension != "rs" { - continue; - } - - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - if path.ends_with("tests/architecture/forbidden_legacy_paths.rs") { - continue; - } - - if content.contains("gateway::oauth") { - offenders.push(path.display().to_string()); - } - } -} diff --git a/tests/architecture/gateway_boundary.rs b/tests/architecture/gateway_boundary.rs deleted file mode 100644 index 3fd822cf5..000000000 --- a/tests/architecture/gateway_boundary.rs +++ /dev/null @@ -1,37 +0,0 @@ -use codex_proxy_core::{ - gateway::{ - fingerprint::Fingerprint, - installation::{generate_installation_id, parse_installation_id}, - }, - protocol::{ - codex::{ - events::{ - cooldown_with_jitter, extract_usage, parse_rate_limit_headers, - retry_after_seconds_from_body, ParsedRateLimits, TokenUsage, - }, - responses::CodexResponsesRequest, - }, - openai::{ - chat::{translate_chat_to_codex, ChatCompletionRequest, ChatMessage}, - responses::ResponseFormat, - }, - }, -}; - -#[test] -fn gateway_exports_core_protocol_and_identity_modules() { - let _fingerprint_type = std::any::type_name::<Fingerprint>(); - let _response_format_type = std::any::type_name::<ResponseFormat>(); - let _request_type = std::any::type_name::<CodexResponsesRequest>(); - let _usage_type = std::any::type_name::<TokenUsage>(); - let _rate_limits_type = std::any::type_name::<ParsedRateLimits>(); - let _translate_fn = translate_chat_to_codex; - let _extract_usage_fn = extract_usage; - let _parse_rate_limit_headers_fn = parse_rate_limit_headers; - let _retry_after_fn = retry_after_seconds_from_body; - let _cooldown_with_jitter_fn = cooldown_with_jitter; - let _chat_request_type = std::any::type_name::<ChatCompletionRequest>(); - let _chat_message_type = std::any::type_name::<ChatMessage>(); - let _generate_installation_id_fn = generate_installation_id; - let _parse_installation_id_fn = parse_installation_id; -} diff --git a/tests/architecture/placeholder_implementations.rs b/tests/architecture/placeholder_implementations.rs deleted file mode 100644 index 0f0fa830c..000000000 --- a/tests/architecture/placeholder_implementations.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::{fs, path::Path}; - -#[test] -fn rust_sources_should_not_keep_placeholder_markers() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let mut offenders = Vec::new(); - - collect_placeholder_markers(&root.join("src"), &mut offenders); - collect_placeholder_markers(&root.join("crates"), &mut offenders); - - assert!( - offenders.is_empty(), - "placeholder implementation markers found:\n{}", - offenders.join("\n") - ); -} - -#[test] -fn serving_modules_should_not_keep_uncalled_toy_helpers() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let mut offenders = Vec::new(); - - for (relative_path, marker) in [ - ( - "crates/core/src/serving/fallback.rs", - "pub fn should_fallback(", - ), - ( - "crates/core/src/serving/recovery.rs", - "pub fn is_recoverable(", - ), - ( - "crates/core/src/serving/responses.rs", - "pub fn prefers_websocket(", - ), - ] { - let path = root.join(relative_path); - let content = fs::read_to_string(&path).unwrap_or_default(); - if content.contains(marker) { - offenders.push(format!("{relative_path} keeps toy helper `{marker}`")); - } - } - - if production_call_count(root, "quota_reached(", "crates/core/src/serving/quota.rs") == 0 { - offenders.push( - "crates/core/src/serving/quota.rs exposes `quota_reached` without a production caller" - .to_string(), - ); - } - - assert!( - offenders.is_empty(), - "uncalled serving toy helpers found:\n{}", - offenders.join("\n") - ); -} - -fn collect_placeholder_markers(dir: &Path, offenders: &mut Vec<String>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_placeholder_markers(&path, offenders); - continue; - } - - if path.extension().and_then(|extension| extension.to_str()) != Some("rs") { - continue; - } - - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - - for marker in [ - "fn main() {}", - "not wired yet", - "后续承载", - "pub struct CodexWebSocketConnection;", - "pub struct CodexWebSocketPool;", - ] { - if content.contains(marker) { - offenders.push(format!("{} contains `{marker}`", path.display())); - } - } - } -} - -fn production_call_count(root: &Path, needle: &str, defining_file: &str) -> usize { - let mut count = 0; - count_production_calls( - &root.join("crates"), - needle, - &root.join(defining_file), - &mut count, - ); - count -} - -fn count_production_calls(dir: &Path, needle: &str, defining_file: &Path, count: &mut usize) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if path.file_name().and_then(|name| name.to_str()) == Some("tests") { - continue; - } - count_production_calls(&path, needle, defining_file, count); - continue; - } - if path == defining_file { - continue; - } - if path.extension().and_then(|extension| extension.to_str()) != Some("rs") { - continue; - } - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - *count += content.matches(needle).count(); - } -} diff --git a/tests/architecture/platform_boundary.rs b/tests/architecture/platform_boundary.rs deleted file mode 100644 index a28c64df9..000000000 --- a/tests/architecture/platform_boundary.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::{fs, path::PathBuf}; - -#[test] -fn platform_exports_foundation_modules() { - let platform_lib = fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("crates/platform/src/lib.rs"), - ) - .expect("expected platform lib.rs to be readable"); - - assert!( - platform_lib.contains("pub mod config;") - && platform_lib.contains("pub mod crypto;") - && platform_lib.contains("pub mod identity;") - && platform_lib.contains("pub mod json;") - && platform_lib.contains("pub mod logging;") - && platform_lib.contains("pub mod storage;"), - "platform crate should expose foundation modules directly from its own crate" - ); -} diff --git a/tests/architecture/runtime_tasks_boundary.rs b/tests/architecture/runtime_tasks_boundary.rs deleted file mode 100644 index 530071b15..000000000 --- a/tests/architecture/runtime_tasks_boundary.rs +++ /dev/null @@ -1,10 +0,0 @@ -use codex_proxy_runtime::tasks::coordinator::{ - start_background_tasks, BackgroundTaskCoordinator, SchedulerHandle, -}; - -#[test] -fn runtime_exports_background_task_coordinator_modules() { - let _coordinator_type = std::any::type_name::<BackgroundTaskCoordinator>(); - let _handle_type = std::any::type_name::<SchedulerHandle>(); - let _start_fn = start_background_tasks; -} diff --git a/tests/architecture/server_startup.rs b/tests/architecture/server_startup.rs deleted file mode 100644 index 15e94e314..000000000 --- a/tests/architecture/server_startup.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::{fs, path::Path}; - -#[test] -fn server_entrypoint_should_own_background_task_lifecycle() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let main_rs = root.join("crates/server/src/main.rs"); - let content = fs::read_to_string(&main_rs).expect("server main should be readable"); - - assert!( - content.contains("start_background_tasks(&state).await") - && content.contains("task_coordinator.shutdown().await"), - "server main must start runtime background tasks and shut them down after axum exits" - ); -} - -#[test] -fn server_entrypoint_should_inject_runtime_installation_id() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let main_rs = root.join("crates/server/src/main.rs"); - let content = fs::read_to_string(&main_rs).expect("server main should be readable"); - - assert!( - content.contains("load_or_create_installation_id(Some(&data_dir))") - && content.contains("with_pool_secret_api_key_hasher_and_installation_id"), - "server main must load the platform installation id and inject it into runtime state" - ); -} - -#[test] -fn server_entrypoint_should_restore_session_affinity_before_serving() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let main_rs = root.join("crates/server/src/main.rs"); - let content = fs::read_to_string(&main_rs).expect("server main should be readable"); - - assert!( - content.contains("restore_session_affinity_from_repository_now().await"), - "server main must restore session affinity mappings from SQLite during startup" - ); -} - -#[test] -fn server_entrypoint_should_restore_account_pool_before_serving() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let main_rs = root.join("crates/server/src/main.rs"); - let content = fs::read_to_string(&main_rs).expect("server main should be readable"); - - assert!( - content.contains("restore_account_pool_from_repository().await"), - "server main must restore active runtime accounts from SQLite during startup" - ); -} - -#[test] -fn server_entrypoint_should_ensure_default_admin_before_serving() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let main_rs = root.join("crates/server/src/main.rs"); - let content = fs::read_to_string(&main_rs).expect("server main should be readable"); - - assert!( - content.contains("ensure_default_admin") - && content.contains("config.admin.default_password"), - "server main must initialize the configured default admin before serving admin routes" - ); -} diff --git a/tests/architecture/test_migration.rs b/tests/architecture/test_migration.rs deleted file mode 100644 index 578fcb664..000000000 --- a/tests/architecture/test_migration.rs +++ /dev/null @@ -1,449 +0,0 @@ -use std::{ - collections::BTreeSet, - fs, - path::{Path, PathBuf}, -}; - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -#[test] -fn root_tests_should_only_keep_architecture_tests_and_shared_fixtures() { - let root = repository_root(); - let mut offenders = Vec::new(); - collect_root_test_rs_files(&root.join("tests"), &root, &mut offenders); - - assert!( - offenders.is_empty(), - "root behavior tests must be migrated into crates/*/tests:\n{}", - offenders.join("\n") - ); -} - -#[test] -fn baseline_behavior_test_suites_should_have_crate_local_migration_coverage() { - let root = repository_root(); - let current_tests = current_crate_test_files(&root); - let mut missing = Vec::new(); - - for suite in baseline_behavior_suites() { - for required in suite.required_current_files { - if !current_tests.contains(*required) { - missing.push(format!( - "{} should be covered by crate-local test file {required}", - suite.baseline_path - )); - } - } - } - - assert!( - missing.is_empty(), - "old behavior test suites lack crate-local migration evidence:\n{}", - missing.join("\n") - ); -} - -#[test] -fn core_service_modules_should_have_production_callers() { - let root = repository_root(); - let mut offenders = Vec::new(); - - for service in [ - CoreService { - path: "crates/core/src/accounts/service.rs", - symbol: "AccountService::", - }, - CoreService { - path: "crates/core/src/usage/service.rs", - symbol: "UsageService::", - }, - CoreService { - path: "crates/core/src/admin/settings.rs", - symbol: "SettingsService::", - }, - ] { - if production_call_count(&root, service.symbol, service.path) == 0 { - offenders.push(format!( - "{} exposes {} without a production caller", - service.path, service.symbol - )); - } - } - - assert!( - offenders.is_empty(), - "core service modules still look like architecture placeholders:\n{}", - offenders.join("\n") - ); -} - -fn collect_root_test_rs_files(dir: &Path, root: &Path, offenders: &mut Vec<String>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let relative = relative_path(root, &path); - if relative == "tests/architecture" || relative.starts_with("tests/fixtures") { - continue; - } - collect_root_test_rs_files(&path, root, offenders); - continue; - } - - if path.extension().and_then(|extension| extension.to_str()) != Some("rs") { - continue; - } - - let relative = relative_path(root, &path); - if relative != "tests/architecture.rs" && !relative.starts_with("tests/architecture/") { - offenders.push(relative); - } - } -} - -fn current_crate_test_files(root: &Path) -> BTreeSet<String> { - let mut files = BTreeSet::new(); - collect_crate_test_files(&root.join("crates"), root, &mut files); - files -} - -fn collect_crate_test_files(dir: &Path, root: &Path, files: &mut BTreeSet<String>) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_crate_test_files(&path, root, files); - continue; - } - if path.extension().and_then(|extension| extension.to_str()) == Some("rs") - && relative_path(root, &path).contains("/tests/") - { - files.insert(relative_path(root, &path)); - } - } -} - -fn production_call_count(root: &Path, needle: &str, defining_file: &str) -> usize { - let mut count = 0; - count_production_calls( - &root.join("crates"), - needle, - &root.join(defining_file), - &mut count, - ); - count -} - -fn count_production_calls(dir: &Path, needle: &str, defining_file: &Path, count: &mut usize) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if path.file_name().and_then(|name| name.to_str()) == Some("tests") { - continue; - } - count_production_calls(&path, needle, defining_file, count); - continue; - } - if path == defining_file { - continue; - } - if path.extension().and_then(|extension| extension.to_str()) != Some("rs") { - continue; - } - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - *count += content.matches(needle).count(); - } -} - -fn relative_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .expect("path should be below repository root") - .to_string_lossy() - .replace('\\', "/") -} - -struct BaselineSuite { - baseline_path: &'static str, - required_current_files: &'static [&'static str], -} - -struct CoreService { - path: &'static str, - symbol: &'static str, -} - -fn baseline_behavior_suites() -> Vec<BaselineSuite> { - vec![ - BaselineSuite { - baseline_path: "tests/admin/accounts/cookies_quota.rs", - required_current_files: &[ - "crates/server/tests/admin_accounts_routes.rs", - "crates/adapters/tests/cookies.rs", - "crates/runtime/tests/quota_refresh.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/admin/accounts/import_export.rs", - required_current_files: &["crates/server/tests/admin_accounts_routes.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/accounts/lifecycle.rs", - required_current_files: &["crates/server/tests/admin_accounts_routes.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/accounts/list.rs", - required_current_files: &["crates/server/tests/admin_accounts_routes.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/accounts/oauth.rs", - required_current_files: &[ - "crates/server/tests/admin_accounts_routes.rs", - "crates/adapters/tests/oauth.rs", - "crates/core/tests/auth.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/admin/api_contract.rs", - required_current_files: &["crates/server/tests/admin_api_contract.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/client_keys_route.rs", - required_current_files: &[ - "crates/server/tests/admin_client_keys_routes.rs", - "crates/adapters/tests/client_keys.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/admin/logs_route.rs", - required_current_files: &[ - "crates/server/tests/admin_logs_routes.rs", - "crates/adapters/tests/events.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/admin/models_route.rs", - required_current_files: &[ - "crates/server/tests/admin_models_routes.rs", - "crates/core/tests/models.rs", - "crates/adapters/tests/models.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/admin/session.rs", - required_current_files: &["crates/platform/tests/admin_password.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/session_login_route.rs", - required_current_files: &["crates/server/tests/admin_session_routes.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/session_repository.rs", - required_current_files: &["crates/adapters/tests/admin_sessions.rs"], - }, - BaselineSuite { - baseline_path: "tests/admin/settings_route.rs", - required_current_files: &[ - "crates/server/tests/admin_settings_routes.rs", - "crates/core/tests/admin_settings.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/admin/usage_stats_route.rs", - required_current_files: &["crates/server/tests/admin_accounts_routes.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_accounts/cookie_store.rs", - required_current_files: &[ - "crates/adapters/tests/cookies.rs", - "crates/runtime/tests/tasks.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_accounts/pool_scheduling.rs", - required_current_files: &["crates/core/tests/account_pool.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_accounts/refresh.rs", - required_current_files: &[ - "crates/core/tests/protocol.rs", - "crates/runtime/tests/token_refresh.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_accounts/refresh_scheduler.rs", - required_current_files: &["crates/core/tests/account_pool.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_accounts/repository.rs", - required_current_files: &[ - "crates/adapters/tests/account_repository.rs", - "crates/adapters/tests/refresh_leases.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_accounts/service_refresh.rs", - required_current_files: &["crates/runtime/tests/token_refresh.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_events/logs_pagination.rs", - required_current_files: &[ - "crates/adapters/tests/events.rs", - "crates/server/tests/admin_logs_routes.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/cli_auth_import.rs", - required_current_files: &["crates/server/tests/admin_accounts_routes.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/fingerprint_update.rs", - required_current_files: &[ - "crates/adapters/tests/codex.rs", - "crates/runtime/tests/fingerprint.rs", - "crates/runtime/tests/tasks.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/headers.rs", - required_current_files: &[ - "crates/adapters/tests/codex.rs", - "crates/core/tests/protocol.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/http_client.rs", - required_current_files: &["crates/adapters/tests/codex.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/oauth_refresh.rs", - required_current_files: &["crates/adapters/tests/oauth.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/usage_events.rs", - required_current_files: &["crates/core/tests/protocol.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/websocket.rs", - required_current_files: &[ - "crates/core/tests/protocol.rs", - "crates/adapters/tests/codex.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_gateway/websocket/pool.rs", - required_current_files: &[ - "crates/adapters/tests/codex.rs", - "crates/runtime/tests/upstream.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_models/catalog.rs", - required_current_files: &[ - "crates/core/tests/models.rs", - "crates/adapters/tests/models.rs", - "crates/server/tests/admin_models_routes.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/chat_completions.rs", - required_current_files: &[ - "crates/server/tests/openai_chat_upstream.rs", - "crates/core/tests/protocol.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/diagnostics_route.rs", - required_current_files: &[ - "crates/server/tests/openai_diagnostics_routes.rs", - "crates/server/tests/admin_settings_routes.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/responses_http_sse.rs", - required_current_files: &["crates/server/tests/openai_chat_upstream.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/responses_websocket.rs", - required_current_files: &[ - "crates/server/tests/openai_chat_upstream.rs", - "crates/adapters/tests/codex.rs", - "crates/runtime/tests/upstream.rs", - "crates/runtime/tests/session_affinity.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/routes_chat.rs", - required_current_files: &["crates/core/tests/protocol.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/routes_responses.rs", - required_current_files: &[ - "crates/server/tests/openai_responses_routes.rs", - "crates/server/tests/openai_models_auth.rs", - "crates/core/tests/models.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/upstream_errors.rs", - required_current_files: &["crates/server/tests/openai_chat_upstream.rs"], - }, - BaselineSuite { - baseline_path: "tests/codex_serving/upstream_fallback.rs", - required_current_files: &["crates/server/tests/openai_chat_upstream.rs"], - }, - BaselineSuite { - baseline_path: "tests/config.rs", - required_current_files: &["crates/platform/tests/config.rs"], - }, - BaselineSuite { - baseline_path: "tests/platform/client_key_auth.rs", - required_current_files: &["crates/platform/tests/client_key_auth.rs"], - }, - BaselineSuite { - baseline_path: "tests/platform/crypto.rs", - required_current_files: &["crates/platform/tests/crypto.rs"], - }, - BaselineSuite { - baseline_path: "tests/platform/http_auth.rs", - required_current_files: &[ - "crates/platform/tests/client_key_auth.rs", - "crates/server/tests/openai_models_auth.rs", - "crates/server/tests/admin_session_routes.rs", - ], - }, - BaselineSuite { - baseline_path: "tests/platform/log_rotation.rs", - required_current_files: &["crates/platform/tests/log_rotation.rs"], - }, - BaselineSuite { - baseline_path: "tests/platform/storage_schema.rs", - required_current_files: &["crates/platform/tests/storage_schema.rs"], - }, - BaselineSuite { - baseline_path: "tests/runtime/http_trace.rs", - required_current_files: &["crates/server/tests/http_trace_middleware.rs"], - }, - BaselineSuite { - baseline_path: "tests/runtime/startup.rs", - required_current_files: &[ - "crates/runtime/tests/account_pool_restore.rs", - "crates/runtime/tests/session_affinity.rs", - "crates/server/tests/openai_chat_upstream.rs", - ], - }, - ] -} diff --git a/tests/architecture/workspace_scaffold.rs b/tests/architecture/workspace_scaffold.rs deleted file mode 100644 index 716ef90ed..000000000 --- a/tests/architecture/workspace_scaffold.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::{fs, path::PathBuf}; - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -#[test] -fn workspace_should_declare_crates_glob_member() { - let cargo_toml = fs::read_to_string(repository_root().join("Cargo.toml")) - .expect("expected root Cargo.toml to be readable"); - - assert!( - cargo_toml.contains("[workspace]") && cargo_toml.contains("members = [\"crates/*\"]"), - "root Cargo.toml should declare a workspace with members = [\"crates/*\"]" - ); -} - -#[test] -fn workspace_should_scaffold_required_member_crates() { - let root = repository_root(); - - for crate_name in [ - "core", "adapters", "runtime", "server", "platform", "assets", "xtask", - ] { - let crate_dir = root.join("crates").join(crate_name); - assert!( - crate_dir.join("Cargo.toml").is_file(), - "expected {crate_name} crate to define Cargo.toml at {}", - crate_dir.join("Cargo.toml").display() - ); - - let entry_paths = if crate_name == "server" { - vec![crate_dir.join("src/lib.rs"), crate_dir.join("src/main.rs")] - } else if crate_name == "xtask" { - vec![crate_dir.join("src/main.rs")] - } else { - vec![crate_dir.join("src/lib.rs")] - }; - - for entry_path in entry_paths { - assert!( - entry_path.is_file(), - "expected {crate_name} crate to define source entry at {}", - entry_path.display() - ); - } - } -} - -#[test] -fn workspace_should_scaffold_runtime_server_and_assets_shapes() { - let root = repository_root(); - - for relative_path in [ - "crates/core/src/admin/mod.rs", - "crates/core/src/admin/ports.rs", - "crates/core/src/admin/client_keys.rs", - "crates/core/src/accounts/model.rs", - "crates/core/src/auth/mod.rs", - "crates/core/src/auth/oauth.rs", - "crates/core/src/gateway/ports.rs", - "crates/core/src/models/mod.rs", - "crates/core/src/models/model.rs", - "crates/core/src/models/catalog.rs", - "crates/core/src/models/ports.rs", - "crates/core/src/models/service.rs", - "crates/core/src/protocol/codex/events.rs", - "crates/adapters/src/codex/mod.rs", - "crates/adapters/src/codex/client.rs", - "crates/adapters/src/codex/models.rs", - "crates/adapters/src/codex/fingerprint.rs", - "crates/adapters/src/codex/websocket/mod.rs", - "crates/adapters/src/codex/websocket/connect.rs", - "crates/adapters/src/codex/websocket/pool.rs", - "crates/adapters/src/codex/websocket/deflate.rs", - "crates/adapters/src/codex/websocket/opening.rs", - "crates/adapters/src/sqlite/mod.rs", - "crates/adapters/src/sqlite/client_keys.rs", - "crates/adapters/src/sqlite/models.rs", - "crates/assets/src/router.rs", - "crates/assets/src/headers.rs", - "crates/runtime/src/bootstrap.rs", - "crates/runtime/src/state.rs", - "crates/runtime/src/services.rs", - "crates/runtime/src/repositories.rs", - "crates/runtime/src/upstream.rs", - "crates/runtime/src/config.rs", - "crates/runtime/src/tasks/mod.rs", - "crates/runtime/src/tasks/coordinator.rs", - "crates/runtime/src/tasks/token_refresh.rs", - "crates/runtime/src/tasks/quota_refresh.rs", - "crates/runtime/src/tasks/model_refresh.rs", - "crates/runtime/src/tasks/cookie_cleanup.rs", - "crates/runtime/src/tasks/session_cleanup.rs", - "crates/runtime/src/tasks/session_affinity_cleanup.rs", - "crates/runtime/src/tasks/fingerprint_update.rs", - "crates/server/src/router.rs", - "crates/server/src/error/mod.rs", - "crates/server/src/error/admin.rs", - "crates/server/src/error/openai.rs", - "crates/server/src/middleware/mod.rs", - "crates/server/src/middleware/request_id.rs", - "crates/server/src/middleware/trace.rs", - "crates/server/src/middleware/auth.rs", - "crates/server/src/middleware/cors.rs", - "crates/server/src/admin_api/mod.rs", - "crates/server/src/admin_api/router.rs", - "crates/server/src/openai_api/mod.rs", - "crates/server/src/openai_api/auth.rs", - "crates/server/src/openai_api/chat.rs", - "crates/server/src/openai_api/diagnostics.rs", - "crates/server/src/openai_api/error.rs", - "crates/server/src/openai_api/models.rs", - "crates/server/src/openai_api/responses.rs", - "crates/server/src/openai_api/router.rs", - "crates/server/src/openai_api/sse.rs", - "crates/xtask/src/build_web.rs", - "crates/xtask/src/check_architecture.rs", - "crates/xtask/src/release.rs", - ] { - assert!( - root.join(relative_path).is_file(), - "expected architecture scaffold file at {}", - root.join(relative_path).display() - ); - } -} - -#[test] -fn migrated_modules_should_not_keep_legacy_duplicates() { - let root = repository_root(); - - for relative_path in [ - "src/main.rs", - "src/runtime/bootstrap.rs", - "src/runtime/mod.rs", - "src/runtime/router.rs", - "src/runtime/state.rs", - "src/runtime/tasks/coordinator.rs", - "src/runtime/tasks/mod.rs", - "src/runtime/tasks/types.rs", - "src/codex/accounts/jwt.rs", - "src/codex/gateway/conversation_identity.rs", - "src/codex/gateway/installation_id.rs", - "src/codex/gateway/fingerprint/mod.rs", - "src/codex/gateway/fingerprint/model.rs", - "src/codex/gateway/protocol/openai_to_codex.rs", - "src/codex/gateway/protocol/schema.rs", - "src/codex/gateway/protocol/tuple_schema.rs", - "src/codex/gateway/transport/types.rs", - "src/codex/gateway/transport/endpoints.rs", - "src/codex/gateway/transport/custom_ca.rs", - "src/codex/gateway/fingerprint/repository.rs", - "src/codex/gateway/fingerprint/updater.rs", - "src/codex/gateway/fingerprint/update_checker.rs", - "src/codex/gateway/transport/sse.rs", - "src/codex/gateway/protocol/codex_to_openai.rs", - "src/codex/gateway/transport/usage_events.rs", - "src/codex/gateway/transport/retry_after.rs", - "src/codex/gateway/transport/rate_limits.rs", - "src/codex/gateway/transport/headers.rs", - "src/codex/gateway/transport/http_client.rs", - "src/codex/gateway/oauth/mod.rs", - "src/codex/gateway/oauth/codex_cli.rs", - "src/codex/gateway/oauth/client.rs", - "src/codex/gateway/oauth/token.rs", - "src/codex/gateway/oauth/refresh.rs", - "src/codex/models/catalog.rs", - "src/codex/models/repository.rs", - "src/codex/models/service.rs", - "src/codex/serving/http/auth.rs", - "src/codex/serving/http/chat.rs", - "src/codex/serving/http/diagnostics.rs", - "src/codex/serving/http/errors.rs", - "src/codex/serving/http/mod.rs", - "src/codex/serving/http/models.rs", - "src/codex/serving/http/responses.rs", - "src/codex/serving/http/router.rs", - "tests/codex_gateway/http_client.rs", - "tests/codex_models/catalog.rs", - ] { - assert!( - !root.join(relative_path).exists(), - "migrated legacy module should be removed: {}", - root.join(relative_path).display() - ); - } -} - -#[test] -fn root_src_should_be_removed_after_workspace_migration() { - let root = repository_root(); - - assert!( - !root.join("src").exists(), - "root src should be removed after member crates expose architecture boundaries directly" - ); -} diff --git a/tests/fixtures/chat/golden/no_available_accounts.json b/tests/fixtures/chat/golden/no_available_accounts.json deleted file mode 100644 index 015a47f54..000000000 --- a/tests/fixtures/chat/golden/no_available_accounts.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "error": { - "message": "No available Codex accounts", - "type": "server_error", - "param": null, - "code": "no_available_accounts" - } -} diff --git a/tests/fixtures/chat/golden/quota_exhausted_fallback_exhausted.json b/tests/fixtures/chat/golden/quota_exhausted_fallback_exhausted.json deleted file mode 100644 index e36c52ce0..000000000 --- a/tests/fixtures/chat/golden/quota_exhausted_fallback_exhausted.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "error": { - "message": "All accounts exhausted (1 quota-exhausted). Codex upstream error: {\"error\":{\"message\":\"quota reached\"}}", - "type": "server_error", - "param": null, - "code": "upstream_error" - } -} diff --git a/tests/fixtures/chat/golden/rate_limit_fallback_exhausted.json b/tests/fixtures/chat/golden/rate_limit_fallback_exhausted.json deleted file mode 100644 index 52c0fcfcc..000000000 --- a/tests/fixtures/chat/golden/rate_limit_fallback_exhausted.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "error": { - "message": "All accounts exhausted (1 rate-limited). Codex upstream error: {\"error\":{\"message\":\"rate limited\"}}", - "type": "server_error", - "param": null, - "code": "upstream_error" - } -} diff --git a/tests/fixtures/chat/non_stream_text.sse b/tests/fixtures/chat/non_stream_text.sse deleted file mode 100644 index 86edd1a19..000000000 --- a/tests/fixtures/chat/non_stream_text.sse +++ /dev/null @@ -1,6 +0,0 @@ -event: response.output_text.delta -data: {"delta":"hello"} - -event: response.completed -data: {"response":{"id":"resp_chat","object":"response","status":"completed","usage":{"input_tokens":9,"output_tokens":3}}} - diff --git a/tests/fixtures/chat/parallel_tools_success.sse b/tests/fixtures/chat/parallel_tools_success.sse deleted file mode 100644 index 3e00ab755..000000000 --- a/tests/fixtures/chat/parallel_tools_success.sse +++ /dev/null @@ -1,6 +0,0 @@ -event: response.output_text.delta -data: {"delta":"ok"} - -event: response.completed -data: {"response":{"id":"resp_chat","object":"response","status":"completed","usage":{"input_tokens":2,"output_tokens":1}}} - diff --git a/tests/fixtures/chat/retry_after_success.sse b/tests/fixtures/chat/retry_after_success.sse deleted file mode 100644 index 83cf7a8e2..000000000 --- a/tests/fixtures/chat/retry_after_success.sse +++ /dev/null @@ -1,6 +0,0 @@ -event: response.output_text.delta -data: {"delta":"ok"} - -event: response.completed -data: {"response":{"id":"resp_chat_after_429","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":1}}} - diff --git a/tests/fixtures/chat/stream_text.sse b/tests/fixtures/chat/stream_text.sse deleted file mode 100644 index 49e83639c..000000000 --- a/tests/fixtures/chat/stream_text.sse +++ /dev/null @@ -1,9 +0,0 @@ -event: response.output_text.delta -data: {"delta":"hel"} - -event: response.output_text.delta -data: {"delta":"lo"} - -event: response.completed -data: {"response":{"id":"resp_chat","object":"response","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"input_tokens_details":{"cached_tokens":4}}}} - diff --git a/tests/fixtures/chat/tool_reasoning_complete.sse b/tests/fixtures/chat/tool_reasoning_complete.sse deleted file mode 100644 index 8e68557f7..000000000 --- a/tests/fixtures/chat/tool_reasoning_complete.sse +++ /dev/null @@ -1,18 +0,0 @@ -event: response.reasoning_summary_text.delta -data: {"delta":"thinking "} - -event: response.output_item.added -data: {"output_index":0,"item":{"type":"function_call","id":"item_1","call_id":"call_abc","name":"lookup"}} - -event: response.function_call_arguments.delta -data: {"item_id":"item_1","delta":"{\"city\""} - -event: response.function_call_arguments.delta -data: {"item_id":"item_1","delta":":\"Paris\"}"} - -event: response.function_call_arguments.done -data: {"item_id":"item_1","arguments":"{\"city\":\"Paris\"}"} - -event: response.completed -data: {"response":{"id":"resp_chat","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"output_tokens_details":{"reasoning_tokens":7}}}} - diff --git a/tests/fixtures/chat/tool_stream.sse b/tests/fixtures/chat/tool_stream.sse deleted file mode 100644 index 53c1453f6..000000000 --- a/tests/fixtures/chat/tool_stream.sse +++ /dev/null @@ -1,12 +0,0 @@ -event: response.output_item.added -data: {"output_index":0,"item":{"type":"function_call","id":"item_1","call_id":"call_abc","name":"lookup"}} - -event: response.function_call_arguments.delta -data: {"item_id":"item_1","delta":"{\"city\""} - -event: response.function_call_arguments.delta -data: {"item_id":"item_1","delta":":\"Paris\"}"} - -event: response.completed -data: {"response":{"id":"resp_chat","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}} - diff --git a/tests/fixtures/chat/tuple_object.sse b/tests/fixtures/chat/tuple_object.sse deleted file mode 100644 index a690ed9ce..000000000 --- a/tests/fixtures/chat/tuple_object.sse +++ /dev/null @@ -1,5 +0,0 @@ -event: response.output_text.delta -data: {"type":"response.output_text.delta","delta":"{\"point\":{\"0\":1,\"1\":2}}"} - -event: response.completed -data: {"response":{"id":"resp_chat_tuple","object":"response","status":"completed","output_text":"{\"point\":{\"0\":0,\"1\":0}}","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\"point\":{\"0\":1,\"1\":2}}"}]}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fixtures/responses/golden/cloudflare_challenge_no_fallback.json b/tests/fixtures/responses/golden/cloudflare_challenge_no_fallback.json deleted file mode 100644 index b5816d23b..000000000 --- a/tests/fixtures/responses/golden/cloudflare_challenge_no_fallback.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "error", - "error": { - "type": "server_error", - "code": "codex_api_error", - "message": "No accounts available. Upstream blocked the request (Cloudflare challenge)" - } -} diff --git a/tests/fixtures/responses/golden/compact_no_available_accounts.json b/tests/fixtures/responses/golden/compact_no_available_accounts.json deleted file mode 100644 index 71820a33a..000000000 --- a/tests/fixtures/responses/golden/compact_no_available_accounts.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "error", - "error": { - "type": "server_error", - "code": "no_available_accounts", - "message": "No available accounts. All accounts are expired or rate-limited." - } -} diff --git a/tests/fixtures/responses/golden/compact_rate_limit_fallback_exhausted.json b/tests/fixtures/responses/golden/compact_rate_limit_fallback_exhausted.json deleted file mode 100644 index 14e9a9072..000000000 --- a/tests/fixtures/responses/golden/compact_rate_limit_fallback_exhausted.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "error", - "error": { - "type": "rate_limit_error", - "code": "rate_limit_exceeded", - "message": "All accounts exhausted (1 rate-limited). Codex upstream error: {\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"compact quota reached\"}}" - } -} diff --git a/tests/fixtures/responses/golden/non_sse_body_event.json b/tests/fixtures/responses/golden/non_sse_body_event.json deleted file mode 100644 index 6bdcb9baa..000000000 --- a/tests/fixtures/responses/golden/non_sse_body_event.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "data": "{\"error\":{\"code\":\"non_sse_response\",\"message\":\"not an SSE stream\",\"type\":\"error\"}}", - "event": "error", - "id": null, - "retry": null - } -] diff --git a/tests/fixtures/responses/golden/response_failed_auth_recovery.json b/tests/fixtures/responses/golden/response_failed_auth_recovery.json deleted file mode 100644 index cfb1d9cc4..000000000 --- a/tests/fixtures/responses/golden/response_failed_auth_recovery.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "resp_after_401_fallback", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1 - } -} diff --git a/tests/fixtures/responses/golden/sse_pretty_json_events.json b/tests/fixtures/responses/golden/sse_pretty_json_events.json deleted file mode 100644 index 30ea436a9..000000000 --- a/tests/fixtures/responses/golden/sse_pretty_json_events.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "data": "{\n \"error\": {\n \"message\": \"bad upstream\"\n }\n}", - "event": "error", - "id": null, - "retry": null - } -] diff --git a/tests/fixtures/responses/golden/sse_standard_events.json b/tests/fixtures/responses/golden/sse_standard_events.json deleted file mode 100644 index a8317e348..000000000 --- a/tests/fixtures/responses/golden/sse_standard_events.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "data": "{\"delta\":\"hel\"}\n{\"delta\":\"lo\"}", - "event": "response.output_text.delta", - "id": "evt_1", - "retry": 5000 - } -] diff --git a/tests/fixtures/responses/golden/stream_premature_close.sse b/tests/fixtures/responses/golden/stream_premature_close.sse deleted file mode 100644 index c2d3d7a43..000000000 --- a/tests/fixtures/responses/golden/stream_premature_close.sse +++ /dev/null @@ -1,5 +0,0 @@ -event: response.output_text.delta -data: {"delta":"partial"} - -event: response.failed -data: {"type":"response.failed","response":{"id":"resp_proxy_redacted","status":"failed","error":{"type":"server_error","code":"stream_disconnected","message":"Upstream stream closed before response.completed"}},"error":{"type":"server_error","code":"stream_disconnected","message":"Upstream stream closed before response.completed"}} diff --git a/tests/fixtures/responses/golden/token_invalid_no_fallback.json b/tests/fixtures/responses/golden/token_invalid_no_fallback.json deleted file mode 100644 index 4f165b42c..000000000 --- a/tests/fixtures/responses/golden/token_invalid_no_fallback.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "error", - "error": { - "type": "server_error", - "code": "codex_api_error", - "message": "All accounts exhausted (1 expired). Codex upstream error: {\"error\":{\"code\":\"token_revoked\",\"message\":\"token revoked\"}}" - } -} diff --git a/tests/fixtures/responses/golden/tuple_reconvert_response.json b/tests/fixtures/responses/golden/tuple_reconvert_response.json deleted file mode 100644 index a1e8aaf7c..000000000 --- a/tests/fixtures/responses/golden/tuple_reconvert_response.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "id": "resp_tuple", - "object": "response", - "status": "completed", - "output_text": "{\"point\":[1,2]}", - "output": [ - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "{\"point\":[1,2]}" - } - ] - } - ], - "usage": { - "input_tokens": 1, - "output_tokens": 1 - } -} diff --git a/tests/fixtures/responses/golden/tuple_reconvert_stream.sse b/tests/fixtures/responses/golden/tuple_reconvert_stream.sse deleted file mode 100644 index c0c8f1c47..000000000 --- a/tests/fixtures/responses/golden/tuple_reconvert_stream.sse +++ /dev/null @@ -1,5 +0,0 @@ -event: response.output_text.delta -data: {"delta":"{\"point\":[1,2]}","type":"response.output_text.delta"} - -event: response.completed -data: {"response":{"id":"resp_tuple","object":"response","output":[{"content":[{"text":"{\"point\":[1,2]}","type":"output_text"}],"role":"assistant","type":"message"}],"output_text":"{\"point\":[1,2]}","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fixtures/responses/golden/websocket_completed.sse b/tests/fixtures/responses/golden/websocket_completed.sse deleted file mode 100644 index a1116abc0..000000000 --- a/tests/fixtures/responses/golden/websocket_completed.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_ws_default","object":"response","usage":{"input_tokens":4,"output_tokens":2,"total_tokens":6}},"type":"response.completed"} diff --git a/tests/fixtures/responses/http_sse/after_401_fallback.sse b/tests/fixtures/responses/http_sse/after_401_fallback.sse deleted file mode 100644 index 91119658c..000000000 --- a/tests/fixtures/responses/http_sse/after_401_fallback.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_401_fallback","object":"response","usage":{"input_tokens":3,"output_tokens":1}}} diff --git a/tests/fixtures/responses/http_sse/after_402.sse b/tests/fixtures/responses/http_sse/after_402.sse deleted file mode 100644 index c6c86ce44..000000000 --- a/tests/fixtures/responses/http_sse/after_402.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_402","object":"response","usage":{"input_tokens":6,"output_tokens":3}}} - diff --git a/tests/fixtures/responses/http_sse/after_403.sse b/tests/fixtures/responses/http_sse/after_403.sse deleted file mode 100644 index f8c04ef17..000000000 --- a/tests/fixtures/responses/http_sse/after_403.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_403","object":"response","usage":{"input_tokens":6,"output_tokens":3}}} - diff --git a/tests/fixtures/responses/http_sse/after_429.sse b/tests/fixtures/responses/http_sse/after_429.sse deleted file mode 100644 index 066f733cc..000000000 --- a/tests/fixtures/responses/http_sse/after_429.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_429","object":"response","usage":{"input_tokens":5,"output_tokens":2}}} - diff --git a/tests/fixtures/responses/http_sse/after_5xx_retry.sse b/tests/fixtures/responses/http_sse/after_5xx_retry.sse deleted file mode 100644 index 83eccddb4..000000000 --- a/tests/fixtures/responses/http_sse/after_5xx_retry.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_5xx_retry","object":"response","usage":{"input_tokens":7,"output_tokens":2}}} diff --git a/tests/fixtures/responses/http_sse/after_cloudflare_403.sse b/tests/fixtures/responses/http_sse/after_cloudflare_403.sse deleted file mode 100644 index eb456495f..000000000 --- a/tests/fixtures/responses/http_sse/after_cloudflare_403.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_cf","object":"response","usage":{"input_tokens":6,"output_tokens":3}}} - diff --git a/tests/fixtures/responses/http_sse/after_model_unsupported_retry.sse b/tests/fixtures/responses/http_sse/after_model_unsupported_retry.sse deleted file mode 100644 index 953871b1c..000000000 --- a/tests/fixtures/responses/http_sse/after_model_unsupported_retry.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_after_model_unsupported_retry","object":"response","usage":{"input_tokens":8,"output_tokens":2}}} diff --git a/tests/fixtures/responses/http_sse/completed_fields.sse b/tests/fixtures/responses/http_sse/completed_fields.sse deleted file mode 100644 index d8fe8ddd2..000000000 --- a/tests/fixtures/responses/http_sse/completed_fields.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_fields","object":"response","usage":{"input_tokens":1,"output_tokens":1}}} - diff --git a/tests/fixtures/responses/http_sse/completed_image_usage.sse b/tests/fixtures/responses/http_sse/completed_image_usage.sse deleted file mode 100644 index eef4c791e..000000000 --- a/tests/fixtures/responses/http_sse/completed_image_usage.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_image","object":"response","status":"completed","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}},"tool_usage":{"image_gen":{"input_tokens":31,"output_tokens":9}}}} - diff --git a/tests/fixtures/responses/http_sse/completed_reasoning_include.sse b/tests/fixtures/responses/http_sse/completed_reasoning_include.sse deleted file mode 100644 index 24aefade4..000000000 --- a/tests/fixtures/responses/http_sse/completed_reasoning_include.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_reasoning_include","object":"response","usage":{"input_tokens":1,"output_tokens":1}}} - diff --git a/tests/fixtures/responses/http_sse/completed_usage.sse b/tests/fixtures/responses/http_sse/completed_usage.sse deleted file mode 100644 index 3259297b9..000000000 --- a/tests/fixtures/responses/http_sse/completed_usage.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.completed -data: {"response":{"id":"resp_1","object":"response","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}} - diff --git a/tests/fixtures/responses/http_sse/default_stream.sse b/tests/fixtures/responses/http_sse/default_stream.sse deleted file mode 100644 index abe7f3308..000000000 --- a/tests/fixtures/responses/http_sse/default_stream.sse +++ /dev/null @@ -1,6 +0,0 @@ -event: response.output_text.delta -data: {"delta":"pong"} - -event: response.completed -data: {"response":{"id":"resp_default_stream","object":"response","usage":{"input_tokens":3,"output_tokens":5}}} - diff --git a/tests/fixtures/responses/http_sse/done_item_completed.sse b/tests/fixtures/responses/http_sse/done_item_completed.sse deleted file mode 100644 index f1f2d9f66..000000000 --- a/tests/fixtures/responses/http_sse/done_item_completed.sse +++ /dev/null @@ -1,6 +0,0 @@ -event: response.output_item.done -data: {"item":{"type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"来自 done item"}]}} - -event: response.completed -data: {"response":{"id":"resp_item","object":"response","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":3}}} - diff --git a/tests/fixtures/responses/http_sse/error_event.sse b/tests/fixtures/responses/http_sse/error_event.sse deleted file mode 100644 index bb15949d0..000000000 --- a/tests/fixtures/responses/http_sse/error_event.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: error -data: {"error":{"message":"model overloaded","type":"server_error","code":"server_error"}} - diff --git a/tests/fixtures/responses/http_sse/failed_event.sse b/tests/fixtures/responses/http_sse/failed_event.sse deleted file mode 100644 index 6d3f922fc..000000000 --- a/tests/fixtures/responses/http_sse/failed_event.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.failed -data: {"response":{"id":"resp_failed","object":"response","status":"failed","error":{"code":"rate_limit_exceeded","message":"quota reached"}}} - diff --git a/tests/fixtures/responses/http_sse/previous_response_not_found.sse b/tests/fixtures/responses/http_sse/previous_response_not_found.sse deleted file mode 100644 index 4ff486726..000000000 --- a/tests/fixtures/responses/http_sse/previous_response_not_found.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: error -data: {"error":{"code":"previous_response_not_found","message":"Previous response with id resp_missing was not found"}} - diff --git a/tests/fixtures/responses/http_sse/response_failed_model_unsupported.sse b/tests/fixtures/responses/http_sse/response_failed_model_unsupported.sse deleted file mode 100644 index c790ccaf6..000000000 --- a/tests/fixtures/responses/http_sse/response_failed_model_unsupported.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.failed -data: {"response":{"id":"resp_sse_model_unsupported","object":"response","status":"failed","error":{"code":"model_not_supported","message":"Model gpt-5.5 is not supported on this account plan"}}} diff --git a/tests/fixtures/responses/http_sse/response_failed_token_invalid.sse b/tests/fixtures/responses/http_sse/response_failed_token_invalid.sse deleted file mode 100644 index 0fb3f4b66..000000000 --- a/tests/fixtures/responses/http_sse/response_failed_token_invalid.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.failed -data: {"response":{"id":"resp_sse_token_invalid","object":"response","status":"failed","error":{"code":"token_invalid","message":"access token is invalid"}}} diff --git a/tests/fixtures/responses/http_sse/stream_after_401_fallback.sse b/tests/fixtures/responses/http_sse/stream_after_401_fallback.sse deleted file mode 100644 index ff6ef6a8b..000000000 --- a/tests/fixtures/responses/http_sse/stream_after_401_fallback.sse +++ /dev/null @@ -1,5 +0,0 @@ -event: response.output_text.delta -data: {"delta":"ok"} - -event: response.completed -data: {"response":{"id":"resp_stream_after_401_fallback","object":"response","usage":{"input_tokens":4,"output_tokens":2}}} diff --git a/tests/fixtures/responses/http_sse/stream_error_event.sse b/tests/fixtures/responses/http_sse/stream_error_event.sse deleted file mode 100644 index f150de072..000000000 --- a/tests/fixtures/responses/http_sse/stream_error_event.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: error -data: {"error":{"message":"upstream stream failed","type":"server_error","code":"upstream_error"}} - diff --git a/tests/fixtures/responses/http_sse/stream_failed_event.sse b/tests/fixtures/responses/http_sse/stream_failed_event.sse deleted file mode 100644 index 40bbb861e..000000000 --- a/tests/fixtures/responses/http_sse/stream_failed_event.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: response.failed -data: {"response":{"id":"resp_stream_failed","object":"response","status":"failed","error":{"code":"server_error","message":"stream failed"}}} - diff --git a/tests/fixtures/responses/http_sse/stream_premature_close.sse b/tests/fixtures/responses/http_sse/stream_premature_close.sse deleted file mode 100644 index 1535fb4ea..000000000 --- a/tests/fixtures/responses/http_sse/stream_premature_close.sse +++ /dev/null @@ -1,2 +0,0 @@ -event: response.output_text.delta -data: {"delta":"partial"} diff --git a/tests/fixtures/responses/http_sse/stream_usage.sse b/tests/fixtures/responses/http_sse/stream_usage.sse deleted file mode 100644 index 5ca0ff17e..000000000 --- a/tests/fixtures/responses/http_sse/stream_usage.sse +++ /dev/null @@ -1,6 +0,0 @@ -event: response.output_text.delta -data: {"delta":"pong"} - -event: response.completed -data: {"response":{"id":"resp_stream","object":"response","usage":{"input_tokens":3,"output_tokens":5,"input_tokens_details":{"cached_tokens":1}}}} - diff --git a/tests/fixtures/responses/http_sse/unanswered_function_call.sse b/tests/fixtures/responses/http_sse/unanswered_function_call.sse deleted file mode 100644 index c3b8abe78..000000000 --- a/tests/fixtures/responses/http_sse/unanswered_function_call.sse +++ /dev/null @@ -1,3 +0,0 @@ -event: error -data: {"error":{"code":"invalid_request","message":"No tool output found for function call call_missing"}} - diff --git a/tests/fixtures/responses/websocket/completed.json b/tests/fixtures/responses/websocket/completed.json deleted file mode 100644 index eda961507..000000000 --- a/tests/fixtures/responses/websocket/completed.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "response.completed", - "response": { - "id": "resp_fixture", - "object": "response", - "usage": { - "input_tokens": 3, - "output_tokens": 1, - "total_tokens": 4 - } - } -} diff --git a/tests/fixtures/responses/websocket/partial_delta.json b/tests/fixtures/responses/websocket/partial_delta.json deleted file mode 100644 index 5992a11d2..000000000 --- a/tests/fixtures/responses/websocket/partial_delta.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"response.output_text.delta","delta":"partial"} diff --git a/tests/fixtures/test-ca.pem b/tests/fixtures/test-ca.pem deleted file mode 100644 index 7c9a9883c..000000000 --- a/tests/fixtures/test-ca.pem +++ /dev/null @@ -1,21 +0,0 @@ -# Test-only self-signed CA fixture used for single-certificate loading. -# These tests only verify PEM parsing and root-certificate registration, not a TLS handshake. ------BEGIN CERTIFICATE----- -MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL -BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky -MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB -DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF -7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH -twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko -ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l -kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM -gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6 -sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57 -7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB -TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd -S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7 -zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO -2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13 -CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+ -SprtRUBjlWzj ------END CERTIFICATE-----