refactor: remove legacy compatibility

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-14 20:04:08 +08:00
co-authored by Codex
parent f557cb17ba
commit bc8650c9cf
16 changed files with 331 additions and 464 deletions
+43 -3
View File
@@ -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"
}
+2 -2
View File
@@ -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. |
@@ -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.
@@ -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.
@@ -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
+6 -84
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AccountExportFormat {
Native,
Sub2Api,
}
pub async fn export_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -37,17 +30,8 @@ pub async fn export_accounts(
Query(query): Query<AccountExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
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<AccountExportFormat, &'static str> {
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<StoredAccount>) -> Value {
json!({
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
"proxies": [],
"accounts": accounts.into_iter().map(sub2api_export_account).collect::<Vec<_>>(),
"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<String, Value>, key: &str, value: Option<String>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
map.insert(key.to_string(), Value::String(value));
}
}
+22 -5
View File
@@ -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<Vec<String>>,
#[serde(alias = "stagger_ms")]
pub stagger_ms: Option<u64>,
pub concurrency: Option<u8>,
}
@@ -56,10 +56,10 @@ pub async fn health_check_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
payload: Option<Json<HealthCheckRequest>>,
body: Bytes,
) -> Result<impl IntoResponse, AdminError> {
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<HealthCheckRequest, AdminError> {
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(
+66 -207
View File
@@ -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<Vec<AccountImportEntry>> {
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<AccountImportEntry> {
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<AccountImportEntry> {
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<String>) -> 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<AccountImportEntry> {
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<String> {
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<String>) -> Option<String> {
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<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
-1
View File
@@ -33,7 +33,6 @@ pub fn router() -> Router<AppState> {
.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(
-1
View File
@@ -494,7 +494,6 @@ struct ResponsesBody {
codex_window_id: Option<String>,
#[serde(rename = "parentThreadId")]
parent_thread_id: Option<String>,
#[serde(alias = "useWebSocket")]
use_websocket: Option<bool>,
}
+1 -69
View File
@@ -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<SqlitePool, sqlx::Error> {
ensure_sqlite_parent_dir(database_url)?;
@@ -20,77 +20,9 @@ pub async fn connect_sqlite(database_url: &str) -> Result<SqlitePool, sqlx::Erro
async fn initialize_schema(pool: &SqlitePool) -> 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::<String, _>("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::<String, _>("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(());
+23
View File
@@ -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);
}
+32 -82
View File
@@ -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]
+20
View File
@@ -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);
}
@@ -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::<Value>(&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();
+59
View File
@@ -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();