mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor: remove legacy compatibility
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+43
-3
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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(());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user