diff --git a/docs/api.md b/docs/api.md index a04771c4e..cc80a2428 100644 --- a/docs/api.md +++ b/docs/api.md @@ -159,7 +159,7 @@ For non-streaming clients, upstream HTTP SSE `event: error` and `event: 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`. -`previous_response_id` and explicit WebSocket-only requests are rejected until the WebSocket transport is implemented and verified. +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/*` @@ -602,7 +602,7 @@ Success response: ### `POST /api/admin/accounts/import` -Imports accounts into encrypted SQLite storage. The request body uses the Rust import format, which accepts the exported account object fields needed by this service and ignores unrelated fields. +Imports accounts into encrypted SQLite storage. The request body uses the native Rust import format. The only supported shape is an object with `accounts`; Sub2API payloads, proxy/runtime fields, `credentials`/`tokens` wrappers, snake_case token fields, and other removed old-project aliases are rejected by producing no importable accounts. Request: @@ -632,7 +632,47 @@ Success response: "message": "OK", "data": { "imported": 1, - "skipped": 0 + "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. Removed aliases such as `format=full` and removed external formats such as `format=sub2api` return 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" } diff --git a/docs/architecture-reorganization.md b/docs/architecture-reorganization.md index 7ef548224..a76b1e3f0 100644 --- a/docs/architecture-reorganization.md +++ b/docs/architecture-reorganization.md @@ -654,7 +654,7 @@ Then move admin orchestration: | Service | Responsibility | | --- | --- | | `AdminAuthService` | admin login, session lifecycle, OAuth PKCE/device flows, logout/status. | -| `AccountService` | manual import, CLI import, native/Sub2API import/export, status/label/delete, refresh, quota, cookies, runtime pool synchronization. | +| `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. | @@ -874,7 +874,7 @@ Split `tests/admin_accounts_route_test.rs` first: | --- | --- | | `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, Sub2API import/export, CLI import. | +| `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. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index c6be42c72..630d1372a 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2972,7 +2972,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav - [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 import, manual token add, CLI import, paginated sanitized list, native/Sub2API export, Sub2API import marking, 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] **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 Sub2API/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, Ollama, 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. @@ -2991,8 +2991,8 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/ - [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/Sub2API account import, 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/Sub2API import, native/Sub2API 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] **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 does not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings. 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. 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 index e6ba604e3..c5c11c849 100644 --- a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md +++ b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md @@ -22,10 +22,10 @@ This audit is the correction point against the TypeScript reference at `/home/zy | Area / commit | TypeScript reference behavior | Rust current behavior | Classification | Action | | --- | --- | --- | --- | --- | -| Sub2API OpenAI OAuth import / `0b6cc7e` | Account import accepts token/refresh-token account exports and Sub2API-shaped OpenAI OAuth data through `AccountImportService`; proxy fields can exist in source data. | `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth payloads, returns `sourceFormat`, and ignores proxy-only fields. | Original parity, scoped. | Keep. Use the real Sub2API export only for private smoke verification; never print or commit secrets. | +| Sub2API OpenAI OAuth import / `0b6cc7e` | Account import accepts token/refresh-token account exports and Sub2API-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 Sub2API/proxy/runtime payloads by producing no importable accounts. | Old-project compatibility, out of scope. | Do not restore unless a new product requirement explicitly names Sub2API 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`, `sub2api`, and `cpa`. | `/admin/accounts/export` supports native/full Rust export and Sub2API OpenAI OAuth export only, without proxy fields. | Partial parity, scoped. | Keep native and Sub2API. Treat `minimal`, `cockpit_tools`, and `cpa` as omitted unless an OpenAI/Codex operation needs them. | +| Account export / `72438c6` | `/auth/accounts/export` supports `full`, `minimal`, `cockpit_tools`, `sub2api`, and `cpa`. | `/admin/accounts/export` supports only the native Rust export format. `full`, `sub2api`, 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, Anthropic, Gemini, OpenRouter/custom, 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. | @@ -163,9 +163,9 @@ Immediate priority after this audit: - Test: `tests/admin_accounts_cookies_quota_test.rs` - Test: `tests/admin_api_keys_route_test.rs` -- [x] Write failing tests for Sub2API OpenAI OAuth import payloads and native exports that carry proxy/runtime metadata. +- [x] Write failing tests for removed Sub2API OpenAI OAuth import payloads and native payloads that carry proxy/runtime metadata. - [x] Run: `cargo test --test admin_accounts_route_test` -- [x] Implement Sub2API import normalization, `sourceFormat` response marking, and proxy-field ignoring. +- [x] Remove Sub2API 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` @@ -184,8 +184,8 @@ Immediate priority after this audit: - [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 and Sub2API account export. -- [x] Implement native and Sub2API account export without proxy fields. +- [x] Write failing tests for native account export and removed Sub2API/full export formats. +- [x] Implement native account export without Sub2API/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. 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 index 07efffa6f..4b5b80129 100644 --- 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 @@ -31,7 +31,7 @@ Current progress: Tasks 1-7 are implemented for the OpenAI GPT/Codex scope. Comm - `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, including Sub2API account import normalization without proxy-pool support. +- `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; Sub2API and proxy-pool payloads are intentionally removed. ## Data Flow diff --git a/src/admin/api/accounts/export.rs b/src/admin/api/accounts/export.rs index 03f5298f4..f9de8e99c 100644 --- a/src/admin/api/accounts/export.rs +++ b/src/admin/api/accounts/export.rs @@ -4,10 +4,9 @@ use axum::{ response::IntoResponse, Extension, }; -use chrono::{SecondsFormat, Utc}; use secrecy::ExposeSecret; use serde::Deserialize; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use crate::{ codex::accounts::repository::StoredAccount, platform::http::request_id::RequestId, @@ -24,12 +23,6 @@ pub struct AccountExportQuery { pub format: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AccountExportFormat { - Native, - Sub2Api, -} - pub async fn export_accounts( State(state): State, Extension(request_id): Extension, @@ -37,17 +30,8 @@ pub async fn export_accounts( Query(query): Query, ) -> Result { let request_id = request_id.as_str().to_string(); - let format = match parse_account_export_format(query.format.as_deref()) { - Ok(format) => format, - Err(message) => { - return Err(AdminError::new( - StatusCode::BAD_REQUEST, - 40001, - message, - request_id, - )); - } - }; + validate_account_export_format(query.format.as_deref()) + .map_err(|message| AdminError::new(StatusCode::BAD_REQUEST, 40001, message, &request_id))?; require_admin_session(&state, &headers, &request_id).await?; let ids = account_export_ids(query.ids.as_deref()); @@ -59,20 +43,16 @@ pub async fn export_accounts( .map_err(|error| account_service_error(error, &request_id))?; // 账号导出会返回可重新导入的 OAuth token;只允许 admin session 访问,不写入日志。 - let data = match format { - AccountExportFormat::Native => native_account_export(accounts), - AccountExportFormat::Sub2Api => sub2api_account_export(accounts), - }; + let data = native_account_export(accounts); Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(data, request_id), )) } -fn parse_account_export_format(value: Option<&str>) -> Result { +fn validate_account_export_format(value: Option<&str>) -> Result<(), &'static str> { match value.unwrap_or("native") { - "" | "native" | "full" => Ok(AccountExportFormat::Native), - "sub2api" => Ok(AccountExportFormat::Sub2Api), + "" | "native" => Ok(()), _ => Err("Unsupported account export format"), } } @@ -103,61 +83,3 @@ fn native_export_account(account: StoredAccount) -> Value { "updatedAt": account.updated_at.to_rfc3339(), }) } - -fn sub2api_account_export(accounts: Vec) -> Value { - json!({ - "exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "proxies": [], - "accounts": accounts.into_iter().map(sub2api_export_account).collect::>(), - "type": "sub2api-data", - "version": 1, - }) -} - -fn sub2api_export_account(account: StoredAccount) -> Value { - let mut credentials = Map::new(); - credentials.insert( - "access_token".to_string(), - Value::String(account.access_token.expose_secret().to_string()), - ); - insert_optional_string( - &mut credentials, - "refresh_token", - account - .refresh_token - .as_ref() - .map(|token| token.expose_secret().to_string()), - ); - insert_optional_string(&mut credentials, "email", account.email.clone()); - insert_optional_string(&mut credentials, "chatgpt_account_id", account.account_id); - insert_optional_string(&mut credentials, "chatgpt_user_id", account.user_id); - insert_optional_string(&mut credentials, "plan_type", account.plan_type); - insert_optional_string( - &mut credentials, - "expires_at", - account - .access_token_expires_at - .map(|value| value.to_rfc3339()), - ); - - json!({ - "name": account - .label - .as_deref() - .filter(|label| !label.trim().is_empty()) - .map(str::to_string) - .or_else(|| account.email.clone()) - .unwrap_or_else(|| account.id.clone()), - "platform": "openai", - "type": "oauth", - "credentials": credentials, - "concurrency": 0, - "priority": 0, - }) -} - -fn insert_optional_string(map: &mut Map, key: &str, value: Option) { - if let Some(value) = value.filter(|value| !value.is_empty()) { - map.insert(key.to_string(), Value::String(value)); - } -} diff --git a/src/admin/api/accounts/health.rs b/src/admin/api/accounts/health.rs index 65573823c..35d1bea36 100644 --- a/src/admin/api/accounts/health.rs +++ b/src/admin/api/accounts/health.rs @@ -1,8 +1,9 @@ use axum::{ + body::Bytes, extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, - Extension, Json, + Extension, }; use serde::{Deserialize, Serialize}; @@ -16,10 +17,9 @@ use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminRespon use super::account_status_value; #[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HealthCheckRequest { pub ids: Option>, - #[serde(alias = "stagger_ms")] pub stagger_ms: Option, pub concurrency: Option, } @@ -56,10 +56,10 @@ pub async fn health_check_accounts( State(state): State, Extension(request_id): Extension, headers: HeaderMap, - payload: Option>, + body: Bytes, ) -> Result { let request_id = request_id.as_str().to_string(); - let payload = payload.map(|Json(payload)| payload).unwrap_or_default(); + let payload = parse_health_check_request(&body, &request_id)?; if payload.ids.as_ref().is_some_and(Vec::is_empty) { return Err(AdminError::new( StatusCode::BAD_REQUEST, @@ -125,6 +125,23 @@ pub async fn health_check_accounts( )) } +fn parse_health_check_request( + body: &Bytes, + request_id: &str, +) -> Result { + if body.is_empty() { + return Ok(HealthCheckRequest::default()); + } + serde_json::from_slice(body).map_err(|_| { + AdminError::new( + StatusCode::BAD_REQUEST, + 40001, + "Invalid health check request", + request_id, + ) + }) +} + pub(super) fn health_check_error(error: HealthCheckError, request_id: &str) -> AdminError { match error { HealthCheckError::RepositoryUnavailable => AdminError::new( diff --git a/src/admin/api/accounts/import.rs b/src/admin/api/accounts/import.rs index a28223860..b087d735c 100644 --- a/src/admin/api/accounts/import.rs +++ b/src/admin/api/accounts/import.rs @@ -15,7 +15,7 @@ use crate::{ }, platform::http::request_id::RequestId, runtime::state::AppState, - utils::json::{first_string, string_at}, + utils::json::first_string, }; use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse}; @@ -24,7 +24,6 @@ use super::validated_account_import_error; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AccountImportFormat { Native, - Sub2Api, CodexCli, } @@ -32,7 +31,6 @@ impl AccountImportFormat { fn as_str(self) -> &'static str { match self { Self::Native => "native", - Self::Sub2Api => "sub2api", Self::CodexCli => "codex_cli", } } @@ -208,227 +206,88 @@ fn store_import_account_error(error: StoreImportAccountError, request_id: &str) } fn parse_account_import_payload(payload: &Value) -> ParsedAccountImportPayload { - if let Some(accounts) = parse_sub2api_oauth_payload(payload) { - return ParsedAccountImportPayload { - accounts, - source_format: AccountImportFormat::Sub2Api, - }; - } - - parse_native_account_payload(payload) -} - -fn parse_sub2api_oauth_payload(payload: &Value) -> Option> { - let accounts = payload.get("accounts")?.as_array()?; - let looks_like_sub2api = string_at(payload, &["type"]).as_deref() == Some("sub2api-data") - || payload.get("proxies").is_some() - || accounts - .iter() - .any(|account| account.get("credentials").is_some()); - if !looks_like_sub2api { - return None; - } - - Some( - accounts - .iter() - .filter_map(sub2api_oauth_account_entry) - .collect(), - ) -} - -fn sub2api_oauth_account_entry(account: &Value) -> Option { - let platform = string_at(account, &["platform"])?.to_ascii_lowercase(); - let account_type = string_at(account, &["type"])?.to_ascii_lowercase(); - if platform != "openai" || account_type != "oauth" { - return None; - } - let credentials = account.get("credentials")?; - let fallback_label = normalized_label(string_at(account, &["name"])); - let mut entry = account_entry_from_value(credentials, fallback_label); - if entry.token.is_none() && entry.refresh_token.is_none() { - return None; - } - if entry.email.is_none() { - entry.email = string_at(credentials, &["email"]); - } - if entry.account_id.is_none() { - entry.account_id = first_string( - credentials, - &[&["chatgpt_account_id"], &["account_id"], &["accountId"]], - ); - } - if entry.user_id.is_none() { - entry.user_id = first_string( - credentials, - &[&["chatgpt_user_id"], &["user_id"], &["userId"]], - ); - } - if entry.plan_type.is_none() { - entry.plan_type = first_string(credentials, &[&["plan_type"], &["planType"]]); - } - Some(entry) -} - -fn parse_native_account_payload(payload: &Value) -> ParsedAccountImportPayload { - if let Some(accounts) = payload.as_array() { - return ParsedAccountImportPayload { - accounts: accounts - .iter() - .filter_map(|account| { - let entry = account_entry_from_value(account, None); - (entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry) - }) - .collect(), - source_format: AccountImportFormat::Native, - }; - } - - if let Some(accounts) = payload.get("accounts").and_then(Value::as_array) { - let source_format = if looks_like_sub2api_native_export(accounts) { - AccountImportFormat::Sub2Api - } else { - AccountImportFormat::Native - }; - return ParsedAccountImportPayload { - accounts: accounts - .iter() - .filter_map(|account| { - let entry = account_entry_from_value(account, None); - (entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry) - }) - .collect(), - source_format, - }; - } - - let entry = account_entry_from_value(payload, None); - let accounts = if entry.token.is_some() || entry.refresh_token.is_some() { - vec![entry] - } else { - Vec::new() - }; ParsedAccountImportPayload { - accounts, + accounts: parse_native_account_payload(payload), source_format: AccountImportFormat::Native, } } -fn looks_like_sub2api_native_export(accounts: &[Value]) -> bool { - accounts.iter().any(|account| { - // sub2api 兼容导出会携带代理/配额运行态字段;这里只用于格式识别,代理数据不进入 Rust 服务。 - account.get("proxyApiKey").is_some() - || account.get("cachedQuota").is_some() - || account.get("quotaVerifyRequired").is_some() - }) +fn parse_native_account_payload(payload: &Value) -> Vec { + if has_removed_import_container_field(payload) { + return Vec::new(); + } + let Some(accounts) = payload.get("accounts").and_then(Value::as_array) else { + return Vec::new(); + }; + accounts + .iter() + .filter_map(account_entry_from_value) + .collect() } -fn account_entry_from_value(value: &Value, fallback_label: Option) -> AccountImportEntry { - let token = first_string( - value, - &[ - &["token"], - &["accessToken"], - &["access_token"], - &["tokens", "accessToken"], - &["tokens", "access_token"], - &["credentials", "token"], - &["credentials", "accessToken"], - &["credentials", "access_token"], - ], - ) - .map(normalize_bearer_token); - let refresh_token = first_string( - value, - &[ - &["refreshToken"], - &["refresh_token"], - &["tokens", "refreshToken"], - &["tokens", "refresh_token"], - &["credentials", "refreshToken"], - &["credentials", "refresh_token"], - ], - ); - - AccountImportEntry { - id: first_string(value, &[&["id"]]), - email: first_string(value, &[&["email"], &["credentials", "email"]]), - account_id: first_string( - value, - &[ - &["accountId"], - &["account_id"], - &["chatgpt_account_id"], - &["credentials", "accountId"], - &["credentials", "account_id"], - &["credentials", "chatgpt_account_id"], - ], - ), - user_id: first_string( - value, - &[ - &["userId"], - &["user_id"], - &["chatgpt_user_id"], - &["credentials", "userId"], - &["credentials", "user_id"], - &["credentials", "chatgpt_user_id"], - ], - ), - label: label_from_value(value).or(fallback_label), - plan_type: first_string( - value, - &[ - &["planType"], - &["plan_type"], - &["credentials", "planType"], - &["credentials", "plan_type"], - ], - ), - token, - refresh_token, - access_token_expires_at: first_string( - value, - &[ - &["accessTokenExpiresAt"], - &["access_token_expires_at"], - &["credentials", "accessTokenExpiresAt"], - &["credentials", "access_token_expires_at"], - ], - ), - status: first_string(value, &[&["status"]]), +fn account_entry_from_value(value: &Value) -> Option { + if has_removed_import_account_field(value) { + return None; } + + let entry = AccountImportEntry { + id: first_string(value, &[&["id"]]), + email: first_string(value, &[&["email"]]), + account_id: first_string(value, &[&["accountId"]]), + user_id: first_string(value, &[&["userId"]]), + label: label_from_value(value), + plan_type: first_string(value, &[&["planType"]]), + token: first_string(value, &[&["token"]]), + refresh_token: first_string(value, &[&["refreshToken"]]), + access_token_expires_at: first_string(value, &[&["accessTokenExpiresAt"]]), + status: first_string(value, &[&["status"]]), + }; + (entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry) +} + +fn has_removed_import_container_field(value: &Value) -> bool { + ["proxies", "type", "exported_at"] + .iter() + .any(|field| value.get(*field).is_some()) +} + +fn has_removed_import_account_field(value: &Value) -> bool { + [ + "accessToken", + "access_token", + "refresh_token", + "tokens", + "credentials", + "account_id", + "user_id", + "chatgpt_account_id", + "chatgpt_user_id", + "plan_type", + "access_token_expires_at", + "expires_at", + "name", + "account_name", + "accountName", + "account_note", + "accountNote", + "note", + "proxyApiKey", + "cachedQuota", + "quotaVerifyRequired", + "usage", + ] + .iter() + .any(|field| value.get(*field).is_some()) } fn label_from_value(value: &Value) -> Option { - normalized_label(first_string( - value, - &[ - &["label"], - &["name"], - &["account_name"], - &["accountName"], - &["account_note"], - &["accountNote"], - &["note"], - ], - )) + normalized_label(first_string(value, &[&["label"]])) } fn normalized_label(value: Option) -> Option { value.map(|label| label.chars().take(64).collect()) } -fn normalize_bearer_token(value: String) -> String { - let trimmed = value.trim(); - trimmed - .strip_prefix("Bearer ") - .or_else(|| trimmed.strip_prefix("bearer ")) - .unwrap_or(trimmed) - .trim() - .to_string() -} - fn empty_to_none(value: Option) -> Option { value .map(|value| value.trim().to_string()) diff --git a/src/admin/api/router.rs b/src/admin/api/router.rs index e055623f2..7e1a69216 100644 --- a/src/admin/api/router.rs +++ b/src/admin/api/router.rs @@ -33,7 +33,6 @@ pub fn router() -> Router { .route("/api/admin/auth/logout", post(auth_logout)) .route("/api/admin/auth/login-start", post(auth_login_start)) .route("/api/admin/auth/code-relay", post(auth_code_relay)) - .route("/api/admin/auth/callback", get(auth_callback)) .route("/auth/openai/callback", get(auth_callback)) .route("/api/admin/auth/device-login", post(auth_device_login)) .route( diff --git a/src/codex/serving/responses.rs b/src/codex/serving/responses.rs index d15687464..ef9cb372f 100644 --- a/src/codex/serving/responses.rs +++ b/src/codex/serving/responses.rs @@ -494,7 +494,6 @@ struct ResponsesBody { codex_window_id: Option, #[serde(rename = "parentThreadId")] parent_thread_id: Option, - #[serde(alias = "useWebSocket")] use_websocket: Option, } diff --git a/src/platform/storage/db.rs b/src/platform/storage/db.rs index aecdc5931..da24dd544 100644 --- a/src/platform/storage/db.rs +++ b/src/platform/storage/db.rs @@ -1,6 +1,6 @@ use std::{fs, path::Path, str::FromStr, time::Duration}; -use sqlx::{sqlite::SqliteConnectOptions, Row, SqlitePool}; +use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; pub async fn connect_sqlite(database_url: &str) -> Result { ensure_sqlite_parent_dir(database_url)?; @@ -20,77 +20,9 @@ pub async fn connect_sqlite(database_url: &str) -> Result Result<(), sqlx::Error> { let schema = include_str!("schema.sql"); sqlx::raw_sql(schema).execute(pool).await?; - ensure_account_usage_schema(pool).await?; - ensure_session_affinity_schema(pool).await?; Ok(()) } -async fn ensure_account_usage_schema(pool: &SqlitePool) -> Result<(), sqlx::Error> { - let rows = sqlx::query("pragma table_info(account_usage)") - .fetch_all(pool) - .await?; - for (name, sql) in ACCOUNT_USAGE_ADDED_COLUMNS { - let exists = rows.iter().any(|row| row.get::("name") == *name); - if !exists { - sqlx::query(*sql).execute(pool).await?; - } - } - Ok(()) -} - -const ACCOUNT_USAGE_ADDED_COLUMNS: &[(&str, &str)] = &[ - ( - "empty_response_count", - "alter table account_usage add column empty_response_count integer not null default 0", - ), - ( - "window_request_count", - "alter table account_usage add column window_request_count integer not null default 0", - ), - ( - "window_input_tokens", - "alter table account_usage add column window_input_tokens integer not null default 0", - ), - ( - "window_output_tokens", - "alter table account_usage add column window_output_tokens integer not null default 0", - ), - ( - "window_cached_tokens", - "alter table account_usage add column window_cached_tokens integer not null default 0", - ), - ( - "window_started_at", - "alter table account_usage add column window_started_at text", - ), - ( - "window_reset_at", - "alter table account_usage add column window_reset_at text", - ), - ( - "limit_window_seconds", - "alter table account_usage add column limit_window_seconds integer", - ), -]; - -async fn ensure_session_affinity_schema(pool: &SqlitePool) -> Result<(), sqlx::Error> { - let rows = sqlx::query("pragma table_info(session_affinities)") - .fetch_all(pool) - .await?; - for (name, sql) in SESSION_AFFINITY_ADDED_COLUMNS { - let exists = rows.iter().any(|row| row.get::("name") == *name); - if !exists { - sqlx::query(*sql).execute(pool).await?; - } - } - Ok(()) -} - -const SESSION_AFFINITY_ADDED_COLUMNS: &[(&str, &str)] = &[( - "function_call_ids_json", - "alter table session_affinities add column function_call_ids_json text not null default '[]'", -)]; - fn ensure_sqlite_parent_dir(database_url: &str) -> Result<(), sqlx::Error> { let Some(path) = sqlite_file_path(database_url) else { return Ok(()); diff --git a/tests/admin/accounts/cookies_quota.rs b/tests/admin/accounts/cookies_quota.rs index 2a99817ef..ef270c0d0 100644 --- a/tests/admin/accounts/cookies_quota.rs +++ b/tests/admin/accounts/cookies_quota.rs @@ -552,3 +552,26 @@ async fn admin_accounts_health_check_should_probe_backend_and_mark_invalid_accou .unwrap(); assert_eq!(status.0, "expired"); } + +#[tokio::test] +async fn admin_accounts_health_check_should_reject_removed_stagger_ms_field() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-account-health-removed-field.sqlite", 32).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); +} diff --git a/tests/admin/accounts/import_export.rs b/tests/admin/accounts/import_export.rs index 07814445b..d16fefba0 100644 --- a/tests/admin/accounts/import_export.rs +++ b/tests/admin/accounts/import_export.rs @@ -135,7 +135,7 @@ async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_ } #[tokio::test] -async fn admin_accounts_import_should_accept_sub2api_oauth_export_and_mark_format() { +async fn admin_accounts_import_should_reject_sub2api_oauth_export() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("admin-sub2api.sqlite"); let url = format!("sqlite://{}", db.display()); @@ -191,25 +191,18 @@ async fn admin_accounts_import_should_accept_sub2api_oauth_export_and_mark_forma .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response_json(response).await; - assert_eq!(body["data"]["sourceFormat"], "sub2api"); - assert_eq!(body["data"]["imported"], 1); - assert_eq!(body["data"]["skipped"], 0); - let stored: (String, String, String, String, String) = - sqlx::query_as("select email, account_id, user_id, label, plan_type from accounts") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(stored.0, "team@example.com"); - assert_eq!(stored.1, "chatgpt-account"); - assert_eq!(stored.2, "chatgpt-user"); - assert_eq!(stored.3, "Sub2API Team"); - assert_eq!(stored.4, "team"); + assert_eq!(body["message"], "No importable accounts found"); + let stored: (i64,) = sqlx::query_as("select count(*) from accounts") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored.0, 0); } #[tokio::test] -async fn admin_accounts_import_should_accept_sub2api_native_account_export_without_proxy_data() { +async fn admin_accounts_import_should_reject_native_payload_with_removed_proxy_fields() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("admin-sub2api-native.sqlite"); let url = format!("sqlite://{}", db.display()); @@ -244,7 +237,6 @@ async fn admin_accounts_import_should_accept_sub2api_native_account_export_witho }); let import_response = app - .clone() .oneshot( Request::builder() .method("POST") @@ -258,28 +250,9 @@ async fn admin_accounts_import_should_accept_sub2api_native_account_export_witho .await .unwrap(); - assert_eq!(import_response.status(), StatusCode::OK); + assert_eq!(import_response.status(), StatusCode::BAD_REQUEST); let body = response_json(import_response).await; - assert_eq!(body["data"]["sourceFormat"], "sub2api"); - assert_eq!(body["data"]["imported"], 1); - - 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_sub2api_native_list") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let list = response_json(list_response).await; - assert_eq!(list["data"][0]["id"], "acct_sub2api_native"); - assert_eq!(list["data"][0]["planType"], "plus"); - assert!(list["data"][0].get("proxyApiKey").is_none()); - assert!(list["data"][0].get("usage").is_none()); + assert_eq!(body["message"], "No importable accounts found"); } #[tokio::test] @@ -328,25 +301,9 @@ async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_fil } #[tokio::test] -async fn admin_accounts_export_should_return_sub2api_openai_oauth_payload_without_proxy_fields() { +async fn admin_accounts_export_should_reject_removed_sub2api_format() { let (app, _state, _pool, _dir) = admin_accounts_test_app("admin-account-export-sub2api.sqlite", 25).await; - import_test_account(&app, "session_1", "acct_export_sub2api").await; - - let label_response = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/api/admin/accounts/acct_export_sub2api/label") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .body(Body::from(r#"{"label":"Sub2API Export"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(label_response.status(), StatusCode::OK); let response = app .oneshot( @@ -359,33 +316,26 @@ async fn admin_accounts_export_should_return_sub2api_openai_oauth_payload_withou ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_json(response).await; - assert_eq!(body["data"]["type"], "sub2api-data"); - assert_eq!(body["data"]["version"], 1); - assert_eq!(body["data"]["proxies"], json!([])); - assert_eq!(body["data"]["accounts"].as_array().unwrap().len(), 1); - assert_eq!(body["data"]["accounts"][0]["name"], "Sub2API Export"); - assert_eq!(body["data"]["accounts"][0]["platform"], "openai"); - assert_eq!(body["data"]["accounts"][0]["type"], "oauth"); - assert_eq!( - body["data"]["accounts"][0]["credentials"]["access_token"], - "access-acct_export_sub2api" - ); - assert_eq!( - body["data"]["accounts"][0]["credentials"]["refresh_token"], - "refresh-acct_export_sub2api" - ); - assert_eq!( - body["data"]["accounts"][0]["credentials"]["email"], - "acct_export_sub2api@example.com" - ); - assert_eq!( - body["data"]["accounts"][0]["credentials"]["plan_type"], - "plus" - ); - assert!(body["data"]["accounts"][0].get("proxy").is_none()); - assert!(body["data"]["accounts"][0].get("proxyUrl").is_none()); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_accounts_export_should_reject_removed_full_format_alias() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-account-export-full-alias.sqlite", 25).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] diff --git a/tests/admin/accounts/oauth.rs b/tests/admin/accounts/oauth.rs index 947bcec16..cf21a253a 100644 --- a/tests/admin/accounts/oauth.rs +++ b/tests/admin/accounts/oauth.rs @@ -607,3 +607,23 @@ async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host() .unwrap(); assert_eq!(stored.email.as_deref(), Some("callback@example.com")); } + +#[tokio::test] +async fn admin_auth_callback_should_not_expose_removed_admin_callback_alias() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-auth-callback-removed-alias.sqlite", 45).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/auth/callback?code=callback-code&state=removed") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/tests/codex_serving/responses_websocket.rs b/tests/codex_serving/responses_websocket.rs index 11c1651d7..bc81a7f39 100644 --- a/tests/codex_serving/responses_websocket.rs +++ b/tests/codex_serving/responses_websocket.rs @@ -106,6 +106,53 @@ async fn v1_responses_should_use_websocket_upstream_by_default_while_serving_sse server.await.unwrap(); } +#[tokio::test] +async fn v1_responses_should_ignore_removed_use_websocket_alias() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (request_tx, request_rx) = 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(); + let request = serde_json::from_str::(&message.into_text().unwrap()).unwrap(); + request_tx.send(request).unwrap(); + websocket + .send(Message::Text( + websocket_completed_response("resp_route_ws_removed_alias", 6, 2).into(), + )) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let imported = build_imported_app(format!("http://{addr}")).await; + + let response = imported + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header( + "authorization", + format!("Bearer {}", imported.client_api_key), + ) + .header("content-type", "application/json") + .body(Body::from( + r#"{"model":"gpt-5.5","input":[],"useWebSocket":false}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let request = request_rx.await.unwrap(); + assert_eq!(request["type"], "response.create"); + assert!(request.get("useWebSocket").is_none()); + server.await.unwrap(); +} + #[tokio::test] async fn v1_responses_websocket_should_stream_first_frame_before_terminal_event() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tests/platform/storage_schema.rs b/tests/platform/storage_schema.rs index c05ddc3e8..1f8db789a 100644 --- a/tests/platform/storage_schema.rs +++ b/tests/platform/storage_schema.rs @@ -1,3 +1,7 @@ +use std::str::FromStr; + +use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; + use codex_proxy_rs::platform::storage::db::connect_sqlite; #[tokio::test] @@ -61,6 +65,61 @@ async fn sqlite_schema_should_persist_session_affinity_function_call_ids() { assert_eq!(row, Some(("function_call_ids_json".to_string(),))); } +#[tokio::test] +async fn connect_sqlite_should_not_patch_legacy_tables() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("legacy.sqlite"); + let url = format!("sqlite://{}", db.display()); + let legacy_options = SqliteConnectOptions::from_str(&url) + .unwrap() + .create_if_missing(true); + let legacy_pool = SqlitePool::connect_with(legacy_options).await.unwrap(); + sqlx::raw_sql( + " + create table account_usage ( + account_id text primary key, + 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 session_affinities ( + response_id text primary key, + account_id text not null, + conversation_id text not null, + turn_state text, + instructions_hash text, + input_tokens integer, + variant_hash text, + expires_at text not null, + created_at text not null + ); + ", + ) + .execute(&legacy_pool) + .await + .unwrap(); + legacy_pool.close().await; + + let pool = connect_sqlite(&url).await.unwrap(); + let window_column: Option<(String,)> = sqlx::query_as( + "select name from pragma_table_info('account_usage') where name = 'window_request_count'", + ) + .fetch_optional(&pool) + .await + .unwrap(); + let affinity_column: Option<(String,)> = sqlx::query_as( + "select name from pragma_table_info('session_affinities') where name = 'function_call_ids_json'", + ) + .fetch_optional(&pool) + .await + .unwrap(); + + assert_eq!(window_column, None); + assert_eq!(affinity_column, None); +} + #[tokio::test] async fn sqlite_schema_should_reject_invalid_account_status() { let dir = tempfile::tempdir().unwrap();