mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-15 00:06:24 +00:00
feat: accept sub2api account imports
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -16,7 +16,7 @@ Update this file before each feature commit.
|
||||
| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, and `cargo test --test account_pool_scheduling_test`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, 401-triggered refresh and single retry for `/v1/responses`, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, and shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, and Chat Completions. Still missing durable release-time slot state, WebSocket/history fallback policy, durable quota cooldown persistence, and admin account CRUD. |
|
||||
| Account repository | Completed | `14fe706` | `cargo test --test account_repository_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, status/label updates, release-time usage counter persistence, and refresh-token-preserving token updates. |
|
||||
| Translation | Completed | `eff182e` + `b9c30ba` | `cargo test --test routes_chat_test`; `cargo test --test chat_completions_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` | Adds OpenAI Chat request types, expanded Codex Responses request type, OpenAI Chat to Codex translation for system/developer instructions, multimodal content, tool/function calls, response formats, reasoning effort, service tier, and parallel tool calls. Codex SSE now converts back to OpenAI chat completions and chat chunks with text deltas, reasoning content, tool calls, usage details, and OpenAI-compatible error bodies. |
|
||||
| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/admin/api-keys`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, full admin API-key mutation/export/import routes, settings mutation/persistence, diagnostics, live backend model refresh, live Chat streaming transform, and WebSocket/history fallback policy. |
|
||||
| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + pending | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; Task 7 import targeted tests and real-file smoke before pending | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/admin/api-keys`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, full admin API-key mutation/export/import routes, settings mutation/persistence, diagnostics, live backend model refresh, live Chat streaming transform, and WebSocket/history fallback policy. |
|
||||
| Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. |
|
||||
| HTTP auth boundary | Completed | `cec4607` + `e9fdea0` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only enabled local `Bearer cpr_...` client API keys verified from SQLite, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. |
|
||||
| Model catalog | Partial | `6341336` + `1738f24` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models`; full `fmt/test/clippy` before `1738f24` | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing including `none` and `minimal`, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, debug summary, SQLite-backed backend model snapshots by plan, cached backend catalog merge, and model-to-plan allowlist derivation. Still missing live backend Codex model fetching per plan, `/admin/refresh-models`, runtime pool allowlist refresh from fetched models, and full model metadata parity. |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**Tech Stack:** Rust, Axum, Reqwest/rustls, serde, sqlx/SQLite, wiremock, tokio, futures, optional WebSocket transport dependency.
|
||||
|
||||
**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Remaining major work continues with WebSocket/history fallback policy, live model refresh, and scoped admin operations.
|
||||
**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Pending Task 7 import work accepts Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Remaining major work continues with WebSocket/history fallback policy, live model refresh, and scoped admin operations.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,7 +110,10 @@
|
||||
- Test: `tests/admin_accounts_route_test.rs`
|
||||
- Test: `tests/admin_api_keys_route_test.rs`
|
||||
|
||||
- [ ] Write failing tests for manual add, delete, batch status, label, quota fetch, health check, cookie get/set/delete, import/export, API key disable/delete.
|
||||
- [x] Write failing tests for Sub2API OpenAI OAuth import payloads and native exports 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.
|
||||
- [ ] Write failing tests for manual add, delete, batch status, label, quota fetch, health check, cookie get/set/delete, remaining import/export, API key disable/delete.
|
||||
- [ ] Run: `cargo test --test admin_accounts_route_test --test admin_api_keys_route_test`
|
||||
- [ ] Implement scoped admin operations needed to operate Codex-backed OpenAI GPT routes.
|
||||
- [ ] Run: `cargo test --test admin_accounts_route_test --test admin_api_keys_route_test`
|
||||
|
||||
@@ -18,7 +18,7 @@ OpenAI Chat compatibility is implemented as a real route, not an alias to Respon
|
||||
|
||||
Responses compatibility keeps native Responses passthrough semantics but adds the TS implementation's in-scope fields: reasoning/service tier, tool choice, parallel tool calls, text formats, prompt cache key, include, client metadata, and Codex context headers. `previous_response_id` uses WebSocket transport rather than being rejected.
|
||||
|
||||
Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Commit `3ecd5b6` partially implements Task 5 for non-streaming `/v1/responses`: upstream 429/402/403 responses classify account state and retry another imported account when available. Commit `3960a35` extends that fallback to HTTP SSE Responses setup and Chat Completions. Commit `1738f24` adds Task 6 cache groundwork: backend model snapshots are stored by account plan and `/v1/models*` reads cached backend models when present. WebSocket-backed `previous_response_id` fallback remains a Task 5 follow-up because account affinity must be preserved.
|
||||
Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Commit `3ecd5b6` partially implements Task 5 for non-streaming `/v1/responses`: upstream 429/402/403 responses classify account state and retry another imported account when available. Commit `3960a35` extends that fallback to HTTP SSE Responses setup and Chat Completions. Commit `1738f24` adds Task 6 cache groundwork: backend model snapshots are stored by account plan and `/v1/models*` reads cached backend models when present. Pending Task 7 import work accepts Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. WebSocket-backed `previous_response_id` fallback remains a Task 5 follow-up because account affinity must be preserved.
|
||||
|
||||
## Components
|
||||
|
||||
@@ -30,7 +30,7 @@ Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Comm
|
||||
- `src/http/v1.rs`: Separate `/v1/responses` and `/v1/chat/completions` handlers sharing upstream lifecycle helpers.
|
||||
- `src/accounts/pool.rs` and repositories: fallback selection, quota/rate-limit updates, durable usage release, and account state mutation hooks.
|
||||
- `src/models/catalog.rs`: static catalog plus persisted backend snapshots, plan allowlist derivation, and admin refresh trigger.
|
||||
- `src/http/admin.rs`: scoped account/API-key/account-health/quota/cookie/admin model operations needed to operate Codex-backed GPT compatibility.
|
||||
- `src/http/admin.rs`: 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.
|
||||
|
||||
## Data Flow
|
||||
|
||||
|
||||
+268
-14
@@ -10,6 +10,7 @@ use axum::{
|
||||
use chrono::{Duration, Utc};
|
||||
use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -183,22 +184,36 @@ pub struct AdminSettingsData {
|
||||
pub usage_history_retention_days: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountImportPayload {
|
||||
pub accounts: Vec<AccountImportEntry>,
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AccountImportFormat {
|
||||
Native,
|
||||
Sub2Api,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountImportEntry {
|
||||
impl AccountImportFormat {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => "native",
|
||||
Self::Sub2Api => "sub2api",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedAccountImportPayload {
|
||||
accounts: Vec<AccountImportEntry>,
|
||||
source_format: AccountImportFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AccountImportEntry {
|
||||
pub id: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub token: String,
|
||||
pub token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
@@ -208,6 +223,7 @@ pub struct AccountImportEntry {
|
||||
pub struct AccountImportData {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
pub source_format: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -700,7 +716,7 @@ pub async fn import_accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AccountImportPayload>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Response {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
let Some(pool) = state.db() else {
|
||||
@@ -725,7 +741,8 @@ pub async fn import_accounts(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
if payload.accounts.is_empty() {
|
||||
let parsed = parse_account_import_payload(&payload);
|
||||
if parsed.accounts.is_empty() {
|
||||
return AdminResponse::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
AdminEnvelope::new(40001, "No importable accounts found", (), request_id),
|
||||
@@ -735,8 +752,8 @@ pub async fn import_accounts(
|
||||
|
||||
let mut imported = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
for entry in payload.accounts {
|
||||
let token = entry.token.trim();
|
||||
for entry in parsed.accounts {
|
||||
let token = entry.token.as_deref().unwrap_or_default().trim();
|
||||
if token.is_empty() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
@@ -816,7 +833,14 @@ pub async fn import_accounts(
|
||||
|
||||
AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountImportData { imported, skipped }, request_id),
|
||||
AdminEnvelope::ok(
|
||||
AccountImportData {
|
||||
imported,
|
||||
skipped,
|
||||
source_format: parsed.source_format.as_str().to_string(),
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -1024,6 +1048,235 @@ fn normalized_account_id(id: Option<String>) -> String {
|
||||
.unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple()))
|
||||
}
|
||||
|
||||
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,
|
||||
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 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,
|
||||
status: first_string(value, &[&["status"]]),
|
||||
}
|
||||
}
|
||||
|
||||
fn label_from_value(value: &Value) -> Option<String> {
|
||||
normalized_label(first_string(
|
||||
value,
|
||||
&[
|
||||
&["label"],
|
||||
&["name"],
|
||||
&["account_name"],
|
||||
&["accountName"],
|
||||
&["account_note"],
|
||||
&["accountNote"],
|
||||
&["note"],
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
fn normalized_label(value: Option<String>) -> Option<String> {
|
||||
value.map(|label| label.chars().take(64).collect())
|
||||
}
|
||||
|
||||
fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
|
||||
paths.iter().find_map(|path| string_at(value, path))
|
||||
}
|
||||
|
||||
fn string_at(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -1031,7 +1284,8 @@ fn empty_to_none(value: Option<String>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn parse_import_status(status: Option<&str>) -> Result<AccountStatus, String> {
|
||||
match status.unwrap_or("active") {
|
||||
let normalized = status.unwrap_or("active").trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"active" => Ok(AccountStatus::Active),
|
||||
"expired" => Ok(AccountStatus::Expired),
|
||||
"quota_exhausted" => Ok(AccountStatus::QuotaExhausted),
|
||||
|
||||
@@ -187,6 +187,154 @@ async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_
|
||||
assert_eq!(body["page"]["limit"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_import_should_accept_sub2api_oauth_export_and_mark_format() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("admin-sub2api.sqlite");
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
let pool = connect_sqlite(&url).await.unwrap();
|
||||
seed_admin_session(&pool, "session_1").await;
|
||||
let app = build_router(AppState::with_pool_and_secret_box(
|
||||
test_config(url),
|
||||
pool.clone(),
|
||||
SecretBox::new([14u8; 32]),
|
||||
));
|
||||
let import_body = json!({
|
||||
"type": "sub2api-data",
|
||||
"version": 1,
|
||||
"proxies": [],
|
||||
"accounts": [
|
||||
{
|
||||
"name": "Sub2API Team",
|
||||
"platform": "openai",
|
||||
"type": "oauth",
|
||||
"credentials": {
|
||||
"access_token": "Bearer sub2api-access-secret",
|
||||
"refresh_token": "rt_sub2api",
|
||||
"email": "team@example.com",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
"chatgpt_user_id": "chatgpt-user",
|
||||
"plan_type": "team"
|
||||
},
|
||||
"concurrency": 0,
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "Other Provider",
|
||||
"platform": "anthropic",
|
||||
"type": "oauth",
|
||||
"credentials": {
|
||||
"access_token": "ignored-secret"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/accounts/import")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_sub2api")
|
||||
.body(Body::from(import_body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_import_should_accept_sub2api_native_account_export_without_proxy_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("admin-sub2api-native.sqlite");
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
let pool = connect_sqlite(&url).await.unwrap();
|
||||
seed_admin_session(&pool, "session_1").await;
|
||||
let app = build_router(AppState::with_pool_and_secret_box(
|
||||
test_config(url),
|
||||
pool,
|
||||
SecretBox::new([15u8; 32]),
|
||||
));
|
||||
let import_body = json!({
|
||||
"accounts": [{
|
||||
"id": "acct_sub2api_native",
|
||||
"token": "native-access-secret",
|
||||
"refreshToken": "native-refresh-secret",
|
||||
"email": "native@example.com",
|
||||
"accountId": "native-account",
|
||||
"userId": "native-user",
|
||||
"label": "Native Sub2API",
|
||||
"planType": "plus",
|
||||
"proxyApiKey": "ignored-proxy-secret",
|
||||
"status": "active",
|
||||
"usage": {
|
||||
"request_count": 1,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"cachedQuota": null,
|
||||
"quotaVerifyRequired": false
|
||||
}]
|
||||
});
|
||||
|
||||
let import_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/accounts/import")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_sub2api_native")
|
||||
.body(Body::from(import_body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(import_response.status(), StatusCode::OK);
|
||||
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("/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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_not_decrypt_account_tokens() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user