diff --git a/config.yaml b/config.yaml index 9e094018b..4aad6f84e 100644 --- a/config.yaml +++ b/config.yaml @@ -42,6 +42,50 @@ ws_pool: enabled: true max_age_ms: 3300000 max_per_account: 8 +fingerprint: + originator: Codex Desktop + app_version: 26.519.81530 + build_number: "3178" + platform: darwin + arch: arm64 + chromium_version: "146" + user_agent_template: "Codex Desktop/{version} ({platform}; {arch})" + default_headers: + - name: Accept-Encoding + value: gzip, deflate, br, zstd + - name: Accept-Language + value: en-US,en;q=0.9 + - name: sec-ch-ua-mobile + value: "?0" + - name: sec-ch-ua-platform + value: "\"macOS\"" + - name: sec-fetch-site + value: same-origin + - name: sec-fetch-mode + value: cors + - name: sec-fetch-dest + value: empty + header_order: + - authorization + - chatgpt-account-id + - originator + - x-openai-internal-codex-residency + - x-client-request-id + - x-codex-installation-id + - x-codex-turn-state + - openai-beta + - user-agent + - sec-ch-ua + - sec-ch-ua-mobile + - sec-ch-ua-platform + - accept-encoding + - accept-language + - sec-fetch-site + - sec-fetch-mode + - sec-fetch-dest + - content-type + - accept + - cookie admin: session_ttl_minutes: 1440 default_username: admin diff --git a/crates/adapters/src/codex/client.rs b/crates/adapters/src/codex/client.rs index d199aaf9e..ec571ffd3 100644 --- a/crates/adapters/src/codex/client.rs +++ b/crates/adapters/src/codex/client.rs @@ -1215,14 +1215,12 @@ fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> { } fn websocket_header_pairs(headers: &HeaderMap) -> Vec<(String, String)> { - let pairs = header_pairs(headers) + header_pairs(headers) .into_iter() .filter(|(name, _)| { !name.eq_ignore_ascii_case("content-type") && !name.eq_ignore_ascii_case("accept") }) - .collect::>(); - - pairs + .collect::>() } fn websocket_exchange_error_to_client_error( diff --git a/crates/adapters/src/codex/fingerprint.rs b/crates/adapters/src/codex/fingerprint.rs index a3ef26b8f..656c67536 100644 --- a/crates/adapters/src/codex/fingerprint.rs +++ b/crates/adapters/src/codex/fingerprint.rs @@ -3,6 +3,7 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use chrono::{DateTime, Utc}; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use sqlx::{Row, SqlitePool}; use thiserror::Error; @@ -14,13 +15,11 @@ use codex_proxy_core::gateway::fingerprint::Fingerprint; /// 指纹历史记录的来源标识。 pub const CODEX_DESKTOP_UPDATE_SOURCE: &str = "codex_desktop_update_source"; +/// 运行时当前指纹槽位。 +pub const CURRENT_FINGERPRINT_ID: &str = "current"; -const AUTO_UPDATED_FINGERPRINT_ID: &str = "auto_updated"; const AUTO_UPDATE_SOURCE: &str = "auto_update"; -const AUTO_UPDATE_PLATFORM: &str = "darwin"; -const AUTO_UPDATE_ARCH: &str = "arm64"; -const AUTO_UPDATE_USER_AGENT_TEMPLATE: &str = "Codex Desktop/{version} ({platform}; {arch})"; -const DEFAULT_AUTO_UPDATE_CHROMIUM_VERSION: &str = "146"; +const CONFIG_SEED_SOURCE: &str = "config_seed"; const APPCAST_TIMEOUT: Duration = Duration::from_secs(30); const POLL_INTERVAL: Duration = Duration::from_secs(3 * 24 * 60 * 60); @@ -99,6 +98,12 @@ pub struct StoredFingerprint { pub source: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredHeader { + name: String, + value: String, +} + /// 指纹自动更新状态。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateState { @@ -132,20 +137,13 @@ impl FingerprintRepository { /// 插入一条指纹更新历史。 pub async fn insert_update(&self, update: &FingerprintUpdate) -> Result<(), sqlx::Error> { - let mut fingerprint = Fingerprint::default_codex_desktop(); - fingerprint.app_version.clone_from(&update.app_version); - fingerprint.build_number.clone_from(&update.build_number); - sqlx::query( - "insert into fingerprints (id, app_version, build_number, platform, arch, chromium_version, user_agent_template, source, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into fingerprint_update_history (id, current_fingerprint_id, app_version, build_number, source, created_at) values (?, ?, ?, ?, ?, ?)", ) .bind(Uuid::new_v4().to_string()) - .bind(fingerprint.app_version) - .bind(fingerprint.build_number) - .bind(fingerprint.platform) - .bind(fingerprint.arch) - .bind(fingerprint.chromium_version) - .bind(fingerprint.user_agent_template) + .bind(CURRENT_FINGERPRINT_ID) + .bind(&update.app_version) + .bind(&update.build_number) .bind(CODEX_DESKTOP_UPDATE_SOURCE) .bind(Utc::now().to_rfc3339()) .execute(&self.pool) @@ -153,8 +151,58 @@ impl FingerprintRepository { Ok(()) } - /// 更新自动应用的最新版本快照。 - pub async fn upsert_auto_update( + /// 写入当前指纹默认值;如果当前槽位已存在,直接读取数据库值。 + pub async fn ensure_current_seed( + &self, + default_fingerprint: &Fingerprint, + ) -> Result { + if let Some(fingerprint) = self.load_current().await? { + return Ok(fingerprint); + } + + let now = Utc::now().to_rfc3339(); + sqlx::query( + r#" + insert into fingerprints ( + id, + originator, + app_version, + build_number, + platform, + arch, + chromium_version, + user_agent_template, + default_headers_json, + header_order_json, + source, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(CURRENT_FINGERPRINT_ID) + .bind(&default_fingerprint.originator) + .bind(&default_fingerprint.app_version) + .bind(&default_fingerprint.build_number) + .bind(&default_fingerprint.platform) + .bind(&default_fingerprint.arch) + .bind(&default_fingerprint.chromium_version) + .bind(&default_fingerprint.user_agent_template) + .bind(encode_default_headers( + &default_fingerprint.default_headers, + )?) + .bind(encode_header_order(&default_fingerprint.header_order)?) + .bind(CONFIG_SEED_SOURCE) + .bind(&now) + .bind(now) + .execute(&self.pool) + .await?; + + Ok(default_fingerprint.clone()) + } + + /// 更新当前指纹中的自动更新版本字段。 + pub async fn update_current_version( &self, app_version: &str, build_number: &str, @@ -162,28 +210,77 @@ impl FingerprintRepository { ) -> Result<(), sqlx::Error> { let resolved_chromium_version = match chromium_version { Some(version) => version.to_string(), - None => sqlx::query_scalar::<_, String>( - "select chromium_version from fingerprints where id = ?", - ) - .bind(AUTO_UPDATED_FINGERPRINT_ID) - .fetch_optional(&self.pool) - .await? - .unwrap_or_else(|| DEFAULT_AUTO_UPDATE_CHROMIUM_VERSION.to_string()), + None => { + sqlx::query_scalar::<_, String>( + "select chromium_version from fingerprints where id = ?", + ) + .bind(CURRENT_FINGERPRINT_ID) + .fetch_one(&self.pool) + .await? + } }; - sqlx::query( - "insert into fingerprints (id, app_version, build_number, platform, arch, chromium_version, user_agent_template, source, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?) \ - on conflict(id) do update set app_version = excluded.app_version, build_number = excluded.build_number, platform = excluded.platform, arch = excluded.arch, chromium_version = excluded.chromium_version, user_agent_template = excluded.user_agent_template, source = excluded.source, created_at = excluded.created_at", + let result = sqlx::query( + "update fingerprints set app_version = ?, build_number = ?, chromium_version = ?, source = ?, updated_at = ? where id = ?", ) - .bind(AUTO_UPDATED_FINGERPRINT_ID) .bind(app_version) .bind(build_number) - .bind(AUTO_UPDATE_PLATFORM) - .bind(AUTO_UPDATE_ARCH) .bind(resolved_chromium_version) - .bind(AUTO_UPDATE_USER_AGENT_TEMPLATE) .bind(AUTO_UPDATE_SOURCE) .bind(Utc::now().to_rfc3339()) + .bind(CURRENT_FINGERPRINT_ID) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + Ok(()) + } + + /// 读取当前运行时指纹。 + pub async fn load_current(&self) -> Result, sqlx::Error> { + let row = sqlx::query( + r#" + select + originator, + app_version, + build_number, + platform, + arch, + chromium_version, + user_agent_template, + default_headers_json, + header_order_json + from fingerprints + where id = ? + "#, + ) + .bind(CURRENT_FINGERPRINT_ID) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| fingerprint_from_row(&row)).transpose() + } + + /// 记录自动更新历史。 + pub async fn insert_update_history( + &self, + app_version: &str, + build_number: &str, + chromium_version: Option<&str>, + manifest_json: Option<&str>, + ) -> Result<(), sqlx::Error> { + sqlx::query( + "insert into fingerprint_update_history (id, current_fingerprint_id, app_version, build_number, chromium_version, source, manifest_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(CURRENT_FINGERPRINT_ID) + .bind(app_version) + .bind(build_number) + .bind(chromium_version) + .bind(AUTO_UPDATE_SOURCE) + .bind(manifest_json) + .bind(Utc::now().to_rfc3339()) .execute(&self.pool) .await?; Ok(()) @@ -192,7 +289,7 @@ impl FingerprintRepository { /// 读取最新一条指纹历史记录。 pub async fn latest(&self) -> Result, sqlx::Error> { let row = sqlx::query( - "select app_version, build_number, source from fingerprints order by created_at desc, id desc limit 1", + "select app_version, build_number, source from fingerprint_update_history order by created_at desc, id desc limit 1", ) .fetch_optional(&self.pool) .await?; @@ -203,38 +300,53 @@ impl FingerprintRepository { source: row.get("source"), })) } +} - /// 读取自动更新应用后的最新指纹。 - pub async fn load_latest_auto_updated(&self) -> Result, sqlx::Error> { - let row = sqlx::query( - r#" - select app_version, build_number, platform, arch, chromium_version, user_agent_template - from fingerprints - where source = ? - order by created_at desc - limit 1 - "#, - ) - .bind(AUTO_UPDATE_SOURCE) - .fetch_optional(&self.pool) - .await?; +fn fingerprint_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let default_headers_json = row.get::("default_headers_json"); + let header_order_json = row.get::("header_order_json"); + Ok(Fingerprint { + originator: row.get("originator"), + app_version: row.get("app_version"), + build_number: row.get("build_number"), + platform: row.get("platform"), + arch: row.get("arch"), + chromium_version: row.get("chromium_version"), + user_agent_template: row.get("user_agent_template"), + default_headers: decode_default_headers(&default_headers_json)?, + header_order: decode_header_order(&header_order_json)?, + }) +} - let Some(row) = row else { - return Ok(None); - }; +fn encode_default_headers(headers: &IndexMap) -> Result { + let headers = headers + .iter() + .map(|(name, value)| StoredHeader { + name: name.clone(), + value: value.clone(), + }) + .collect::>(); + serde_json::to_string(&headers).map_err(json_error) +} - Ok(Some(Fingerprint { - originator: "Codex Desktop".to_string(), - app_version: row.get("app_version"), - build_number: row.get("build_number"), - platform: row.get("platform"), - arch: row.get("arch"), - chromium_version: row.get("chromium_version"), - user_agent_template: row.get("user_agent_template"), - default_headers: Fingerprint::default_headers(), - header_order: Fingerprint::default_header_order(), - })) - } +fn decode_default_headers(value: &str) -> Result, sqlx::Error> { + let headers = serde_json::from_str::>(value).map_err(json_error)?; + Ok(headers + .into_iter() + .map(|header| (header.name, header.value)) + .collect()) +} + +fn encode_header_order(header_order: &[String]) -> Result { + serde_json::to_string(header_order).map_err(json_error) +} + +fn decode_header_order(value: &str) -> Result, sqlx::Error> { + serde_json::from_str(value).map_err(json_error) +} + +fn json_error(error: serde_json::Error) -> sqlx::Error { + sqlx::Error::Decode(Box::new(error)) } /// 通过 HTTP 拉取更新清单并写入历史记录。 @@ -420,7 +532,10 @@ impl UpdateChecker { if let Some(repository) = &self.repository { repository - .upsert_auto_update(version, build, chromium_version.as_deref()) + .update_current_version(version, build, chromium_version.as_deref()) + .await?; + repository + .insert_update_history(version, build, chromium_version.as_deref(), None) .await?; } diff --git a/crates/adapters/src/sqlite/accounts.rs b/crates/adapters/src/sqlite/accounts.rs index 9c8aa4d07..a87c3a61a 100644 --- a/crates/adapters/src/sqlite/accounts.rs +++ b/crates/adapters/src/sqlite/accounts.rs @@ -20,8 +20,8 @@ const INSERT_ACCOUNT_SQL: &str = r" insert into accounts ( id, email, - account_id, - user_id, + chatgpt_account_id, + chatgpt_user_id, label, plan_type, access_token_cipher, @@ -36,8 +36,8 @@ const SELECT_STORED_ACCOUNT_BY_ID_SQL: &str = r" select id, email, - account_id, - user_id, + chatgpt_account_id as account_id, + chatgpt_user_id as user_id, label, plan_type, access_token_cipher, @@ -54,8 +54,8 @@ const SELECT_STORED_ACCOUNT_BY_CHATGPT_IDENTITY_SQL: &str = r" select id, email, - account_id, - user_id, + chatgpt_account_id as account_id, + chatgpt_user_id as user_id, label, plan_type, access_token_cipher, @@ -66,8 +66,8 @@ select added_at, updated_at from accounts -where account_id = ? - and ((user_id is null and ? is null) or user_id = ?) +where chatgpt_account_id = ? + and ((chatgpt_user_id is null and ? is null) or chatgpt_user_id = ?) order by added_at asc limit 1"; @@ -75,8 +75,8 @@ const LIST_STORED_ACCOUNTS_AFTER_CURSOR_SQL: &str = r" select id, email, - account_id, - user_id, + chatgpt_account_id as account_id, + chatgpt_user_id as user_id, label, plan_type, access_token_cipher, @@ -96,8 +96,8 @@ const LIST_STORED_ACCOUNTS_SQL: &str = r" select id, email, - account_id, - user_id, + chatgpt_account_id as account_id, + chatgpt_user_id as user_id, label, plan_type, access_token_cipher, @@ -115,8 +115,8 @@ const LIST_ACCOUNT_METADATA_AFTER_CURSOR_SQL: &str = r" select id, email, - account_id, - user_id, + chatgpt_account_id as account_id, + chatgpt_user_id as user_id, label, plan_type, access_token_expires_at, @@ -133,8 +133,8 @@ const LIST_ACCOUNT_METADATA_SQL: &str = r" select id, email, - account_id, - user_id, + chatgpt_account_id as account_id, + chatgpt_user_id as user_id, label, plan_type, access_token_expires_at, @@ -149,8 +149,8 @@ const LIST_POOL_ACCOUNTS_SQL: &str = r" select a.id, a.email, - a.account_id, - a.user_id, + a.chatgpt_account_id as account_id, + a.chatgpt_user_id as user_id, a.label, a.plan_type, a.access_token_cipher, @@ -189,8 +189,8 @@ const GET_POOL_ACCOUNT_SQL: &str = r" select a.id, a.email, - a.account_id, - a.user_id, + a.chatgpt_account_id as account_id, + a.chatgpt_user_id as user_id, a.label, a.plan_type, a.access_token_cipher, @@ -233,6 +233,8 @@ insert into account_usage ( input_tokens, output_tokens, cached_tokens, + reasoning_tokens, + total_tokens, image_input_tokens, image_output_tokens, image_request_count, @@ -246,13 +248,15 @@ insert into account_usage ( window_image_request_count, window_image_request_failed_count, last_used_at -) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(account_id) do update set request_count = request_count + excluded.request_count, empty_response_count = empty_response_count + excluded.empty_response_count, input_tokens = input_tokens + excluded.input_tokens, output_tokens = output_tokens + excluded.output_tokens, cached_tokens = cached_tokens + excluded.cached_tokens, + reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens, + total_tokens = total_tokens + excluded.total_tokens, image_input_tokens = image_input_tokens + excluded.image_input_tokens, image_output_tokens = image_output_tokens + excluded.image_output_tokens, image_request_count = image_request_count + excluded.image_request_count, @@ -281,6 +285,8 @@ select input_tokens, output_tokens, cached_tokens, + reasoning_tokens, + total_tokens, image_input_tokens, image_output_tokens, image_request_count, @@ -311,6 +317,8 @@ select au.input_tokens, au.output_tokens, au.cached_tokens, + au.reasoning_tokens, + au.total_tokens, au.image_input_tokens, au.image_output_tokens, au.image_request_count, @@ -334,6 +342,8 @@ select au.input_tokens, au.output_tokens, au.cached_tokens, + au.reasoning_tokens, + au.total_tokens, au.image_input_tokens, au.image_output_tokens, au.image_request_count, @@ -352,6 +362,8 @@ select coalesce(sum(input_tokens), 0) as input_tokens, coalesce(sum(output_tokens), 0) as output_tokens, coalesce(sum(cached_tokens), 0) as cached_tokens, + coalesce(sum(reasoning_tokens), 0) as reasoning_tokens, + coalesce(sum(total_tokens), 0) as total_tokens, coalesce(sum(image_input_tokens), 0) as image_input_tokens, coalesce(sum(image_output_tokens), 0) as image_output_tokens, coalesce(sum(image_request_count), 0) as image_request_count, @@ -366,6 +378,8 @@ set input_tokens = 0, output_tokens = 0, cached_tokens = 0, + reasoning_tokens = 0, + total_tokens = 0, image_input_tokens = 0, image_output_tokens = 0, image_request_count = 0, @@ -477,8 +491,8 @@ const UPDATE_ACCOUNT_CLAIMS_WITH_REFRESH_SQL: &str = r" update accounts set email = ?, - account_id = ?, - user_id = ?, + chatgpt_account_id = ?, + chatgpt_user_id = ?, plan_type = ?, access_token_cipher = ?, refresh_token_cipher = ?, @@ -492,8 +506,8 @@ const UPDATE_ACCOUNT_CLAIMS_PRESERVING_REFRESH_SQL: &str = r" update accounts set email = ?, - account_id = ?, - user_id = ?, + chatgpt_account_id = ?, + chatgpt_user_id = ?, plan_type = ?, access_token_cipher = ?, access_token_expires_at = ?, @@ -513,8 +527,8 @@ const UPDATE_IMPORTED_ACCOUNT_WITH_REFRESH_SQL: &str = r" update accounts set email = ?, - account_id = ?, - user_id = ?, + chatgpt_account_id = ?, + chatgpt_user_id = ?, label = ?, plan_type = ?, access_token_cipher = ?, @@ -533,8 +547,8 @@ const UPDATE_IMPORTED_ACCOUNT_PRESERVING_REFRESH_SQL: &str = r" update accounts set email = ?, - account_id = ?, - user_id = ?, + chatgpt_account_id = ?, + chatgpt_user_id = ?, label = ?, plan_type = ?, access_token_cipher = ?, @@ -710,6 +724,10 @@ pub struct UsageDelta { pub output_tokens: i64, /// 缓存 token 增量。 pub cached_tokens: i64, + /// reasoning token 增量。 + pub reasoning_tokens: i64, + /// 上游返回的总 token 增量。 + pub total_tokens: i64, /// 图片输入 token 增量。 pub image_input_tokens: i64, /// 图片输出 token 增量。 @@ -729,6 +747,8 @@ impl Default for UsageDelta { input_tokens: 0, output_tokens: 0, cached_tokens: 0, + reasoning_tokens: 0, + total_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, image_request_count: 0, @@ -753,6 +773,10 @@ pub struct AccountUsageRecord { pub output_tokens: i64, /// 缓存 token 数。 pub cached_tokens: i64, + /// reasoning token 数。 + pub reasoning_tokens: i64, + /// 上游返回的总 token 数。 + pub total_tokens: i64, /// 图片输入 token 数。 pub image_input_tokens: i64, /// 图片输出 token 数。 @@ -808,6 +832,10 @@ pub struct AccountUsageListRecord { pub output_tokens: i64, /// 缓存 token 数。 pub cached_tokens: i64, + /// reasoning token 数。 + pub reasoning_tokens: i64, + /// 上游返回的总 token 数。 + pub total_tokens: i64, /// 图片输入 token 数。 pub image_input_tokens: i64, /// 图片输出 token 数。 @@ -835,6 +863,10 @@ pub struct AccountUsageSummary { pub output_tokens: i64, /// 缓存 token 总数。 pub cached_tokens: i64, + /// reasoning token 总数。 + pub reasoning_tokens: i64, + /// 上游返回 token 总数。 + pub total_tokens: i64, /// 图片输入 token 总数。 pub image_input_tokens: i64, /// 图片输出 token 总数。 @@ -1235,6 +1267,8 @@ impl SqliteAccountStore { .bind(usage.input_tokens) .bind(usage.output_tokens) .bind(usage.cached_tokens) + .bind(usage.reasoning_tokens) + .bind(usage.total_tokens) .bind(usage.image_input_tokens) .bind(usage.image_output_tokens) .bind(usage.image_request_count) @@ -1324,6 +1358,8 @@ impl SqliteAccountStore { input_tokens: row.get("input_tokens"), output_tokens: row.get("output_tokens"), cached_tokens: row.get("cached_tokens"), + reasoning_tokens: row.get("reasoning_tokens"), + total_tokens: row.get("total_tokens"), image_input_tokens: row.get("image_input_tokens"), image_output_tokens: row.get("image_output_tokens"), image_request_count: row.get("image_request_count"), @@ -1762,6 +1798,8 @@ fn sqlite_usage_delta(usage: AccountUsageDelta) -> UsageDelta { input_tokens: u64_to_i64_saturating(usage.input_tokens), output_tokens: u64_to_i64_saturating(usage.output_tokens), cached_tokens: u64_to_i64_saturating(usage.cached_tokens), + reasoning_tokens: u64_to_i64_saturating(usage.reasoning_tokens), + total_tokens: u64_to_i64_saturating(usage.total_tokens), image_input_tokens: u64_to_i64_saturating(usage.image_input_tokens), image_output_tokens: u64_to_i64_saturating(usage.image_output_tokens), image_request_count: u64_to_i64_saturating(usage.image_requests), @@ -1805,6 +1843,8 @@ fn usage_from_row(row: &sqlx::sqlite::SqliteRow) -> SqliteAccountStoreResult, /// HTTP 状态码。 pub status_code: Option, + /// 上游传输方式。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, /// 搜索关键词。 pub search: Option, } @@ -86,7 +99,7 @@ impl SqliteEventLogStore { ) -> SqliteEventLogStoreResult> { let fetch_limit = i64::from(limit) + 1; let mut builder = QueryBuilder::::new( - "select id, request_id, kind, level, account_id, route, model, status_code, latency_ms, message, metadata_json, created_at from event_logs", + "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs", ); push_filter(&mut builder, &filter, cursor.as_deref())?; builder.push(" order by created_at desc, id desc limit "); @@ -114,7 +127,7 @@ impl SqliteEventLogStore { /// 按 ID 读取事件日志。 pub async fn get(&self, id: &str) -> SqliteEventLogStoreResult> { let row = sqlx::query( - "select id, request_id, kind, level, account_id, route, model, status_code, latency_ms, message, metadata_json, created_at from event_logs where id = ?", + "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs where id = ?", ) .bind(id) .fetch_optional(&self.pool) @@ -151,9 +164,76 @@ impl EventLogStore for SqliteEventLogStore { } } +#[derive(Debug, Clone)] +struct EventLogSummaryFields { + transport: Option, + attempt_index: Option, + upstream_status_code: Option, + failure_class: Option, + response_id: Option, + upstream_request_id: Option, +} + +fn event_summary_fields(event: &EventLog) -> EventLogSummaryFields { + EventLogSummaryFields { + transport: event + .transport + .clone() + .or_else(|| metadata_string(&event.metadata, &["transport"])), + attempt_index: event + .attempt_index + .or_else(|| metadata_i64(&event.metadata, &["attemptIndex", "attempt_index"])), + upstream_status_code: event.upstream_status_code.or_else(|| { + metadata_i64( + &event.metadata, + &[ + "upstreamStatus", + "upstreamStatusCode", + "upstream_status_code", + ], + ) + }), + failure_class: event + .failure_class + .clone() + .or_else(|| metadata_string(&event.metadata, &["failureClass", "failure_class"])), + response_id: event + .response_id + .clone() + .or_else(|| metadata_string(&event.metadata, &["responseId", "response_id"])), + upstream_request_id: event.upstream_request_id.clone().or_else(|| { + metadata_string( + &event.metadata, + &[ + "upstreamRequestId", + "upstream_request_id", + "openaiRequestId", + "cfRay", + ], + ) + }), + } +} + +fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + metadata + .get(*key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string) + }) +} + +fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) +} + async fn append_event(pool: &SqlitePool, event: &EventLog) -> SqliteEventLogStoreResult<()> { + let summary = event_summary_fields(event); sqlx::query( - "insert into event_logs (id, request_id, kind, level, account_id, route, model, status_code, latency_ms, message, metadata_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into event_logs (id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&event.id) .bind(&event.request_id) @@ -163,6 +243,12 @@ async fn append_event(pool: &SqlitePool, event: &EventLog) -> SqliteEventLogStor .bind(&event.route) .bind(&event.model) .bind(event.status_code) + .bind(summary.transport) + .bind(summary.attempt_index) + .bind(summary.upstream_status_code) + .bind(summary.failure_class) + .bind(summary.response_id) + .bind(summary.upstream_request_id) .bind(event.latency_ms) .bind(&event.message) .bind(event.metadata.to_string()) @@ -222,6 +308,30 @@ fn push_filter( separated.push("status_code = "); separated.push_bind_unseparated(status_code); } + if let Some(transport) = filter.transport.as_deref() { + separated.push("transport = "); + separated.push_bind_unseparated(transport); + } + if let Some(attempt_index) = filter.attempt_index { + separated.push("attempt_index = "); + separated.push_bind_unseparated(attempt_index); + } + if let Some(upstream_status_code) = filter.upstream_status_code { + separated.push("upstream_status_code = "); + separated.push_bind_unseparated(upstream_status_code); + } + if let Some(failure_class) = filter.failure_class.as_deref() { + separated.push("failure_class = "); + separated.push_bind_unseparated(failure_class); + } + if let Some(response_id) = filter.response_id.as_deref() { + separated.push("response_id = "); + separated.push_bind_unseparated(response_id); + } + if let Some(upstream_request_id) = filter.upstream_request_id.as_deref() { + separated.push("upstream_request_id = "); + separated.push_bind_unseparated(upstream_request_id); + } if let Some(search) = filter.search.as_deref() { let pattern = format!("%{search}%"); separated.push("(message like "); @@ -255,6 +365,12 @@ fn event_from_row(row: &sqlx::sqlite::SqliteRow) -> SqliteEventLogStoreResult("metadata_json"))?, diff --git a/crates/adapters/tests/account_repository.rs b/crates/adapters/tests/account_repository.rs index a6687155b..36441da3e 100644 --- a/crates/adapters/tests/account_repository.rs +++ b/crates/adapters/tests/account_repository.rs @@ -158,6 +158,8 @@ async fn account_repository_should_accumulate_usage_counters() { input_tokens: 10, output_tokens: 4, cached_tokens: 3, + reasoning_tokens: 2, + total_tokens: 14, empty_response_count: 0, ..UsageDelta::default() }, @@ -170,6 +172,8 @@ async fn account_repository_should_accumulate_usage_counters() { input_tokens: 8, output_tokens: 2, cached_tokens: 1, + reasoning_tokens: 1, + total_tokens: 10, empty_response_count: 1, ..UsageDelta::default() }, @@ -182,6 +186,8 @@ async fn account_repository_should_accumulate_usage_counters() { assert_eq!(usage.input_tokens, 18); assert_eq!(usage.output_tokens, 6); assert_eq!(usage.cached_tokens, 4); + assert_eq!(usage.reasoning_tokens, 3); + assert_eq!(usage.total_tokens, 24); assert_eq!(usage.empty_response_count, 1); assert!(usage.last_used_at.is_some()); } diff --git a/crates/adapters/tests/codex.rs b/crates/adapters/tests/codex.rs index 0af72f0f6..946aa362d 100644 --- a/crates/adapters/tests/codex.rs +++ b/crates/adapters/tests/codex.rs @@ -22,7 +22,9 @@ use tokio_tungstenite::{ accept_hdr_async_with_config, tungstenite::{ extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, - handshake::server::{Request as WsRequest, Response as WsResponse}, + handshake::server::{ + Callback, ErrorResponse, Request as WsRequest, Response as WsResponse, + }, protocol::WebSocketConfig, Message, }, @@ -51,15 +53,41 @@ fn websocket_accept_config() -> WebSocketConfig { async fn accept_codex_test_websocket( stream: TcpStream, ) -> tokio_tungstenite::WebSocketStream { + accept_codex_test_websocket_with(stream, |_request, response| { + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + }) + .await +} + +struct TestWebSocketCallback(F); + +impl Callback for TestWebSocketCallback +where + F: FnOnce(&WsRequest, &mut WsResponse) + Unpin, +{ + fn on_request( + self, + request: &WsRequest, + mut response: WsResponse, + ) -> Result { + (self.0)(request, &mut response); + Ok(response) + } +} + +async fn accept_codex_test_websocket_with( + stream: TcpStream, + callback: F, +) -> tokio_tungstenite::WebSocketStream +where + F: FnOnce(&WsRequest, &mut WsResponse) + Unpin, +{ accept_hdr_async_with_config( stream, - |_request: &WsRequest, mut response: WsResponse| { - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().unwrap(), - ); - Ok(response) - }, + TestWebSocketCallback(callback), Some(websocket_accept_config()), ) .await diff --git a/crates/adapters/tests/codex/codex_fingerprint.rs b/crates/adapters/tests/codex/codex_fingerprint.rs index a547ca357..1ba05fd45 100644 --- a/crates/adapters/tests/codex/codex_fingerprint.rs +++ b/crates/adapters/tests/codex/codex_fingerprint.rs @@ -10,31 +10,35 @@ fn update_manifest_should_extract_version_and_build_number() { } #[tokio::test] -async fn fingerprint_repository_should_upsert_auto_update_record() { +async fn fingerprint_repository_should_update_current_record() { let dir = tempfile::tempdir().expect("temp dir"); let db = dir.path().join("fingerprints.sqlite"); let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display())) .await .expect("sqlite pool"); let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool.clone()); + let default_fingerprint = + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(); - repo.upsert_auto_update("26.800.1", "6001", Some("147")) + repo.ensure_current_seed(&default_fingerprint) .await - .expect("first upsert"); - repo.upsert_auto_update("26.800.2", "6002", None) + .expect("seed current fingerprint"); + repo.update_current_version("26.800.1", "6001", Some("147")) .await - .expect("second upsert"); + .expect("first update"); + repo.update_current_version("26.800.2", "6002", None) + .await + .expect("second update"); let stored = repo - .load_latest_auto_updated() + .load_current() .await - .expect("load latest") + .expect("load current") .expect("stored fingerprint"); - let count: (i64,) = - sqlx::query_as("select count(*) from fingerprints where id = 'auto_updated'") - .fetch_one(&pool) - .await - .expect("count row"); + let count: (i64,) = sqlx::query_as("select count(*) from fingerprints where id = 'current'") + .fetch_one(&pool) + .await + .expect("count row"); assert_eq!(count.0, 1); assert_eq!(stored.app_version, "26.800.2"); @@ -60,6 +64,11 @@ async fn fingerprint_updater_should_fetch_manifest_and_persist_history() { .await .expect("sqlite pool"); let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool); + repo.ensure_current_seed( + &codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .await + .expect("seed current fingerprint"); let updater = codex_proxy_adapters::codex::fingerprint::FingerprintUpdater::new( reqwest::Client::new(), repo.clone(), @@ -146,6 +155,11 @@ async fn update_checker_should_apply_available_update_to_repository() { .await .expect("sqlite pool"); let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool); + repo.ensure_current_seed( + &codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .await + .expect("seed current fingerprint"); let extracted_path = dir.path().join("extracted-fingerprint.json"); std::fs::write( &extracted_path, @@ -167,9 +181,9 @@ async fn update_checker_should_apply_available_update_to_repository() { .await .expect("apply update"); let stored = repo - .load_latest_auto_updated() + .load_current() .await - .expect("load latest") + .expect("load current") .expect("stored fingerprint"); assert!(applied); diff --git a/crates/adapters/tests/codex/codex_headers.rs b/crates/adapters/tests/codex/codex_headers.rs index 64925911d..f96fc83d4 100644 --- a/crates/adapters/tests/codex/codex_headers.rs +++ b/crates/adapters/tests/codex/codex_headers.rs @@ -153,40 +153,30 @@ fn websocket_connection_should_build_standard_opening_headers_around_business_he } #[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn websocket_execute_response_create_request_should_capture_handshake_headers() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_hdr_async_with_config( - stream, - |_request: &WsRequest, mut response: WsResponse| { - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().unwrap(), - ); - response - .headers_mut() - .insert("x-codex-turn-state", "turn-from-handshake".parse().unwrap()); - response.headers_mut().insert( - "set-cookie", - "cf_clearance=ws; Domain=.chatgpt.com; Path=/" - .parse() - .unwrap(), - ); - response - .headers_mut() - .insert("x-ratelimit-remaining-requests", "41".parse().unwrap()); - Ok(response) - }, - Some(websocket_accept_config()), - ) - .await - .unwrap(); + let mut websocket = accept_codex_test_websocket_with(stream, |_request, response| { + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + response + .headers_mut() + .insert("x-codex-turn-state", "turn-from-handshake".parse().unwrap()); + response.headers_mut().insert( + "set-cookie", + "cf_clearance=ws; Domain=.chatgpt.com; Path=/" + .parse() + .unwrap(), + ); + response + .headers_mut() + .insert("x-ratelimit-remaining-requests", "41".parse().unwrap()); + }) + .await; let _message = websocket.next().await.unwrap().unwrap(); websocket .send(Message::Text( @@ -241,10 +231,6 @@ async fn websocket_execute_response_create_request_should_capture_handshake_head } #[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn codex_backend_client_websocket_should_forward_security_chain_headers_and_payload_fields() { let received_headers = Arc::new(Mutex::new(Vec::<(String, String)>::new())); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -252,31 +238,24 @@ async fn codex_backend_client_websocket_should_forward_security_chain_headers_an let headers_for_server = Arc::clone(&received_headers); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_hdr_async_with_config( - stream, - move |request: &WsRequest, response: WsResponse| { - let mut response = response; - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().unwrap(), - ); - let headers = request - .headers() - .iter() - .map(|(name, value)| { - ( - name.as_str().to_string(), - value.to_str().unwrap_or_default().to_string(), - ) - }) - .collect::>(); - *headers_for_server.lock().unwrap() = headers; - Ok(response) - }, - Some(websocket_accept_config()), - ) - .await - .unwrap(); + let mut websocket = accept_codex_test_websocket_with(stream, move |request, response| { + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + let headers = request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect::>(); + *headers_for_server.lock().unwrap() = headers; + }) + .await; let message = websocket.next().await.unwrap().unwrap(); let payload = serde_json::from_str::(&message.into_text().unwrap()) .expect("client payload should be json"); @@ -467,6 +446,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m input_tokens: 2, output_tokens: 3, cached_tokens: 1, + reasoning_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, total_tokens: 5, diff --git a/crates/adapters/tests/codex/codex_websocket.rs b/crates/adapters/tests/codex/codex_websocket.rs index 4f488a6a5..786d73f3c 100644 --- a/crates/adapters/tests/codex/codex_websocket.rs +++ b/crates/adapters/tests/codex/codex_websocket.rs @@ -98,26 +98,19 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_ let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_hdr_async_with_config( - stream, - |request: &WsRequest, response: WsResponse| { - let extensions = request - .headers() - .get("sec-websocket-extensions") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - assert!(extensions.contains("permessage-deflate")); - let mut response = response; - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().unwrap(), - ); - Ok(response) - }, - Some(websocket_accept_config()), - ) - .await - .unwrap(); + let mut websocket = accept_codex_test_websocket_with(stream, |request, response| { + let extensions = request + .headers() + .get("sec-websocket-extensions") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + assert!(extensions.contains("permessage-deflate")); + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + }) + .await; let delta = json!({ "type": "response.output_text.delta", @@ -1175,40 +1168,30 @@ async fn codex_backend_client_stream_should_error_when_websocket_closes_before_t } #[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_present() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); - let mut websocket = accept_hdr_async_with_config( - stream, - |_request: &WsRequest, mut response: WsResponse| { - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().unwrap(), - ); - response - .headers_mut() - .insert("x-codex-turn-state", "turn-ws-client".parse().unwrap()); - response.headers_mut().insert( - "set-cookie", - "cf_clearance=client-ws; Domain=.chatgpt.com; Path=/" - .parse() - .unwrap(), - ); - response - .headers_mut() - .insert("x-ratelimit-remaining-requests", "17".parse().unwrap()); - Ok(response) - }, - Some(websocket_accept_config()), - ) - .await - .unwrap(); + let mut websocket = accept_codex_test_websocket_with(stream, |_request, response| { + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + response + .headers_mut() + .insert("x-codex-turn-state", "turn-ws-client".parse().unwrap()); + response.headers_mut().insert( + "set-cookie", + "cf_clearance=client-ws; Domain=.chatgpt.com; Path=/" + .parse() + .unwrap(), + ); + response + .headers_mut() + .insert("x-ratelimit-remaining-requests", "17".parse().unwrap()); + }) + .await; let message = websocket.next().await.unwrap().unwrap(); let payload = serde_json::from_str::(&message.into_text().unwrap()) .expect("client payload should be json"); diff --git a/crates/adapters/tests/events.rs b/crates/adapters/tests/events.rs index 8eef1f89d..c351babdf 100644 --- a/crates/adapters/tests/events.rs +++ b/crates/adapters/tests/events.rs @@ -1,6 +1,7 @@ use codex_proxy_adapters::sqlite::events::{EventLogFilter, SqliteEventLogStore}; use codex_proxy_core::events::model::{EventLevel, EventLog}; use codex_proxy_platform::storage::connect_sqlite; +use serde_json::json; #[tokio::test] async fn event_log_store_should_filter_and_cursor_page_events() { @@ -47,6 +48,45 @@ async fn event_log_store_should_filter_and_cursor_page_events() { assert_eq!(page.items[0].route.as_deref(), Some("/v1/responses")); } +#[tokio::test] +async fn event_log_store_should_promote_diagnostic_metadata_fields() { + let (store, _dir) = event_log_store("event-logs-diagnostics.sqlite").await; + let mut event = EventLog::new("request", EventLevel::Error, "upstream failed"); + event.id = "log_diagnostic".to_string(); + event.metadata = json!({ + "transport": "websocket", + "attemptIndex": 2, + "upstreamStatus": 429, + "failureClass": "rate_limited", + "responseId": "resp_1", + "upstreamRequestId": "req_upstream_1" + }); + + store.append(&event).await.unwrap(); + let page = store + .list( + EventLogFilter { + transport: Some("websocket".to_string()), + failure_class: Some("rate_limited".to_string()), + upstream_status_code: Some(429), + ..EventLogFilter::default() + }, + None, + 10, + ) + .await + .unwrap(); + + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].id, "log_diagnostic"); + assert_eq!(page.items[0].attempt_index, Some(2)); + assert_eq!(page.items[0].response_id.as_deref(), Some("resp_1")); + assert_eq!( + page.items[0].upstream_request_id.as_deref(), + Some("req_upstream_1") + ); +} + #[tokio::test] async fn event_log_store_should_get_count_and_clear_events() { let (store, _dir) = event_log_store("event-logs-clear.sqlite").await; diff --git a/crates/adapters/tests/models.rs b/crates/adapters/tests/models.rs index 458ea271d..5b2934bfe 100644 --- a/crates/adapters/tests/models.rs +++ b/crates/adapters/tests/models.rs @@ -48,7 +48,7 @@ async fn sqlite_account_store_should_list_pool_accounts() { .expect("encrypt refresh token"); sqlx::query( "insert into accounts ( - id, email, account_id, user_id, label, plan_type, access_token_cipher, refresh_token_cipher, + id, email, chatgpt_account_id, chatgpt_user_id, label, plan_type, access_token_cipher, refresh_token_cipher, access_token_expires_at, status, added_at, updated_at ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) diff --git a/crates/adapters/tests/session_affinity.rs b/crates/adapters/tests/session_affinity.rs index beb9c5741..f6324e76c 100644 --- a/crates/adapters/tests/session_affinity.rs +++ b/crates/adapters/tests/session_affinity.rs @@ -10,6 +10,7 @@ async fn session_affinity_store_should_upsert_and_list_active_records() { let pool = connect_sqlite(&format!("sqlite://{}", db.display())) .await .expect("sqlite pool"); + insert_account(&pool, "acct_1").await; let store = SqliteSessionAffinityStore::new(pool); let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap(); @@ -49,6 +50,8 @@ async fn session_affinity_store_should_delete_expired_records() { let pool = connect_sqlite(&format!("sqlite://{}", db.display())) .await .expect("sqlite pool"); + insert_account(&pool, "acct_1").await; + insert_account(&pool, "acct_2").await; let store = SqliteSessionAffinityStore::new(pool); let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap(); @@ -100,3 +103,13 @@ async fn session_affinity_store_should_delete_expired_records() { assert_eq!(records.len(), 1); assert_eq!(records[0].response_id, "resp_active"); } + +async fn insert_account(pool: &sqlx::SqlitePool, id: &str) { + sqlx::query( + "insert into accounts (id, access_token_cipher, status, added_at, updated_at) values (?, 'cipher', 'active', '2026-06-18T12:00:00Z', '2026-06-18T12:00:00Z')", + ) + .bind(id) + .execute(pool) + .await + .expect("account should be inserted"); +} diff --git a/crates/core/src/accounts/usage.rs b/crates/core/src/accounts/usage.rs index d4056cb51..5b85d6d08 100644 --- a/crates/core/src/accounts/usage.rs +++ b/crates/core/src/accounts/usage.rs @@ -11,6 +11,10 @@ pub struct AccountUsageDelta { pub output_tokens: u64, /// 缓存 token 增量。 pub cached_tokens: u64, + /// reasoning token 增量。 + pub reasoning_tokens: u64, + /// 上游返回的总 token 增量。 + pub total_tokens: u64, /// 空响应数增量。 pub empty_responses: u64, /// 图片工具输入 token 增量。 @@ -31,6 +35,8 @@ impl AccountUsageDelta { input_tokens: self.input_tokens + other.input_tokens, output_tokens: self.output_tokens + other.output_tokens, cached_tokens: self.cached_tokens + other.cached_tokens, + reasoning_tokens: self.reasoning_tokens + other.reasoning_tokens, + total_tokens: self.total_tokens + other.total_tokens, empty_responses: self.empty_responses + other.empty_responses, image_input_tokens: self.image_input_tokens + other.image_input_tokens, image_output_tokens: self.image_output_tokens + other.image_output_tokens, diff --git a/crates/core/src/events/model.rs b/crates/core/src/events/model.rs index 0eaa0a56c..6bcb8e3c3 100644 --- a/crates/core/src/events/model.rs +++ b/crates/core/src/events/model.rs @@ -39,6 +39,18 @@ pub struct EventLog { pub model: Option, /// HTTP 状态码。 pub status_code: Option, + /// 上游传输方式,例如 http_sse 或 websocket。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, /// 请求耗时毫秒。 pub latency_ms: Option, /// 展示消息。 @@ -61,6 +73,12 @@ impl EventLog { route: None, model: None, status_code: None, + transport: None, + attempt_index: None, + upstream_status_code: None, + failure_class: None, + response_id: None, + upstream_request_id: None, latency_ms: None, message: message.into(), metadata: Value::Object(Default::default()), diff --git a/crates/core/src/protocol/codex/events.rs b/crates/core/src/protocol/codex/events.rs index 57bd43b52..8d2ac1eb0 100644 --- a/crates/core/src/protocol/codex/events.rs +++ b/crates/core/src/protocol/codex/events.rs @@ -21,6 +21,8 @@ pub struct TokenUsage { pub output_tokens: u64, /// 命中缓存的输入 token 数。 pub cached_tokens: u64, + /// 输出 token 中的 reasoning token 数。 + pub reasoning_tokens: u64, /// 图片工具输入 token 数。 pub image_input_tokens: u64, /// 图片工具输出 token 数。 @@ -36,6 +38,7 @@ impl TokenUsage { input_tokens, output_tokens, cached_tokens, + reasoning_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, total_tokens: input_tokens + output_tokens, @@ -48,6 +51,7 @@ impl TokenUsage { input_tokens: self.input_tokens + other.input_tokens, output_tokens: self.output_tokens + other.output_tokens, cached_tokens: self.cached_tokens + other.cached_tokens, + reasoning_tokens: self.reasoning_tokens + other.reasoning_tokens, image_input_tokens: self.image_input_tokens + other.image_input_tokens, image_output_tokens: self.image_output_tokens + other.image_output_tokens, total_tokens: self.total_tokens + other.total_tokens, @@ -74,6 +78,13 @@ pub fn extract_usage(body: &Value) -> Option { .or_else(|| nested_number_field(usage, &["prompt_tokens_details", "cached_tokens"])) .or_else(|| number_field(usage, "cached_tokens")) .unwrap_or_default(); + let reasoning_tokens = + nested_number_field(usage, &["output_tokens_details", "reasoning_tokens"]) + .or_else(|| { + nested_number_field(usage, &["completion_tokens_details", "reasoning_tokens"]) + }) + .or_else(|| number_field(usage, "reasoning_tokens")) + .unwrap_or_default(); let image_input_tokens = nested_number_field(body, &["tool_usage", "image_gen", "input_tokens"]).unwrap_or_default(); let image_output_tokens = @@ -87,12 +98,15 @@ pub fn extract_usage(body: &Value) -> Option { "prompt_tokens", "completion_tokens", "cached_tokens", + "reasoning_tokens", "total_tokens", ] .iter() .any(|field| usage.get(*field).is_some()) || usage.get("input_tokens_details").is_some() || usage.get("prompt_tokens_details").is_some() + || usage.get("output_tokens_details").is_some() + || usage.get("completion_tokens_details").is_some() || image_input_tokens > 0 || image_output_tokens > 0; @@ -100,6 +114,7 @@ pub fn extract_usage(body: &Value) -> Option { input_tokens, output_tokens, cached_tokens, + reasoning_tokens, image_input_tokens, image_output_tokens, total_tokens, diff --git a/crates/core/src/usage/service.rs b/crates/core/src/usage/service.rs index 9157154be..11a99585f 100644 --- a/crates/core/src/usage/service.rs +++ b/crates/core/src/usage/service.rs @@ -24,6 +24,8 @@ impl UsageService { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cached_tokens: usage.cached_tokens, + reasoning_tokens: usage.reasoning_tokens, + total_tokens: usage.total_tokens, empty_responses: 0, image_input_tokens: usage.image_input_tokens, image_output_tokens: usage.image_output_tokens, diff --git a/crates/core/tests/protocol/protocol_usage_rate_limits.rs b/crates/core/tests/protocol/protocol_usage_rate_limits.rs index 9e7eb9814..08d836e6c 100644 --- a/crates/core/tests/protocol/protocol_usage_rate_limits.rs +++ b/crates/core/tests/protocol/protocol_usage_rate_limits.rs @@ -20,6 +20,7 @@ fn extract_usage_should_read_codex_usage_shape() { input_tokens: 12, output_tokens: 5, cached_tokens: 3, + reasoning_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, total_tokens: 17, @@ -27,6 +28,35 @@ fn extract_usage_should_read_codex_usage_shape() { ); } +#[test] +fn extract_usage_should_read_reasoning_and_total_tokens() { + let body = json!({ + "usage": { + "input_tokens": 12, + "output_tokens": 8, + "total_tokens": 20, + "output_tokens_details": { + "reasoning_tokens": 6 + } + } + }); + + let usage = extract_usage(&body).expect("usage should exist"); + + assert_eq!( + usage, + TokenUsage { + input_tokens: 12, + output_tokens: 8, + cached_tokens: 0, + reasoning_tokens: 6, + image_input_tokens: 0, + image_output_tokens: 0, + total_tokens: 20, + } + ); +} + #[test] fn extract_usage_should_read_image_generation_tokens_separately() { let body = json!({ @@ -53,6 +83,7 @@ fn extract_usage_should_read_image_generation_tokens_separately() { input_tokens: 12, output_tokens: 5, cached_tokens: 3, + reasoning_tokens: 0, image_input_tokens: 31, image_output_tokens: 9, total_tokens: 17, @@ -76,6 +107,7 @@ fn extract_usage_should_merge_openai_usage_shape() { input_tokens: 1, output_tokens: 2, cached_tokens: 0, + reasoning_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, total_tokens: 3, @@ -87,6 +119,7 @@ fn extract_usage_should_merge_openai_usage_shape() { input_tokens: 9, output_tokens: 6, cached_tokens: 2, + reasoning_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, total_tokens: 15, @@ -113,6 +146,7 @@ fn extract_sse_usage_should_prefer_completed_response_usage() { input_tokens: 3, output_tokens: 5, cached_tokens: 1, + reasoning_tokens: 0, image_input_tokens: 0, image_output_tokens: 0, total_tokens: 8, @@ -137,6 +171,7 @@ fn extract_sse_usage_should_read_completed_image_generation_tokens() { input_tokens: 12, output_tokens: 5, cached_tokens: 3, + reasoning_tokens: 0, image_input_tokens: 31, image_output_tokens: 9, total_tokens: 17, diff --git a/crates/platform/src/config/types.rs b/crates/platform/src/config/types.rs index bf615aad9..e20acef37 100644 --- a/crates/platform/src/config/types.rs +++ b/crates/platform/src/config/types.rs @@ -25,6 +25,8 @@ pub struct AppConfig { pub tls: TlsConfig, /// WebSocket 连接池配置。 pub ws_pool: WebSocketPoolConfig, + /// 上游请求指纹默认配置。 + pub fingerprint: FingerprintConfig, /// 管理员初始化配置。 pub admin: AdminConfig, /// 日志配置。 @@ -159,6 +161,94 @@ impl Default for WebSocketPoolConfig { } } +/// 上游请求指纹默认配置。 +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FingerprintConfig { + /// 客户端来源名。 + pub originator: String, + /// 应用版本。 + pub app_version: String, + /// 构建号。 + pub build_number: String, + /// 平台名。 + pub platform: String, + /// 架构名。 + pub arch: String, + /// Chromium 主版本。 + pub chromium_version: String, + /// User-Agent 模板。 + pub user_agent_template: String, + /// 默认请求头。 + pub default_headers: Vec, + /// 请求头排序优先级。 + pub header_order: Vec, +} + +impl Default for FingerprintConfig { + fn default() -> Self { + Self { + originator: "Codex Desktop".to_string(), + app_version: "26.519.81530".to_string(), + build_number: "3178".to_string(), + platform: "darwin".to_string(), + arch: "arm64".to_string(), + chromium_version: "146".to_string(), + user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(), + default_headers: vec![ + FingerprintHeaderConfig::new("Accept-Encoding", "gzip, deflate, br, zstd"), + FingerprintHeaderConfig::new("Accept-Language", "en-US,en;q=0.9"), + FingerprintHeaderConfig::new("sec-ch-ua-mobile", "?0"), + FingerprintHeaderConfig::new("sec-ch-ua-platform", "\"macOS\""), + FingerprintHeaderConfig::new("sec-fetch-site", "same-origin"), + FingerprintHeaderConfig::new("sec-fetch-mode", "cors"), + FingerprintHeaderConfig::new("sec-fetch-dest", "empty"), + ], + header_order: vec![ + "authorization".to_string(), + "chatgpt-account-id".to_string(), + "originator".to_string(), + "x-openai-internal-codex-residency".to_string(), + "x-client-request-id".to_string(), + "x-codex-installation-id".to_string(), + "x-codex-turn-state".to_string(), + "openai-beta".to_string(), + "user-agent".to_string(), + "sec-ch-ua".to_string(), + "sec-ch-ua-mobile".to_string(), + "sec-ch-ua-platform".to_string(), + "accept-encoding".to_string(), + "accept-language".to_string(), + "sec-fetch-site".to_string(), + "sec-fetch-mode".to_string(), + "sec-fetch-dest".to_string(), + "content-type".to_string(), + "accept".to_string(), + "cookie".to_string(), + ], + } + } +} + +/// 指纹默认请求头配置项。 +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FingerprintHeaderConfig { + /// 请求头名称。 + pub name: String, + /// 请求头值。 + pub value: String, +} + +impl FingerprintHeaderConfig { + fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + /// 管理员初始化与会话配置。 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct AdminConfig { diff --git a/crates/platform/src/storage/schema.sql b/crates/platform/src/storage/schema.sql index 1650a2ea9..572a14e9f 100644 --- a/crates/platform/src/storage/schema.sql +++ b/crates/platform/src/storage/schema.sql @@ -1,5 +1,14 @@ pragma foreign_keys = on; +-- ============================================ +-- Schema Migrations +-- ============================================ +create table if not exists schema_migrations ( + version integer primary key, + name text not null, + applied_at text not null +); + -- ============================================ -- Admin Users -- ============================================ @@ -44,8 +53,8 @@ create index if not exists idx_client_api_keys_prefix on client_api_keys(prefix) create table if not exists accounts ( id text primary key, email text, - account_id text, - user_id text, + chatgpt_account_id text, + chatgpt_user_id text, label text, plan_type text, access_token_cipher text not null, @@ -64,7 +73,9 @@ create table if not exists accounts ( ); create index if not exists idx_accounts_status on accounts(status); -create index if not exists idx_accounts_identity on accounts(account_id, user_id) where account_id is not null; +create unique index if not exists ux_accounts_chatgpt_identity +on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, '')) +where chatgpt_account_id is not null; -- ============================================ -- Account Refresh Leases @@ -88,6 +99,8 @@ create table if not exists account_usage ( input_tokens integer not null default 0 check (input_tokens >= 0), output_tokens integer not null default 0 check (output_tokens >= 0), cached_tokens integer not null default 0 check (cached_tokens >= 0), + reasoning_tokens integer not null default 0 check (reasoning_tokens >= 0), + total_tokens integer not null default 0 check (total_tokens >= 0), image_input_tokens integer not null default 0 check (image_input_tokens >= 0), image_output_tokens integer not null default 0 check (image_output_tokens >= 0), image_request_count integer not null default 0 check (image_request_count >= 0), @@ -129,13 +142,28 @@ create index if not exists idx_account_cookies_account_domain on account_cookies -- ============================================ create table if not exists fingerprints ( id text primary key, + originator text not null, app_version text not null, build_number text not null, platform text not null, arch text not null, chromium_version text not null, user_agent_template text not null, + default_headers_json text not null, + header_order_json text not null, source text not null, + created_at text not null, + updated_at text not null +); + +create table if not exists fingerprint_update_history ( + id text primary key, + current_fingerprint_id text not null references fingerprints(id) on delete cascade, + app_version text not null, + build_number text not null, + chromium_version text, + source text not null, + manifest_json text, created_at text not null ); @@ -151,6 +179,12 @@ create table if not exists event_logs ( route text, model text, status_code integer check (status_code is null or (status_code >= 100 and status_code <= 599)), + transport text, + attempt_index integer check (attempt_index is null or attempt_index >= 0), + upstream_status_code integer check (upstream_status_code is null or (upstream_status_code >= 100 and upstream_status_code <= 599)), + failure_class text, + response_id text, + upstream_request_id text, latency_ms integer check (latency_ms is null or latency_ms >= 0), message text not null, metadata_json text not null, @@ -161,6 +195,10 @@ create index if not exists idx_event_logs_created_id on event_logs(created_at de create index if not exists idx_event_logs_kind_created on event_logs(kind, created_at desc); create index if not exists idx_event_logs_request_id on event_logs(request_id); create index if not exists idx_event_logs_account on event_logs(account_id, created_at desc) where account_id is not null; +create index if not exists idx_event_logs_transport on event_logs(transport, created_at desc) where transport is not null; +create index if not exists idx_event_logs_failure_class on event_logs(failure_class, created_at desc) where failure_class is not null; +create index if not exists idx_event_logs_response_id on event_logs(response_id) where response_id is not null; +create index if not exists idx_event_logs_upstream_request_id on event_logs(upstream_request_id) where upstream_request_id is not null; -- ============================================ -- Model Plan Snapshots @@ -176,7 +214,7 @@ create table if not exists model_plan_snapshots ( -- ============================================ create table if not exists session_affinities ( response_id text primary key, - account_id text not null, + account_id text not null references accounts(id) on delete cascade, conversation_id text not null, turn_state text, instructions_hash text, diff --git a/crates/platform/src/storage/sqlite.rs b/crates/platform/src/storage/sqlite.rs index 16fe92686..2b0b1782f 100644 --- a/crates/platform/src/storage/sqlite.rs +++ b/crates/platform/src/storage/sqlite.rs @@ -22,37 +22,6 @@ pub async fn connect_sqlite(database_url: &str) -> Result Result<(), sqlx::Error> { let schema = include_str!("schema.sql"); sqlx::raw_sql(schema).execute(pool).await?; - apply_lightweight_migrations(pool).await?; - Ok(()) -} - -async fn apply_lightweight_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { - add_accounts_next_refresh_at_if_missing(pool).await?; - Ok(()) -} - -async fn add_accounts_next_refresh_at_if_missing(pool: &SqlitePool) -> Result<(), sqlx::Error> { - let table_exists: Option<(String,)> = - sqlx::query_as("select name from sqlite_master where type = 'table' and name = ?") - .bind("accounts") - .fetch_optional(pool) - .await?; - if table_exists.is_none() { - return Ok(()); - } - - let column_exists: Option<(String,)> = - sqlx::query_as("select name from pragma_table_info('accounts') where name = ?") - .bind("next_refresh_at") - .fetch_optional(pool) - .await?; - if column_exists.is_some() { - return Ok(()); - } - - sqlx::query("alter table accounts add column next_refresh_at text") - .execute(pool) - .await?; Ok(()) } diff --git a/crates/platform/tests/config.rs b/crates/platform/tests/config.rs index e6ced9a94..ba9127fd7 100644 --- a/crates/platform/tests/config.rs +++ b/crates/platform/tests/config.rs @@ -46,6 +46,50 @@ ws_pool: enabled: true max_age_ms: 3300000 max_per_account: 8 +fingerprint: + originator: Codex Desktop + app_version: 26.519.81530 + build_number: "3178" + platform: darwin + arch: arm64 + chromium_version: "146" + user_agent_template: "Codex Desktop/{version} ({platform}; {arch})" + default_headers: + - name: Accept-Encoding + value: gzip, deflate, br, zstd + - name: Accept-Language + value: en-US,en;q=0.9 + - name: sec-ch-ua-mobile + value: "?0" + - name: sec-ch-ua-platform + value: "\"macOS\"" + - name: sec-fetch-site + value: same-origin + - name: sec-fetch-mode + value: cors + - name: sec-fetch-dest + value: empty + header_order: + - authorization + - chatgpt-account-id + - originator + - x-openai-internal-codex-residency + - x-client-request-id + - x-codex-installation-id + - x-codex-turn-state + - openai-beta + - user-agent + - sec-ch-ua + - sec-ch-ua-mobile + - sec-ch-ua-platform + - accept-encoding + - accept-language + - sec-fetch-site + - sec-fetch-mode + - sec-fetch-dest + - content-type + - accept + - cookie admin: session_ttl_minutes: 1440 default_username: admin @@ -78,6 +122,9 @@ fn default_config_keeps_only_codex_backend() { assert!(cfg.ws_pool.enabled); assert_eq!(cfg.ws_pool.max_age_ms, 3_300_000); assert_eq!(cfg.ws_pool.max_per_account, 8); + assert_eq!(cfg.fingerprint.originator, "Codex Desktop"); + assert_eq!(cfg.fingerprint.app_version, "26.519.81530"); + assert_eq!(cfg.fingerprint.default_headers[0].name, "Accept-Encoding"); } #[test] @@ -146,6 +193,50 @@ ws_pool: enabled: true max_age_ms: 3300000 max_per_account: 8 +fingerprint: + originator: Codex Desktop + app_version: 26.519.81530 + build_number: "3178" + platform: darwin + arch: arm64 + chromium_version: "146" + user_agent_template: "Codex Desktop/{version} ({platform}; {arch})" + default_headers: + - name: Accept-Encoding + value: gzip, deflate, br, zstd + - name: Accept-Language + value: en-US,en;q=0.9 + - name: sec-ch-ua-mobile + value: "?0" + - name: sec-ch-ua-platform + value: "\"macOS\"" + - name: sec-fetch-site + value: same-origin + - name: sec-fetch-mode + value: cors + - name: sec-fetch-dest + value: empty + header_order: + - authorization + - chatgpt-account-id + - originator + - x-openai-internal-codex-residency + - x-client-request-id + - x-codex-installation-id + - x-codex-turn-state + - openai-beta + - user-agent + - sec-ch-ua + - sec-ch-ua-mobile + - sec-ch-ua-platform + - accept-encoding + - accept-language + - sec-fetch-site + - sec-fetch-mode + - sec-fetch-dest + - content-type + - accept + - cookie admin: session_ttl_minutes: 1440 logging: diff --git a/crates/platform/tests/storage_schema.rs b/crates/platform/tests/storage_schema.rs index e11962c62..6e988d3a2 100644 --- a/crates/platform/tests/storage_schema.rs +++ b/crates/platform/tests/storage_schema.rs @@ -1,7 +1,4 @@ -use std::str::FromStr; - use codex_proxy_platform::storage::connect_sqlite; -use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; #[tokio::test] async fn sqlite_schema_creates_accounts_and_event_tables() { @@ -76,6 +73,26 @@ async fn sqlite_schema_should_persist_image_generation_usage_columns() { ); } +#[tokio::test] +async fn sqlite_schema_should_persist_token_total_usage_columns() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("test.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + + let rows: Vec<(String,)> = sqlx::query_as( + "select name from pragma_table_info('account_usage') where name in ('reasoning_tokens', 'total_tokens') order by name", + ) + .fetch_all(&pool) + .await + .unwrap(); + + assert_eq!( + rows.into_iter().map(|row| row.0).collect::>(), + ["reasoning_tokens", "total_tokens"] + ); +} + #[tokio::test] async fn sqlite_schema_should_persist_quota_verify_required_flag() { let dir = tempfile::tempdir().unwrap(); @@ -93,6 +110,28 @@ async fn sqlite_schema_should_persist_quota_verify_required_flag() { assert_eq!(row, Some(("quota_verify_required".to_string(),))); } +#[tokio::test] +async fn sqlite_schema_should_enforce_unique_chatgpt_identity() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("account-identity.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + + sqlx::query( + "insert into accounts (id, chatgpt_account_id, chatgpt_user_id, access_token_cipher, status, added_at, updated_at) values ('acct_a', 'chatgpt-account', 'chatgpt-user', 'cipher', 'active', '2026-06-14T00:00:00Z', '2026-06-14T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + let result = sqlx::query( + "insert into accounts (id, chatgpt_account_id, chatgpt_user_id, access_token_cipher, status, added_at, updated_at) values ('acct_b', 'chatgpt-account', 'chatgpt-user', 'cipher', 'active', '2026-06-14T00:00:00Z', '2026-06-14T00:00:00Z')", + ) + .execute(&pool) + .await; + + assert!(result.is_err()); +} + #[tokio::test] async fn sqlite_schema_should_persist_session_affinity_function_call_ids() { let dir = tempfile::tempdir().unwrap(); @@ -111,58 +150,79 @@ async fn sqlite_schema_should_persist_session_affinity_function_call_ids() { } #[tokio::test] -async fn connect_sqlite_should_not_patch_incomplete_existing_tables() { +async fn sqlite_schema_should_persist_complete_current_fingerprint_columns() { let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("incomplete.sqlite"); + let db = dir.path().join("fingerprints.sqlite"); let url = format!("sqlite://{}", db.display()); - let incomplete_options = SqliteConnectOptions::from_str(&url) - .unwrap() - .create_if_missing(true); - let incomplete_pool = SqlitePool::connect_with(incomplete_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(&incomplete_pool) - .await - .unwrap(); - incomplete_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'", + let rows: Vec<(String,)> = sqlx::query_as( + "select name from pragma_table_info('fingerprints') where name in ('originator', 'app_version', 'build_number', 'platform', 'arch', 'chromium_version', 'user_agent_template', 'default_headers_json', 'header_order_json', 'source', 'created_at', 'updated_at') order by name", ) - .fetch_optional(&pool) + .fetch_all(&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'", + + assert_eq!( + rows.into_iter().map(|row| row.0).collect::>(), + [ + "app_version", + "arch", + "build_number", + "chromium_version", + "created_at", + "default_headers_json", + "header_order_json", + "originator", + "platform", + "source", + "updated_at", + "user_agent_template", + ] + ); +} + +#[tokio::test] +async fn sqlite_schema_should_persist_fingerprint_history_table() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("fingerprint-history.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + + let row: Option<(String,)> = sqlx::query_as( + "select name from sqlite_master where type = 'table' and name = 'fingerprint_update_history'", ) .fetch_optional(&pool) .await .unwrap(); - assert_eq!(window_column, None); - assert_eq!(affinity_column, None); + assert_eq!(row, Some(("fingerprint_update_history".to_string(),))); +} + +#[tokio::test] +async fn sqlite_schema_should_persist_structured_event_diagnostic_columns() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("event-diagnostics.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + + let rows: Vec<(String,)> = sqlx::query_as( + "select name from pragma_table_info('event_logs') where name in ('transport', 'attempt_index', 'upstream_status_code', 'failure_class', 'response_id', 'upstream_request_id') order by name", + ) + .fetch_all(&pool) + .await + .unwrap(); + + assert_eq!( + rows.into_iter().map(|row| row.0).collect::>(), + [ + "attempt_index", + "failure_class", + "response_id", + "transport", + "upstream_request_id", + "upstream_status_code", + ] + ); } #[tokio::test] diff --git a/crates/runtime/src/bootstrap.rs b/crates/runtime/src/bootstrap.rs index 669ce8743..b3573c664 100644 --- a/crates/runtime/src/bootstrap.rs +++ b/crates/runtime/src/bootstrap.rs @@ -2,11 +2,40 @@ use codex_proxy_adapters::codex::fingerprint::FingerprintRepository; use codex_proxy_core::gateway::fingerprint::Fingerprint; +use codex_proxy_platform::{config::FingerprintConfig, storage::SqlitePool}; -/// 从指纹仓储加载运行时指纹,必要时回退到默认值。 -pub async fn load_runtime_fingerprint(repository: &FingerprintRepository) -> Fingerprint { - match repository.load_latest_auto_updated().await { - Ok(Some(fingerprint)) => fingerprint, - _ => Fingerprint::default_codex_desktop(), +/// 从配置默认值构造指纹。 +pub fn fingerprint_from_config(config: &FingerprintConfig) -> Fingerprint { + Fingerprint { + originator: config.originator.clone(), + app_version: config.app_version.clone(), + build_number: config.build_number.clone(), + platform: config.platform.clone(), + arch: config.arch.clone(), + chromium_version: config.chromium_version.clone(), + user_agent_template: config.user_agent_template.clone(), + default_headers: config + .default_headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect(), + header_order: config.header_order.clone(), } } + +/// 从指纹仓储加载运行时当前指纹;首次启动时用配置默认值写入当前槽位。 +pub async fn load_runtime_fingerprint( + repository: &FingerprintRepository, + default_fingerprint: &Fingerprint, +) -> Result { + repository.ensure_current_seed(default_fingerprint).await +} + +/// 从 SQLite 连接池加载运行时当前指纹。 +pub async fn load_runtime_fingerprint_from_pool( + pool: SqlitePool, + default_fingerprint: &Fingerprint, +) -> Result { + let repository = FingerprintRepository::new(pool); + load_runtime_fingerprint(&repository, default_fingerprint).await +} diff --git a/crates/runtime/src/services.rs b/crates/runtime/src/services.rs index 64b344160..9329af1c4 100644 --- a/crates/runtime/src/services.rs +++ b/crates/runtime/src/services.rs @@ -1365,6 +1365,10 @@ pub struct AdminUsageRecord { pub output_tokens: i64, /// 缓存 token 数。 pub cached_tokens: i64, + /// reasoning token 数。 + pub reasoning_tokens: i64, + /// 上游返回的总 token 数。 + pub total_tokens: i64, /// 图片输入 token 数。 pub image_input_tokens: i64, /// 图片输出 token 数。 @@ -1392,6 +1396,10 @@ pub struct AdminUsageSummary { pub output_tokens: i64, /// 缓存 token 总数。 pub cached_tokens: i64, + /// reasoning token 总数。 + pub reasoning_tokens: i64, + /// 上游返回 token 总数。 + pub total_tokens: i64, /// 图片输入 token 总数。 pub image_input_tokens: i64, /// 图片输出 token 总数。 @@ -1574,7 +1582,7 @@ impl ChatDispatchService { ) .await { - QuotaVerificationDecision::Ready(acquired) => acquired, + QuotaVerificationDecision::Ready(acquired) => *acquired, QuotaVerificationDecision::RetryWithAnotherAccount => { rate_limited_count += 1; last_rate_limit_error = Some(QUOTA_VERIFY_LIMIT_REACHED_MESSAGE.to_string()); @@ -2276,7 +2284,7 @@ impl ResponseDispatchService { ) .await { - QuotaVerificationDecision::Ready(acquired) => acquired, + QuotaVerificationDecision::Ready(acquired) => *acquired, QuotaVerificationDecision::RetryWithAnotherAccount => { rate_limited_count += 1; last_rate_limit_error = Some(QUOTA_VERIFY_LIMIT_REACHED_MESSAGE.to_string()); @@ -2685,6 +2693,10 @@ impl ResponseDispatchService { } } + #[expect( + clippy::too_many_arguments, + reason = "keeps response dispatch logging call sites explicit at branch exits" + )] async fn record_response_dispatch_error( &self, request_id: &str, @@ -2897,7 +2909,7 @@ impl ResponseDispatchService { ) .await { - QuotaVerificationDecision::Ready(acquired) => acquired, + QuotaVerificationDecision::Ready(acquired) => *acquired, QuotaVerificationDecision::RetryWithAnotherAccount => { rate_limited_count += 1; last_rate_limit_error = Some(QUOTA_VERIFY_LIMIT_REACHED_MESSAGE.to_string()); @@ -3259,7 +3271,7 @@ impl ResponseDispatchService { ) .await { - QuotaVerificationDecision::Ready(acquired) => acquired, + QuotaVerificationDecision::Ready(acquired) => *acquired, QuotaVerificationDecision::RetryWithAnotherAccount => { rate_limited_count += 1; last_rate_limit_error = Some(QUOTA_VERIFY_LIMIT_REACHED_MESSAGE.to_string()); @@ -3629,11 +3641,15 @@ impl ResponseDispatchService { } enum QuotaVerificationDecision { - Ready(AcquiredAccount), + Ready(Box), RetryWithAnotherAccount, MaxAttemptsReached, } +#[expect( + clippy::too_many_arguments, + reason = "quota verification needs shared services plus mutable retry state at dispatch loop boundaries" +)] async fn verify_acquired_quota_if_required( account_pool: &RuntimeAccountPoolService, codex: &CodexBackendClient, @@ -3645,7 +3661,7 @@ async fn verify_acquired_quota_if_required( verify_attempts: &mut usize, ) -> QuotaVerificationDecision { if !acquired.account.quota_verify_required { - return QuotaVerificationDecision::Ready(acquired); + return QuotaVerificationDecision::Ready(Box::new(acquired)); } let account_id = acquired.account.id.clone(); @@ -3678,7 +3694,7 @@ async fn verify_acquired_quota_if_required( error = %error, "failed to verify dirty quota before upstream request" ); - return QuotaVerificationDecision::Ready(acquired); + return QuotaVerificationDecision::Ready(Box::new(acquired)); } }; @@ -3694,7 +3710,7 @@ async fn verify_acquired_quota_if_required( return QuotaVerificationDecision::RetryWithAnotherAccount; } - QuotaVerificationDecision::Ready(acquired_with_verified_quota(acquired, "a)) + QuotaVerificationDecision::Ready(Box::new(acquired_with_verified_quota(acquired, "a))) } fn acquired_with_verified_quota(mut acquired: AcquiredAccount, quota: &Value) -> AcquiredAccount { @@ -5761,6 +5777,18 @@ pub struct AdminLogFilter { pub model: Option, /// HTTP 状态码。 pub status_code: Option, + /// 上游传输方式。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, /// 搜索关键词。 pub search: Option, } @@ -5837,6 +5865,10 @@ pub struct AdminAccountService { impl AdminAccountService { /// 构造管理端账号服务。 + #[expect( + clippy::too_many_arguments, + reason = "constructor wires service dependencies from runtime bootstrap" + )] pub fn new( store: SqliteAccountStore, cookies: SqliteCookieStore, @@ -8662,6 +8694,8 @@ impl From for AdminUsageRecord { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cached_tokens: usage.cached_tokens, + reasoning_tokens: usage.reasoning_tokens, + total_tokens: usage.total_tokens, image_input_tokens: usage.image_input_tokens, image_output_tokens: usage.image_output_tokens, image_request_count: usage.image_request_count, @@ -8680,6 +8714,8 @@ impl From for AdminUsageSummary { input_tokens: summary.input_tokens, output_tokens: summary.output_tokens, cached_tokens: summary.cached_tokens, + reasoning_tokens: summary.reasoning_tokens, + total_tokens: summary.total_tokens, image_input_tokens: summary.image_input_tokens, image_output_tokens: summary.image_output_tokens, image_request_count: summary.image_request_count, @@ -8698,6 +8734,12 @@ impl From for EventLogFilter { route: filter.route, model: filter.model, status_code: filter.status_code, + transport: filter.transport, + attempt_index: filter.attempt_index, + upstream_status_code: filter.upstream_status_code, + failure_class: filter.failure_class, + response_id: filter.response_id, + upstream_request_id: filter.upstream_request_id, search: filter.search, } } diff --git a/crates/runtime/tests/account_pool_restore.rs b/crates/runtime/tests/account_pool_restore.rs index 495cd6b05..7065a8312 100644 --- a/crates/runtime/tests/account_pool_restore.rs +++ b/crates/runtime/tests/account_pool_restore.rs @@ -46,7 +46,7 @@ async fn insert_account(pool: &sqlx::SqlitePool, secret_box: &SecretBox, id: &st .encrypt(&SecretString::new(format!("access-{id}").into())) .expect("access token should encrypt"); sqlx::query( - "insert into accounts (id, email, account_id, user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(format!("{id}@example.com")) @@ -109,6 +109,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/runtime/tests/fingerprint.rs b/crates/runtime/tests/fingerprint.rs index 09ad089f2..ad394fb30 100644 --- a/crates/runtime/tests/fingerprint.rs +++ b/crates/runtime/tests/fingerprint.rs @@ -10,20 +10,23 @@ use codex_proxy_platform::config::{ use codex_proxy_platform::crypto::SecretBox; #[tokio::test] -async fn runtime_should_load_latest_auto_updated_fingerprint_when_present() { +async fn runtime_should_seed_and_load_current_fingerprint() { let dir = tempfile::tempdir().expect("temp dir"); let db = dir.path().join("fingerprints.sqlite"); let pool = connect_sqlite(&format!("sqlite://{}", db.display())) .await .expect("sqlite pool"); let repo = FingerprintRepository::new(pool); - repo.upsert_auto_update("26.900.1", "7001", Some("147")) - .await - .expect("insert fingerprint"); + let default_fingerprint = + codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(); - let stored = codex_proxy_runtime::bootstrap::load_runtime_fingerprint(&repo).await; + let stored = + codex_proxy_runtime::bootstrap::load_runtime_fingerprint(&repo, &default_fingerprint) + .await + .expect("load runtime fingerprint"); - assert_eq!(stored.app_version, "26.900.1"); + assert_eq!(stored.app_version, default_fingerprint.app_version); + assert_eq!(stored.default_headers, default_fingerprint.default_headers); } #[tokio::test] @@ -82,6 +85,7 @@ async fn runtime_should_build_state_with_model_service() { force_http11: false, }, ws_pool: Default::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, @@ -159,6 +163,7 @@ async fn runtime_state_should_expose_backend_model_snapshot_through_model_servic force_http11: false, }, ws_pool: Default::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/runtime/tests/session_affinity.rs b/crates/runtime/tests/session_affinity.rs index 8cd9e82a3..c1e115e68 100644 --- a/crates/runtime/tests/session_affinity.rs +++ b/crates/runtime/tests/session_affinity.rs @@ -21,6 +21,7 @@ async fn app_state_should_restore_session_affinity_from_sqlite() { let store = SqliteSessionAffinityStore::new(pool.clone()); let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap(); + insert_account(&pool, "acct_restore").await; store .upsert( "resp_restore", @@ -57,6 +58,16 @@ async fn app_state_should_restore_session_affinity_from_sqlite() { ); } +async fn insert_account(pool: &sqlx::SqlitePool, id: &str) { + sqlx::query( + "insert into accounts (id, access_token_cipher, status, added_at, updated_at) values (?, 'cipher', 'active', '2026-06-18T12:00:00Z', '2026-06-18T12:00:00Z')", + ) + .bind(id) + .execute(pool) + .await + .expect("account should be inserted"); +} + fn test_config(database_url: String) -> AppConfig { AppConfig { server: ServerConfig { @@ -104,6 +115,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/runtime/tests/tasks.rs b/crates/runtime/tests/tasks.rs index bbf85f5f1..56a686008 100644 --- a/crates/runtime/tests/tasks.rs +++ b/crates/runtime/tests/tasks.rs @@ -87,15 +87,16 @@ async fn mount_appcast(server: &MockServer, body: &str) { .await; } -async fn wait_for_auto_updated_fingerprint( +async fn wait_for_current_fingerprint_version( repo: &FingerprintRepository, ) -> codex_proxy_core::gateway::fingerprint::Fingerprint { let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2); loop { if let Some(stored) = repo - .load_latest_auto_updated() + .load_current() .await - .expect("latest auto update should load") + .expect("current fingerprint should load") + .filter(|stored| stored.app_version == "26.900.1") { return stored; } @@ -281,6 +282,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: Default::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/runtime/tests/tasks/cleanup.rs b/crates/runtime/tests/tasks/cleanup.rs index b1af959c1..09d836edd 100644 --- a/crates/runtime/tests/tasks/cleanup.rs +++ b/crates/runtime/tests/tasks/cleanup.rs @@ -85,6 +85,8 @@ async fn session_affinity_cleanup_task_should_delete_only_expired_affinities() { .expect("sqlite pool"); let store = SqliteSessionAffinityStore::new(pool.clone()); let now = Utc::now(); + insert_account(&pool, "expired-account").await; + insert_account(&pool, "active-account").await; store .upsert( "expired-response", diff --git a/crates/runtime/tests/tasks/fingerprint.rs b/crates/runtime/tests/tasks/fingerprint.rs index d5d71f05f..db2c7b619 100644 --- a/crates/runtime/tests/tasks/fingerprint.rs +++ b/crates/runtime/tests/tasks/fingerprint.rs @@ -8,6 +8,11 @@ async fn fingerprint_update_task_should_start_background_checker() { .await .expect("sqlite pool"); let repo = FingerprintRepository::new(pool); + repo.ensure_current_seed( + &codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .await + .expect("seed current fingerprint"); let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( Some(repo), @@ -43,6 +48,11 @@ async fn fingerprint_update_task_should_apply_initial_appcast_update_to_reposito .await .expect("sqlite pool"); let repo = FingerprintRepository::new(pool); + repo.ensure_current_seed( + &codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .await + .expect("seed current fingerprint"); let extracted_path = dir.path().join("extracted-fingerprint.json"); std::fs::write( &extracted_path, @@ -58,7 +68,7 @@ async fn fingerprint_update_task_should_apply_initial_appcast_update_to_reposito "6001".to_string(), ); - let stored = wait_for_auto_updated_fingerprint(&repo).await; + let stored = wait_for_current_fingerprint_version(&repo).await; handle.shutdown().await; assert_eq!( @@ -94,6 +104,11 @@ async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current .await .expect("sqlite pool"); let repo = FingerprintRepository::new(pool); + repo.ensure_current_seed( + &codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(), + ) + .await + .expect("seed current fingerprint"); let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task( Some(repo.clone()), format!("{}/appcast.xml", server.uri()), @@ -104,12 +119,9 @@ async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current wait_for_appcast_requests(&server, 1).await; handle.shutdown().await; - let stored = repo - .load_latest_auto_updated() - .await - .expect("latest auto update should load"); + let latest_history = repo.latest().await.expect("latest history should load"); - assert!(stored.is_none()); + assert!(latest_history.is_none()); } #[tokio::test] diff --git a/crates/runtime/tests/upstream.rs b/crates/runtime/tests/upstream.rs index f0e4807c8..3cd03ab32 100644 --- a/crates/runtime/tests/upstream.rs +++ b/crates/runtime/tests/upstream.rs @@ -15,7 +15,9 @@ use tokio_tungstenite::{ accept_hdr_async_with_config, tungstenite::{ extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, - handshake::server::{Request as WsRequest, Response as WsResponse}, + handshake::server::{ + Callback, ErrorResponse, Request as WsRequest, Response as WsResponse, + }, protocol::WebSocketConfig, Message, }, @@ -113,15 +115,41 @@ fn websocket_accept_config() -> WebSocketConfig { async fn accept_runtime_test_websocket( stream: TcpStream, ) -> tokio_tungstenite::WebSocketStream { + accept_runtime_test_websocket_with(stream, |_request, response| { + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + }) + .await +} + +struct TestWebSocketCallback(F); + +impl Callback for TestWebSocketCallback +where + F: FnOnce(&WsRequest, &mut WsResponse) + Unpin, +{ + fn on_request( + self, + request: &WsRequest, + mut response: WsResponse, + ) -> Result { + (self.0)(request, &mut response); + Ok(response) + } +} + +async fn accept_runtime_test_websocket_with( + stream: TcpStream, + callback: F, +) -> tokio_tungstenite::WebSocketStream +where + F: FnOnce(&WsRequest, &mut WsResponse) + Unpin, +{ accept_hdr_async_with_config( stream, - |_request: &WsRequest, mut response: WsResponse| { - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().unwrap(), - ); - Ok(response) - }, + TestWebSocketCallback(callback), Some(websocket_accept_config()), ) .await diff --git a/crates/server/src/admin_api/logs/query.rs b/crates/server/src/admin_api/logs/query.rs index 613ffa9ae..9f79dc4c1 100644 --- a/crates/server/src/admin_api/logs/query.rs +++ b/crates/server/src/admin_api/logs/query.rs @@ -40,6 +40,18 @@ pub struct LogsQuery { pub model: Option, /// HTTP 状态码。 pub status_code: Option, + /// 上游传输方式。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, /// 搜索关键词。 pub search: Option, } @@ -77,6 +89,12 @@ fn filter_from_query(query: LogsQuery, request_id: &str) -> Result for AdminUsageStatsData { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cached_tokens: usage.cached_tokens, + reasoning_tokens: usage.reasoning_tokens, + total_tokens: usage.total_tokens, image_input_tokens: usage.image_input_tokens, image_output_tokens: usage.image_output_tokens, image_request_count: usage.image_request_count, @@ -177,6 +187,8 @@ impl From for AdminUsageStatsSummaryData { input_tokens: summary.input_tokens, output_tokens: summary.output_tokens, cached_tokens: summary.cached_tokens, + reasoning_tokens: summary.reasoning_tokens, + total_tokens: summary.total_tokens, image_input_tokens: summary.image_input_tokens, image_output_tokens: summary.image_output_tokens, image_request_count: summary.image_request_count, diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index bd0ef79be..cc4a93376 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -9,6 +9,7 @@ use codex_proxy_platform::{ storage::{connect_sqlite, ensure_data_dir, load_or_create_installation_id}, }; use codex_proxy_runtime::{ + bootstrap::{fingerprint_from_config, load_runtime_fingerprint_from_pool}, state::AppState, tasks::coordinator::{start_background_tasks, BackgroundTaskCoordinator}, }; @@ -43,12 +44,16 @@ async fn build_application( let default_admin_password = config.admin.default_password.clone(); let data_dir = ensure_data_dir()?; let installation_id = load_or_create_installation_id(Some(&data_dir))?; - let state = AppState::with_pool_secret_api_key_hasher_and_installation_id( + let default_fingerprint = fingerprint_from_config(&config.fingerprint); + let runtime_fingerprint = + load_runtime_fingerprint_from_pool(pool.clone(), &default_fingerprint).await?; + let state = AppState::with_pool_secret_api_key_hasher_fingerprint_and_installation_id( config, pool, secret_box, api_key_hasher, - installation_id, + runtime_fingerprint, + Some(installation_id), ); let created_default_admin = state .services diff --git a/crates/server/tests/admin_accounts_routes.rs b/crates/server/tests/admin_accounts_routes.rs index def72a855..167e1aa90 100644 --- a/crates/server/tests/admin_accounts_routes.rs +++ b/crates/server/tests/admin_accounts_routes.rs @@ -363,6 +363,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs index 100a6ca1d..1f2a97edb 100644 --- a/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_import_export.rs @@ -66,6 +66,12 @@ async fn admin_accounts_import_should_store_native_account_tokens() { let pool = connect_sqlite(&url).await.unwrap(); seed_admin_session(&pool, "session_1").await; let secret_box = SecretBox::new([79u8; 32]); + let access_token = test_jwt( + "chatgpt_import", + Some("user_import"), + Some("import@example.com"), + Some("team"), + ); let state = AppState::with_pool_secret_and_api_key_hasher( test_config(url), pool.clone(), @@ -91,7 +97,7 @@ async fn admin_accounts_import_should_store_native_account_tokens() { "userId": "user_import", "label": "secondary", "planType": "team", - "token": "access-import", + "token": access_token.clone(), "refreshToken": "refresh-import", "accessTokenExpiresAt": "2026-06-18T02:00:00Z", "status": "disabled" @@ -116,7 +122,7 @@ async fn admin_accounts_import_should_store_native_account_tokens() { assert_eq!(body["data"]["imported"], 1); assert_eq!(body["data"]["skipped"], 0); assert_eq!(body["data"]["sourceFormat"], "native"); - assert_eq!(stored.access_token.expose_secret(), "access-import"); + assert_eq!(stored.access_token.expose_secret(), access_token); assert_eq!( stored.refresh_token.unwrap().expose_secret(), "refresh-import" @@ -358,7 +364,6 @@ async fn admin_accounts_import_should_expire_sub2api_account_when_token_is_expir "label": "expired-label", "planType": "free", "token": expired_access_token, - "refreshToken": "expired-refresh", "status": "active", "cachedQuota": { "rate_limit": { @@ -436,6 +441,12 @@ async fn admin_accounts_import_should_require_admin_session_cookie() { async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() { let (app, _state, pool, _dir, _secret_box) = admin_accounts_test_app("admin-accounts-import-sanitized.sqlite", 113).await; + let access_token = test_jwt( + "chatgpt-account", + Some("chatgpt-user"), + Some("user@example.com"), + Some("plus"), + ); let response = app .clone() @@ -455,7 +466,7 @@ async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_ "userId": "chatgpt-user", "label": "primary", "planType": "plus", - "token": "access-secret", + "token": access_token.clone(), "refreshToken": "refresh-secret", "status": "active" }] @@ -480,7 +491,7 @@ async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_ .await .unwrap(); assert!(stored.0.starts_with("v1:")); - assert!(!stored.0.contains("access-secret")); + assert!(!stored.0.contains(&access_token)); assert!(stored.1.starts_with("v1:")); assert!(!stored.1.contains("refresh-secret")); diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs index 9bdf5e96c..2f744f17f 100644 --- a/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_oauth.rs @@ -157,6 +157,12 @@ async fn admin_auth_logout_should_clear_accounts_usage_cookies_and_runtime_pool( let pool = connect_sqlite(&url).await.unwrap(); seed_admin_session(&pool, "session_1").await; let secret_box = SecretBox::new([109u8; 32]); + let access_token = test_jwt( + "chatgpt-auth-logout", + Some("user-auth-logout"), + Some("logout@example.com"), + Some("plus"), + ); let state = AppState::with_pool_secret_and_api_key_hasher( test_config(url), pool.clone(), @@ -178,7 +184,7 @@ async fn admin_auth_logout_should_clear_accounts_usage_cookies_and_runtime_pool( "id": "acct_auth_logout", "email": "logout@example.com", "planType": "plus", - "token": "access-auth-logout", + "token": access_token, "refreshToken": "refresh-auth-logout", "status": "active" }] @@ -426,11 +432,12 @@ async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host() "http://codex.local:1455/" ); assert_eq!(exchange_calls.lock().await[0].code, "callback-code"); - let count: (i64,) = sqlx::query_as("select count(*) from accounts where account_id = ?") - .bind("callback-account") - .fetch_one(&pool) - .await - .unwrap(); + let count: (i64,) = + sqlx::query_as("select count(*) from accounts where chatgpt_account_id = ?") + .bind("callback-account") + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(count.0, 1); } diff --git a/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs b/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs index 5eb63bbba..2668bfdde 100644 --- a/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs +++ b/crates/server/tests/admin_accounts_routes/admin_accounts_quota.rs @@ -22,7 +22,7 @@ async fn admin_usage_stats_should_return_page_and_summary() { .await .unwrap(); sqlx::query( - "insert into account_usage (account_id, request_count, empty_response_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, last_used_at) values (?, 3, 1, 21, 8, 5, 7, 2, 1, 0, ?)", + "insert into account_usage (account_id, request_count, empty_response_count, input_tokens, output_tokens, cached_tokens, reasoning_tokens, total_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, last_used_at) values (?, 3, 1, 21, 8, 5, 4, 29, 7, 2, 1, 0, ?)", ) .bind("acct_usage") .bind("2026-06-18T00:10:00Z") @@ -58,6 +58,8 @@ async fn admin_usage_stats_should_return_page_and_summary() { assert_eq!(page_body["data"][0]["email"], "usage@example.com"); assert_eq!(page_body["data"][0]["requestCount"], 3); assert_eq!(page_body["data"][0]["inputTokens"], 21); + assert_eq!(page_body["data"][0]["reasoningTokens"], 4); + assert_eq!(page_body["data"][0]["totalTokens"], 29); let summary_response = app .oneshot( @@ -78,6 +80,8 @@ async fn admin_usage_stats_should_return_page_and_summary() { assert_eq!(summary_body["data"]["accountCount"], 1); assert_eq!(summary_body["data"]["requestCount"], 3); assert_eq!(summary_body["data"]["outputTokens"], 8); + assert_eq!(summary_body["data"]["reasoningTokens"], 4); + assert_eq!(summary_body["data"]["totalTokens"], 29); } #[tokio::test] diff --git a/crates/server/tests/admin_client_keys_routes.rs b/crates/server/tests/admin_client_keys_routes.rs index d747e8965..76d054151 100644 --- a/crates/server/tests/admin_client_keys_routes.rs +++ b/crates/server/tests/admin_client_keys_routes.rs @@ -138,6 +138,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/admin_logs_routes.rs b/crates/server/tests/admin_logs_routes.rs index 7bd90ac4c..8b8b580f1 100644 --- a/crates/server/tests/admin_logs_routes.rs +++ b/crates/server/tests/admin_logs_routes.rs @@ -445,6 +445,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/admin_models_routes.rs b/crates/server/tests/admin_models_routes.rs index 357233569..0ed345456 100644 --- a/crates/server/tests/admin_models_routes.rs +++ b/crates/server/tests/admin_models_routes.rs @@ -282,6 +282,7 @@ fn test_config(database_url: String, base_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/admin_session_routes.rs b/crates/server/tests/admin_session_routes.rs index 85bc648e6..087d7e957 100644 --- a/crates/server/tests/admin_session_routes.rs +++ b/crates/server/tests/admin_session_routes.rs @@ -201,6 +201,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 60, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/admin_settings_routes.rs b/crates/server/tests/admin_settings_routes.rs index a9c80a17c..09df60f01 100644 --- a/crates/server/tests/admin_settings_routes.rs +++ b/crates/server/tests/admin_settings_routes.rs @@ -389,6 +389,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/assets_routes.rs b/crates/server/tests/assets_routes.rs index f6d08037e..244ed4549 100644 --- a/crates/server/tests/assets_routes.rs +++ b/crates/server/tests/assets_routes.rs @@ -138,6 +138,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/openai_chat_upstream.rs b/crates/server/tests/openai_chat_upstream.rs index 266e6d3e1..8d44a85cc 100644 --- a/crates/server/tests/openai_chat_upstream.rs +++ b/crates/server/tests/openai_chat_upstream.rs @@ -47,7 +47,9 @@ use tokio_tungstenite::{ accept_hdr_async_with_config, tungstenite::{ extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, - handshake::server::{Callback, Request as WsRequest, Response as WsResponse}, + handshake::server::{ + Callback, ErrorResponse, Request as WsRequest, Response as WsResponse, + }, protocol::WebSocketConfig, Error as WsError, Message, }, @@ -71,25 +73,35 @@ fn websocket_accept_config() -> WebSocketConfig { } async fn accept_async(stream: TcpStream) -> Result, WsError> { - accept_hdr_async(stream, |_request: &WsRequest, response: WsResponse| { - Ok(response) - }) - .await + accept_hdr_async(stream, |_request, _response| {}).await } -async fn accept_hdr_async( +struct TestWebSocketCallback(F); + +impl Callback for TestWebSocketCallback +where + F: FnOnce(&WsRequest, &mut WsResponse) + Unpin, +{ + fn on_request( + self, + request: &WsRequest, + mut response: WsResponse, + ) -> Result { + (self.0)(request, &mut response); + Ok(response) + } +} + +async fn accept_hdr_async( stream: TcpStream, - callback: C, + callback: F, ) -> Result, WsError> where - C: Callback + Unpin, + F: FnOnce(&WsRequest, &mut WsResponse) + Unpin, { accept_hdr_async_with_config( stream, - move |request: &WsRequest, response: WsResponse| { - let response = callback.on_request(request, response)?; - Ok(response) - }, + TestWebSocketCallback(callback), Some(websocket_accept_config()), ) .await @@ -524,7 +536,7 @@ async fn insert_account(pool: &SqlitePool, secret_box: &SecretBox) { .encrypt(&SecretString::new("access-secret".to_string().into())) .unwrap(); sqlx::query( - "insert into accounts (id, email, account_id, user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("acct_chat") .bind("user@example.com") @@ -553,7 +565,7 @@ async fn insert_named_account( )) .unwrap(); sqlx::query( - "insert into accounts (id, email, account_id, user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(format!("{id}@example.com")) @@ -617,10 +629,6 @@ struct HistoryRecoveryCapture { second_ws_payload: Value, } -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn spawn_single_websocket_completed_upstream( response_id: &'static str, ) -> (String, tokio::task::JoinHandle) { @@ -630,19 +638,15 @@ async fn spawn_single_websocket_completed_upstream( let (stream, _) = listener.accept().await.unwrap(); let captured_headers = Arc::new(Mutex::new(Vec::new())); let captured_headers_for_callback = Arc::clone(&captured_headers); - let mut websocket = accept_hdr_async( - stream, - move |request: &WsRequest, mut response: WsResponse| { - *captured_headers_for_callback.lock().unwrap() = request_headers(request); - response - .headers_mut() - .insert("x-ratelimit-limit-requests", "55".parse().unwrap()); - response - .headers_mut() - .insert("x-codex-primary-used-percent", "44".parse().unwrap()); - Ok(response) - }, - ) + let mut websocket = accept_hdr_async(stream, move |request, response| { + *captured_headers_for_callback.lock().unwrap() = request_headers(request); + response + .headers_mut() + .insert("x-ratelimit-limit-requests", "55".parse().unwrap()); + response + .headers_mut() + .insert("x-codex-primary-used-percent", "44".parse().unwrap()); + }) .await .unwrap(); let message = websocket.next().await.unwrap().unwrap(); @@ -661,10 +665,6 @@ async fn spawn_single_websocket_completed_upstream( (base_url, server) } -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn spawn_websocket_failure_then_websocket_success_upstream( failure: String, ) -> (String, tokio::task::JoinHandle) { @@ -674,13 +674,9 @@ async fn spawn_websocket_failure_then_websocket_success_upstream( let (first_stream, _) = listener.accept().await.unwrap(); let first_headers = Arc::new(Mutex::new(Vec::new())); let first_headers_for_callback = Arc::clone(&first_headers); - let mut first_websocket = accept_hdr_async( - first_stream, - move |request: &WsRequest, response: WsResponse| { - *first_headers_for_callback.lock().unwrap() = request_headers(request); - Ok(response) - }, - ) + let mut first_websocket = accept_hdr_async(first_stream, move |request, _response| { + *first_headers_for_callback.lock().unwrap() = request_headers(request); + }) .await .unwrap(); let first_message = first_websocket.next().await.unwrap().unwrap(); @@ -695,13 +691,9 @@ async fn spawn_websocket_failure_then_websocket_success_upstream( let (second_stream, _) = listener.accept().await.unwrap(); let second_headers = Arc::new(Mutex::new(Vec::new())); let second_headers_for_callback = Arc::clone(&second_headers); - let mut second_websocket = accept_hdr_async( - second_stream, - move |request: &WsRequest, response: WsResponse| { - *second_headers_for_callback.lock().unwrap() = request_headers(request); - Ok(response) - }, - ) + let mut second_websocket = accept_hdr_async(second_stream, move |request, _response| { + *second_headers_for_callback.lock().unwrap() = request_headers(request); + }) .await .unwrap(); let second_message = second_websocket.next().await.unwrap().unwrap(); @@ -727,10 +719,6 @@ async fn spawn_websocket_failure_then_websocket_success_upstream( (base_url, server) } -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn spawn_chunked_websocket_upstream() -> ( String, oneshot::Receiver<()>, @@ -745,13 +733,11 @@ async fn spawn_chunked_websocket_upstream() -> ( let (stream, _) = listener.accept().await.unwrap(); let captured_headers = Arc::new(Mutex::new(Vec::new())); let captured_headers_for_callback = Arc::clone(&captured_headers); - let mut websocket = - accept_hdr_async(stream, move |request: &WsRequest, response: WsResponse| { - *captured_headers_for_callback.lock().unwrap() = request_headers(request); - Ok(response) - }) - .await - .unwrap(); + let mut websocket = accept_hdr_async(stream, move |request, _response| { + *captured_headers_for_callback.lock().unwrap() = request_headers(request); + }) + .await + .unwrap(); let message = websocket.next().await.unwrap().unwrap(); let payload = serde_json::from_str::(&message.into_text().unwrap()) .expect("websocket payload should be json"); @@ -902,15 +888,11 @@ async fn accept_websocket_response_with_authorization_and_message( payload } -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn accept_websocket_with_authorization( stream: TcpStream, expected_authorization: &'static str, ) -> tokio_tungstenite::WebSocketStream { - accept_hdr_async(stream, move |request: &WsRequest, response: WsResponse| { + accept_hdr_async(stream, move |request, _response| { assert_eq!( request .headers() @@ -918,7 +900,6 @@ async fn accept_websocket_with_authorization( .and_then(|value| value.to_str().ok()), Some(expected_authorization) ); - Ok(response) }) .await .unwrap() @@ -1583,6 +1564,7 @@ fn test_config(database_url: String, base_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs b/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs index 4f926fd52..091b6d649 100644 --- a/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs +++ b/crates/server/tests/openai_chat_upstream/openai_responses_websocket.rs @@ -403,10 +403,6 @@ async fn responses_websocket_should_not_reuse_connection_when_pool_is_disabled() } #[tokio::test] -#[expect( - clippy::result_large_err, - reason = "tokio-tungstenite handshake callbacks use a large error response type" -)] async fn responses_websocket_stream_should_record_metadata_turn_state_for_continuation() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -440,13 +436,9 @@ async fn responses_websocket_stream_should_record_metadata_turn_state_for_contin let (second_stream, _) = listener.accept().await.unwrap(); let second_headers = Arc::new(Mutex::new(Vec::new())); let second_headers_for_callback = Arc::clone(&second_headers); - let mut second_websocket = accept_hdr_async( - second_stream, - move |request: &WsRequest, response: WsResponse| { - *second_headers_for_callback.lock().unwrap() = request_headers(request); - Ok(response) - }, - ) + let mut second_websocket = accept_hdr_async(second_stream, move |request, _response| { + *second_headers_for_callback.lock().unwrap() = request_headers(request); + }) .await .unwrap(); let second_message = second_websocket.next().await.unwrap().unwrap(); diff --git a/crates/server/tests/openai_diagnostics_routes.rs b/crates/server/tests/openai_diagnostics_routes.rs index 726989b7f..6adb11b93 100644 --- a/crates/server/tests/openai_diagnostics_routes.rs +++ b/crates/server/tests/openai_diagnostics_routes.rs @@ -306,6 +306,7 @@ fn test_config(database_url: String, base_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/openai_models_auth.rs b/crates/server/tests/openai_models_auth.rs index d5c7e7bdf..0430ef66a 100644 --- a/crates/server/tests/openai_models_auth.rs +++ b/crates/server/tests/openai_models_auth.rs @@ -67,6 +67,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/crates/server/tests/openai_responses_routes.rs b/crates/server/tests/openai_responses_routes.rs index c6f2c5bfc..11a321819 100644 --- a/crates/server/tests/openai_responses_routes.rs +++ b/crates/server/tests/openai_responses_routes.rs @@ -227,6 +227,7 @@ fn test_config(database_url: String) -> AppConfig { force_http11: false, }, ws_pool: WebSocketPoolConfig::default(), + fingerprint: Default::default(), admin: AdminConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, diff --git a/docs/database-schema-audit.md b/docs/database-schema-audit.md new file mode 100644 index 000000000..ec1b0cf6e --- /dev/null +++ b/docs/database-schema-audit.md @@ -0,0 +1,890 @@ +# 数据库表设计与字段命名审计 + +审计日期:2026-06-20 + +本文档只审计当前 Rust 项目的 SQLite 表设计、字段设计、字段冗余和后续指纹持久化方向。本文不包含真实账号、token、cookie、API key 等敏感数据。 + +## 目标 + +- 把当前表结构中不够清晰、不够稳定、容易制造重复数据或迁移债的问题固定下来。 +- 给出一套可以长期维护的命名规范和表职责边界。 +- 明确本轮指纹设计:`config.yaml` 只作为首次默认种子,数据库持久化后以数据库为准;后续自动更新只写数据库,运行时只从数据库加载。 +- 保持项目定位为 OpenAI / Codex 相关能力,不引入通用 proxy、VPN 或其它 provider 的泛化字段。 + +## 本轮实施结果 + +项目尚未上线,本轮按“新库最终结构”直接迁移,不写旧结构兼容代码。已经落地的结构变更: + +- `schema.sql` 成为新库最终结构,移除 `connect_sqlite()` 里的 ad hoc 轻量补丁逻辑。 +- 新增 `schema_migrations` 表作为后续上线后的迁移账本。 +- `accounts` 上游身份物理列改为 `chatgpt_account_id` / `chatgpt_user_id`,并增加 `ux_accounts_chatgpt_identity` 唯一索引。 +- `account_usage` 补齐并贯通 `reasoning_tokens`、`total_tokens`,协议解析、仓储写入、管理端列表和汇总都使用同一语义。 +- `fingerprints` 使用固定当前槽位 `id = 'current'`,完整保存 `originator`、默认请求头、请求头顺序和更新时间;`config.yaml` 只做首次种子。 +- `fingerprint_update_history` 单独保存自动更新历史,不参与运行时当前指纹选择。 +- `session_affinities.account_id` 改为本地账号外键并级联删除。 +- `event_logs` 补齐 `transport`、`attempt_index`、`upstream_status_code`、`failure_class`、`response_id`、`upstream_request_id` 顶层排查字段,并保留 `metadata_json` 作为补充上下文。 + +仍然暂不拆 `accounts`、`account_usage` 等大表。拆表属于 P2 长期清洁项,等功能边界继续稳定后再做,避免为了形式拆表制造更多搬运代码。 + +## 审计基线 + +改造前 schema 已经能支撑基础运行,但还不是“教科书级”的表设计。主要问题不是缺表,而是职责边界和命名语义混在一起: + +1. `accounts` 同时承载账号身份、token、刷新计划、配额快照、配额冷却、Cloudflare 恢复状态,字段越来越像“运行时大杂烩”。 +2. `accounts.account_id` / `accounts.user_id` 命名过泛。它们实际是 ChatGPT/OpenAI 上游身份,不应该和本地账号主键 `id` 混淆。 +3. 上游账号身份只有普通索引,没有唯一约束。导入相同上游账号时,数据库层不能阻止重复记录。 +4. `schema.sql` 和实际运行库曾经出现 drift:运行库 `account_usage` 有 `reasoning_tokens`、`total_tokens`,但 `schema.sql` 没有;`session_affinities.function_call_ids_json` 的默认值表达也不一致。 +5. 迁移机制还是 ad hoc:`connect_sqlite()` 运行 `schema.sql` 后只补一个 `accounts.next_refresh_at`。缺少 `schema_migrations` 这种可追踪迁移账本。 +6. `fingerprints` 表只存了部分指纹字段。`originator`、默认请求头、请求头顺序仍从代码默认值补齐,不满足“后续都去数据库里拿”。 +7. 时间字段命名不统一:`added_at` 和 `created_at` 混用;`fingerprints.created_at` 在 upsert 时被覆盖,语义更像 `updated_at`。 +8. 多处 JSON 字段承担外部快照或小型结构化状态,这可以接受,但必须有命名、校验和索引边界,不能让核心查询长期依赖 `metadata_json like ...`。 + +## 表清单 + +| 表 | 当前职责 | 总体判断 | +| --- | --- | --- | +| `admin_users` | 管理员账号 | 简单可用,命名基本清晰 | +| `admin_sessions` | 管理员登录会话 | 简单可用 | +| `client_api_keys` | 客户端访问 key | 可用,但 `name` / `label` 语义重叠,唯一约束不足 | +| `accounts` | OpenAI/ChatGPT 账号、token、刷新、配额、恢复状态 | 已完成上游身份重命名和唯一约束;后续再评估拆表 | +| `account_refresh_leases` | 多任务刷新租约 | 职责清晰,字段可小幅命名优化 | +| `account_usage` | 账号累计用量和窗口用量 | 已补齐 reasoning/total;拆 totals/windows 属于 P2 | +| `account_cookies` | 账号级 cookie 持久化 | 基本合理,`id` 可选冗余 | +| `fingerprints` | Codex Desktop 请求指纹版本 | 已补齐当前槽位完整指纹字段 | +| `event_logs` | 管理端结构化事件日志 | 已补齐常用链路排查字段 | +| `model_plan_snapshots` | 套餐模型列表快照 | 简单可用 | +| `session_affinities` | response/session 到账号的亲和性 | 已补账号外键和默认值一致性 | + +## 命名规范 + +后续新字段和迁移建议遵守以下规则。 + +### 标识符 + +- 本地表主键统一用 `id`。 +- 外部系统 ID 必须加来源前缀,不使用裸 `account_id` / `user_id`: + - 推荐:`chatgpt_account_id`、`chatgpt_user_id` + - 可接受:`openai_account_id`、`openai_user_id` + - 不推荐:`account_id`、`user_id`,因为它们和本地外键语义冲突。 +- 外键字段使用被引用实体名加 `_id`,例如 `account_id` 表示本地 `accounts.id`,不是上游账号 ID。 + +### 时间字段 + +- 时间字段统一使用 RFC3339 UTC 字符串,字段名以 `_at` 或 `_until` 结尾。 +- 创建时间统一 `created_at`,更新时间统一 `updated_at`。 +- `added_at` 应逐步迁移为 `created_at`。 +- 如果字段表示“未来允许执行时间”,命名用 `next_*_at`,例如 `next_refresh_at`。 +- 如果字段表示“冷却截止时间”,命名用 `*_cooldown_until`,当前命名可保留。 + +### 数值字段 + +- 计数用 `_count`,token 用 `_tokens`,毫秒用 `_ms`,秒用 `_seconds`。 +- 累计总量和窗口总量不要混在命名里: + - 累计:`request_count`、`input_tokens` + - 窗口:`window_request_count`、`window_input_tokens` +- 如果保存 `total_tokens`,必须定义为“上游返回的原始 total”,不要同时把它当作 `input_tokens + output_tokens` 的派生值。 + +### 布尔字段 + +- SQLite 仍用 `integer check (value in (0, 1))`。 +- 命名应表达肯定语义: + - `enabled` 可接受,但 `is_enabled` 更清晰。 + - `quota_verify_required` 建议迁移为 `requires_quota_verification`。 + - `quota_limit_reached` 可接受。 + +### 密文和 JSON + +- 密文字段建议统一用 `_ciphertext`,比 `_cipher` 更准确。 + - 当前字段 `access_token_cipher`、`refresh_token_cipher`、`value_cipher` 可先保留,后续大迁移再统一。 +- JSON 字段必须以 `_json` 结尾。 +- `_json` 字段只用于以下情况: + - 外部接口原始快照,短期不做复杂查询。 + - 小型原子结构,作为整体读写,例如请求头顺序。 + - 事件元数据,作为补充上下文。 +- 需要筛选、排序、聚合的字段不要长期只放在 JSON 里。 + +## 全局问题 + +### 1. 缺少迁移账本 + +改造前启动流程: + +1. `connect_sqlite()` 创建连接。 +2. 执行 `schema.sql`。 +3. 执行 `apply_lightweight_migrations()`。 +4. 只补 `accounts.next_refresh_at`。 + +问题: + +- 迁移没有版本号,不知道一个运行库应用过哪些结构变更。 +- 迁移散在 Rust 函数里,后续越补越难审计。 +- `schema.sql` 与运行库 drift 时,没有统一入口修正。 + +本轮处理: + +- 项目未上线,直接让 `schema.sql` 表达最终新库结构。 +- 移除 Rust 里的 ad hoc lightweight migration。 +- 增加 `schema_migrations` 表,为上线后的版本化迁移做准备。 + +后续上线后建议: + +```sql +create table if not exists schema_migrations ( + version integer primary key, + name text not null, + applied_at text not null +); +``` + +后续每个结构变更用单独版本: + +```text +0001_initial_schema +0002_accounts_next_refresh_at +0003_account_usage_reasoning_total_tokens +0004_fingerprint_full_profile +0005_accounts_chatgpt_identity_unique +``` + +`schema.sql` 保持“新库最终结构”,迁移脚本负责“旧库升级路径”。本轮不写旧库升级路径。 + +### 2. 运行库和 schema.sql 曾经 drift + +改造前观察到的 drift: + +- 实际运行库 `account_usage` 有 `reasoning_tokens`、`total_tokens`。 +- `schema.sql` 的 `account_usage` 没有这两个字段。 +- 实际运行库 `session_affinities.function_call_ids_json` 默认值显示为 `"[]"`。 +- `schema.sql` 写的是 `'[]'`。 + +风险: + +- 新装库、老运行库、测试库结构不一致。 +- 代码一旦开始读写这些字段,某些环境会失败。 +- 审计数据库时无法判断哪个结构才是事实来源。 + +本轮处理: + +- `schema.sql` 已直接补齐新库最终字段。 +- `account_usage.reasoning_tokens` / `account_usage.total_tokens` 已从协议解析贯通到 SQLite 写入、管理端列表和汇总。 +- 平台层 schema 测试已覆盖关键列、唯一索引和外键。 + +### 3. 账号唯一身份需要数据库兜底 + +改造前只有普通身份索引: + +```sql +create index on accounts(account_id, user_id) +where account_id is not null; +``` + +这是普通索引,不是唯一索引。它能加速查找,但不能阻止重复。 + +真实问题已经出现:同一个上游账号身份导入后出现两条本地记录,而不是更新。 + +本轮已落地: + +```sql +create unique index if not exists ux_accounts_chatgpt_identity +on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, '')) +where chatgpt_account_id is not null; +``` + +本次没有旧线上库,不做重复数据兼容迁移。如果本地 `.runtime` 已有旧表,直接重建运行库。 + +导入适配器也必须遵守同一套身份优先级: + +1. 优先用 `chatgpt_account_id + chatgpt_user_id` 更新。 +2. 其次用导入文件中的稳定本地 `id` 更新。 +3. 仅邮箱不可作为唯一身份,只能作为展示或弱匹配辅助。 + +## 逐表审计 + +### `admin_users` + +改造前字段: + +```text +id, password_hash, created_at, updated_at +``` + +判断:职责清晰。 + +建议: + +- 保持当前结构即可。 +- 如果后续支持多管理员,可以补 `is_enabled`、`last_login_at`。 +- `id` 当前如果承载用户名,需要明确它就是登录名;如果未来支持独立用户 ID,应新增 `username` 并迁移。 + +### `admin_sessions` + +当前字段: + +```text +id, user_id, expires_at, created_at +``` + +判断:职责清晰。 + +建议: + +- `user_id references admin_users(id) on delete cascade` 是正确方向。 +- 可选补 `last_seen_at`,但不是当前必须项。 + +### `client_api_keys` + +当前字段: + +```text +id, name, prefix, key_hash, label, enabled, created_at, last_used_at +``` + +问题: + +- `name` 和 `label` 语义重叠。一个面向机器标识,一个面向展示标签时可以共存,但当前文档和字段名没有说明。 +- `prefix` 只有 enabled 条件索引,没有唯一约束。 +- `key_hash` 没有唯一约束。 +- 缺 `updated_at`,管理端修改名称、禁用 key 后无法统一排序或审计。 + +建议: + +- 如果只保留一个展示名,保留 `name`,删除或废弃 `label`。 +- 如果两个都保留,定义清楚: + - `name`:用户可见名称,必填。 + - `label`:短标签或分组,可选。 +- 添加: + +```sql +create unique index if not exists ux_client_api_keys_key_hash +on client_api_keys(key_hash); +``` + +- `enabled` 可后续迁移为 `is_enabled`。 +- 补 `updated_at`、`revoked_at` 会更完整。 + +### `accounts` + +当前字段职责混合: + +```text +id +email, chatgpt_account_id, chatgpt_user_id, label, plan_type +access_token_cipher, refresh_token_cipher, access_token_expires_at, next_refresh_at +status +quota_json, quota_fetched_at, quota_limit_reached, quota_verify_required, quota_cooldown_until +cloudflare_cooldown_until +added_at, updated_at +``` + +已处理问题: + +- 上游身份物理列已改为 `chatgpt_account_id` / `chatgpt_user_id`。 +- 数据库已增加 `ux_accounts_chatgpt_identity` 唯一索引,防止同一上游身份重复导入。 +- 仓储 SQL 用 alias 继续向当前 Rust 领域结构返回 `account_id` / `user_id`,后续再做 Rust 字段大改名。 + +仍然存在的问题: + +- `added_at` 和其它表的 `created_at` 不统一。 +- token、刷新调度、配额、Cloudflare 恢复状态都塞在账号主表里。 +- `status` 同时表达可服务状态、刷新状态、终态禁用状态,需要明确状态机。 + +建议命名: + +| 当前字段 | 建议字段 | 说明 | +| --- | --- | --- | +| `account_id` | `chatgpt_account_id` | 对应请求头 `chatgpt-account-id` 和上游账号身份 | +| `user_id` | `chatgpt_user_id` | 上游用户身份 | +| `added_at` | `created_at` | 与全库统一 | +| `access_token_cipher` | `access_token_ciphertext` | 后续大迁移再统一 | +| `refresh_token_cipher` | `refresh_token_ciphertext` | 后续大迁移再统一 | +| `quota_verify_required` | `requires_quota_verification` | 布尔语义更自然 | + +本轮方案: + +- 不拆表,先把数据库物理列名和唯一约束改干净。 +- Rust 领域结构暂时保留 `account_id` / `user_id`,仓储层用 SQL alias 做边界转换;这不是旧库兼容代码,而是避免本轮把本地外键 `account_id` 和上游身份重命名混在一起造成无意义 churn。 + +长期目标拆分: + +```text +accounts + id + email + label + plan_type + status + chatgpt_account_id + chatgpt_user_id + created_at + updated_at + +account_tokens + account_id + access_token_ciphertext + refresh_token_ciphertext + access_token_expires_at + next_refresh_at + token_updated_at + +account_quota_state + account_id + quota_json + quota_fetched_at + quota_limit_reached + requires_quota_verification + quota_cooldown_until + updated_at + +account_recovery_state + account_id + cloudflare_cooldown_until + updated_at +``` + +不是所有拆分都要一次做。优先级是: + +1. 先加唯一身份约束,解决重复导入。 +2. 再补 `created_at` 命名兼容。 +3. 最后考虑 token/quota/recovery 拆表。 + +状态机建议: + +| 状态 | 语义 | +| --- | --- | +| `active` | access token 当前可用于请求 | +| `expired` | access token 不可用,但 refresh token 可能还能自救 | +| `refreshing` | 临时刷新中,必须有租约或短期超时兜底 | +| `quota_exhausted` | 当前配额不可用,但不是账号封禁 | +| `disabled` | 本地确认不可调度,例如 refresh token 永久失效 | +| `banned` | 上游封禁或账号停用,不可再参与刷新和请求 | + +关键约束: + +- `disabled` / `banned` 启动时不应重新进入刷新调度。 +- `expired` 是否参与恢复,必须同时看 `refresh_token_ciphertext` 和 `next_refresh_at`。 +- `next_refresh_at` 到达前,启动扫描和后台扫描都不应刷新。 + +### `account_refresh_leases` + +当前字段: + +```text +account_id, owner, expires_at, updated_at +``` + +判断:职责清晰。 + +建议: + +- `account_id` 在这张表里表示本地账号外键,命名正确。 +- `owner` 可以更名为 `lease_owner`,但不是必须。 +- 保持 `account_id primary key references accounts(id) on delete cascade`。 + +### `account_usage` + +当前 schema.sql 字段: + +```text +account_id +request_count, empty_response_count +input_tokens, output_tokens, cached_tokens +reasoning_tokens, total_tokens +image_input_tokens, image_output_tokens +image_request_count, image_request_failed_count +window_* +window_started_at, window_reset_at, limit_window_seconds +last_used_at +``` + +问题: + +- 累计用量和窗口用量在同一行,写入路径集中,字段数量会继续膨胀。 +- `total_tokens` 如果只是 `input_tokens + output_tokens`,就是冗余;如果保存上游原始 `total_tokens`,必须在字段注释和代码里明确。 +- 图片用量、reasoning 用量、窗口用量混在一张表,后续每加一个计量维度都会扩字段。 + +本轮处理: + +- `reasoning_tokens`、`total_tokens` 已写入 `schema.sql`。 +- `TokenUsage` 已解析 `output_tokens_details.reasoning_tokens` 和上游 `total_tokens`。 +- `AccountUsageDelta`、SQLite upsert、管理端 usage list/summary 已全部贯通。 +- 明确定义: + - `input_tokens`:上游 usage 输入 token。 + - `output_tokens`:上游 usage 输出 token。 + - `cached_tokens`:输入侧缓存 token。 + - `reasoning_tokens`:输出 token details 中的 reasoning token。 + - `total_tokens`:上游返回的原始 total token;缺失时才用输入加输出兜底。 +- 窗口字段继续保留,但只作为当前 rate-limit 窗口快照。 + +长期目标: + +> 优先级说明:拆分 `account_usage_totals` / `account_usage_windows` 是 P2 长期清洁项,不进入本轮 P0/P1。本轮已解决 schema drift 和字段定义不清的问题。只有当窗口统计、历史统计、管理端聚合继续扩张时,才值得拆表。 + +```text +account_usage_totals + account_id + request_count + empty_response_count + input_tokens + output_tokens + cached_tokens + reasoning_tokens + total_tokens + image_input_tokens + image_output_tokens + image_request_count + image_request_failed_count + last_used_at + +account_usage_windows + account_id + window_started_at + window_reset_at + limit_window_seconds + request_count + input_tokens + output_tokens + cached_tokens + image_input_tokens + image_output_tokens + image_request_count + image_request_failed_count + updated_at +``` + +这样累计统计和限流窗口可以独立重置、独立迁移。 + +### `account_cookies` + +当前字段: + +```text +id, account_id, domain, name, value_cipher, path, expires_at, updated_at +unique(account_id, domain, name, path) +``` + +判断:整体合理。 + +问题: + +- `id` 对数据库唯一性不是必须,因为自然键已经是 `(account_id, domain, name, path)`。 +- `value_cipher` 建议后续统一为 `value_ciphertext`。 +- `expires_at` 可能来自 RFC2822 cookie 日期,也可能来自 RFC3339 `max-age` 计算值。代码读取时可解析两种格式,但清理 SQL 使用字符串比较,若混合格式会有边界风险。 + +建议: + +- 如果管理端不需要按 cookie 单条 ID 操作,可以考虑去掉 `id`,以自然键为主键。 +- 如果保留 `id`,当前设计也可接受。 +- 将入库 `expires_at` 统一规范化为 RFC3339 UTC,再做 SQL 清理。 + +### `fingerprints` + +当前字段: + +```text +id +app_version, build_number +platform, arch, chromium_version +user_agent_template +source +created_at +``` + +改造前代码行为: + +- `Fingerprint::default_codex_desktop()` 在 core 里硬编码默认值。 +- 旧仓储只从自动更新快照里读版本、平台、架构、Chromium 和 UA 模板。 +- `originator`、`default_headers`、`header_order` 仍由代码默认值补齐。 +- 旧自动更新写入会覆盖 `created_at`,这个字段实际承担了 `updated_at` 的语义。 +- 改造前 `server/main.rs` 构造 `AppState` 时没有加载数据库指纹,运行时仍可能使用硬编码默认指纹。 + +结论:改造前表不满足“指纹默认来自 config.yaml,持久化到数据库,后续都从数据库拿”。 + +本轮已按“当前槽位 + 可选历史”的语义落地,不再把运行时当前快照和历史记录混在同一套 `source + created_at` 查询里。 + +### 当前槽位表 + +```text +request_fingerprints + id + originator + app_version + build_number + platform + arch + chromium_version + user_agent_template + default_headers_json + header_order_json + source + created_at + updated_at +``` + +本轮保留现有表名 `fingerprints`,直接在该表上补齐同样字段。 + +字段定义: + +| 字段 | 语义 | +| --- | --- | +| `id` | 指纹槽位。运行时当前槽位固定使用 `current` | +| `originator` | `originator` 请求头值,例如 `Codex Desktop` | +| `app_version` | Codex Desktop 应用版本 | +| `build_number` | Codex Desktop 构建号 | +| `platform` | UA 模板中的平台字段,例如 `darwin` | +| `arch` | UA 模板中的架构字段,例如 `arm64` | +| `chromium_version` | `sec-ch-ua` 的 Chromium 主版本 | +| `user_agent_template` | UA 模板 | +| `default_headers_json` | 默认请求头集合,建议保存为有序数组 | +| `header_order_json` | 最终请求头排序优先级 | +| `source` | 最近一次写入来源:`config_seed`、`auto_update`、`manual` | +| `created_at` | 首次创建时间 | +| `updated_at` | 最近更新时间 | + +槽位规则: + +- `config_seed` 首次写入时使用 `id = 'current'`。 +- `auto_update` 更新时覆盖同一行 `id = 'current'`,只更新远端更新源明确提供的字段,例如 `app_version`、`build_number`、`chromium_version`、`source`、`updated_at`。 +- `manual` 管理端调整也覆盖同一行 `id = 'current'`。 +- `load_current()` 固定执行 `select ... from fingerprints where id = 'current'`,不按 `source` 或 `created_at desc` 猜测当前指纹。 +- 项目尚未上线,本轮不保留旧自动更新槽位的兼容路径;实现时直接使用 `CURRENT_FINGERPRINT_ID = 'current'`。 +- 本地开发库里如果已有旧自动更新行,可以直接清空 `fingerprints` 或重建 `.runtime` 数据库,不写生产兼容迁移。 +- `source` 只是“最近一次写入来源”,不是槽位选择条件。 + +这样可以回答三个关键问题: + +- 首次配置种子写哪一行:写 `id = 'current'`。 +- 自动更新写哪一行:覆盖 `id = 'current'`。 +- 运行时读哪一行:只读 `id = 'current'`。 + +### 可选历史表 + +如果需要保留每次 appcast 检查或手动变更历史,应单独建追加表,不建议继续把历史记录塞进当前槽位表: + +```text +fingerprint_update_history + id + current_fingerprint_id + app_version + build_number + chromium_version + source + manifest_json + created_at +``` + +历史表规则: + +- `id` 使用 UUID。 +- 每次检查到更新可以 insert 一行历史。 +- 历史只用于审计和排查,不参与运行时 `load_current()`。 +- `insert_update()` 已改写到历史表,不写入当前槽位表。 + +不推荐的方案: + +- 不推荐在同一张 `fingerprints` 表里一会儿用固定 ID 表示当前,一会儿用 UUID 表示历史,再靠 `source = 'runtime' order by created_at desc` 推断当前。这会让 `created_at` / `updated_at` / `source` 三个字段语义互相污染。 + +`default_headers_json` 建议格式: + +```json +[ + {"name": "Accept-Encoding", "value": "gzip, deflate, br, zstd"}, + {"name": "Accept-Language", "value": "en-US,en;q=0.9"}, + {"name": "sec-ch-ua-mobile", "value": "?0"}, + {"name": "sec-ch-ua-platform", "value": "\"macOS\""} +] +``` + +不用单独建 `fingerprint_headers` 表的理由: + +- 请求头集合是小型原子配置。 +- 查询不会按单个 header 聚合。 +- 保持 JSON 原子读写更简单,也更符合“运行时指纹快照”的语义。 + +如果后续要做管理端逐项编辑、审计每个 header 的历史,再考虑拆表。 + +启动流程建议: + +1. 读取 `config.yaml` 的 `fingerprint` 默认配置。 +2. 连接 SQLite 并初始化当前 schema。由于尚未上线,本轮可以直接更新 `schema.sql` 并重建本地运行库。 +3. 调用 `fingerprints.ensure_current_seed(config_fingerprint)`。 +4. 如果 `id = 'current'` 不存在,插入 config 默认值,`source = config_seed`。 +5. 如果 `id = 'current'` 已存在,不用 config 覆盖数据库。 +6. 从 `id = 'current'` 读取完整指纹,构造 `AppState`。 +7. 后台自动更新只覆盖 `id = 'current'` 中明确由更新源提供的字段。 +8. 请求链路和诊断接口只读运行时加载的数据库指纹。 + +重要约束: + +- `config.yaml` 是首次默认值,不是持续覆盖源。 +- 数据库是启动后的事实来源。 +- 自动更新不得把 `originator`、`platform`、`arch`、`default_headers_json`、`header_order_json` 重置成代码默认值。 +- core 层不应依赖 platform 配置类型。转换应在 runtime 或 adapters 层完成,避免依赖倒置。 + +### `event_logs` + +当前字段: + +```text +id, request_id, kind, level, account_id, route, model, status_code, +transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, +latency_ms, message, metadata_json, created_at +``` + +判断:基础可用,常用排查字段已从 `metadata_json` 提升为顶层列。 + +仍需注意: + +- `attempt_index` 只有请求重试上下文明示时才会有值;不会凭空生成。 +- `upstream_request_id` 需要上游响应头或 metadata 提供;没有来源时保持空。 + +已补充高频查询字段: + +```text +transport +attempt_index +upstream_status_code +failure_class +response_id +upstream_request_id +``` + +字段来源建议: + +| 字段 | 数据来源 | +| --- | --- | +| `transport` | runtime 请求分发结果或上游客户端返回的 transport 标记,例如 `http_sse`、`websocket`、`websocket_fallback_http_sse` | +| `attempt_index` | 账号请求重试循环里的第几次尝试,从 runtime dispatch / retry context 递增产生 | +| `upstream_status_code` | `CodexClientError::Upstream` 或 HTTP response status;成功响应可为空或等于最终 `status_code` | +| `failure_class` | runtime 现有错误分类结果,例如 auth、quota、cloudflare、rate_limit、transport、empty_response、history_recovery 等 | +| `response_id` | 上游 Responses / Chat / WebSocket completed 事件里的 response id;失败前没有 response id 时为空 | +| `upstream_request_id` | 上游响应头中能拿到的 request id,例如 `x-request-id`、`cf-ray` 或 OpenAI 相关 request id;没有则为空 | + +实施原则: + +- 字段从现有请求上下文、上游响应头、错误类型中提取,不新增模拟值。 +- 同一个用户请求的每次账号尝试都应能用同一个 `request_id` 串起来。 +- `metadata_json` 继续保存完整上下文,但管理端常用筛选项必须升为顶层列。 + +保留 `metadata_json`,但它只做补充上下文,不承担主要筛选维度。 + +### `model_plan_snapshots` + +当前字段: + +```text +plan_type, models_json, fetched_at +``` + +判断:简单合理。 + +建议: + +- `plan_type` 作为自然主键可保留。 +- 如果后续需要区分来源,可加 `source`,但当前项目只做 OpenAI/Codex,不需要泛化 provider 字段。 + +### `session_affinities` + +当前字段: + +```text +response_id +account_id +conversation_id +turn_state +instructions_hash +input_tokens +function_call_ids_json +variant_hash +expires_at +created_at +``` + +判断:职责清晰。 + +问题: + +- `account_id` 没有在 schema 中声明为 `references accounts(id) on delete cascade`。 +- `function_call_ids_json` 的默认值在运行库和 `schema.sql` 表达不一致。 +- `turn_state` 如果包含上游续链敏感状态,后续需要确认是否要加密或至少严格控制日志输出。 + +建议: + +- 增加账号外键,账号删除时 cascade 删除 affinity。 +- 统一 `function_call_ids_json default '[]'`。 +- `conversation_id`、`variant_hash` 可以保留当前命名,它们表达的是本项目内部续链匹配键。 + +## 指纹配置和数据库持久化方案 + +用户要求: + +- 本次指纹默认值写在 `config.yaml`。 +- 启动时持久化到数据库。 +- 后续更新只更新数据库。 +- 后续运行都从数据库拿。 + +建议配置结构: + +```yaml +fingerprint: + originator: Codex Desktop + app_version: 26.519.81530 + build_number: "3178" + platform: darwin + arch: arm64 + chromium_version: "146" + user_agent_template: "Codex Desktop/{version} ({platform}; {arch})" + default_headers: + - name: Accept-Encoding + value: gzip, deflate, br, zstd + - name: Accept-Language + value: en-US,en;q=0.9 + - name: sec-ch-ua-mobile + value: "?0" + - name: sec-ch-ua-platform + value: "\"macOS\"" + - name: sec-fetch-site + value: same-origin + - name: sec-fetch-mode + value: cors + - name: sec-fetch-dest + value: empty + header_order: + - authorization + - chatgpt-account-id + - originator + - x-openai-internal-codex-residency + - x-client-request-id + - x-codex-installation-id + - x-codex-turn-state + - openai-beta + - user-agent + - sec-ch-ua + - sec-ch-ua-mobile + - sec-ch-ua-platform + - accept-encoding + - accept-language + - sec-fetch-site + - sec-fetch-mode + - sec-fetch-dest + - content-type + - accept + - cookie +``` + +说明: + +- `default_headers` 用数组,不用 map,是为了保留顺序和大小写。 +- `header_order` 统一使用小写 header name,因为排序匹配通常按大小写无关处理。 +- 配置文件中不放任何 token、cookie 或账号身份。 + +本轮数据库结构调整: + +项目尚未上线,不需要为旧 `fingerprints` 表写兼容迁移。直接更新 `schema.sql` 的最终结构,并让新库包含完整字段即可。 + +建议最终字段约束: + +```text +id text primary key +originator text not null +app_version text not null +build_number text not null +platform text not null +arch text not null +chromium_version text not null +user_agent_template text not null +default_headers_json text not null +header_order_json text not null +source text not null +created_at text not null +updated_at text not null +``` + +本地已有 `.runtime` 数据库时,处理方式是删除运行库、重建运行库,或只清空并重建 `fingerprints` 表;不写旧槽位到当前槽位的兼容升级逻辑。 + +已落地仓储 API: + +```text +CURRENT_FINGERPRINT_ID = "current" +ensure_current_seed(default_fingerprint) -> Fingerprint +load_current() -> Option +update_current_version(app_version, build_number, chromium_version) -> () +insert_update_history(update) -> () # 可选;写 fingerprint_update_history,不参与运行时读取 +``` + +旧自动更新槽位相关 API 已从运行时代码移除。运行时需要的是“当前指纹”,不是“最新自动更新历史”。 + +## 建议目标结构 + +如果允许做一次较完整的 schema v2,目标结构如下。 + +```text +admin_users +admin_sessions +client_api_keys + +accounts +account_tokens +account_quota_state +account_recovery_state +account_refresh_leases +account_usage_totals +account_usage_windows +account_cookies + +request_fingerprints +event_logs +model_plan_snapshots +session_affinities +schema_migrations +``` + +本轮已实现这些: + +1. 增加 `schema_migrations` 作为未来上线后的迁移纪律;本轮未上线,本地库可以直接重建。 +2. 对齐 `schema.sql` 和运行库 drift。 +3. 补全 `fingerprints` 字段和 `id = 'current'` 当前槽位加载链路。 +4. 解决 `accounts` 上游身份唯一约束和重复导入。 +5. 数据库物理列改为 `chatgpt_account_id` / `chatgpt_user_id`;Rust 领域结构暂时通过仓储 alias 过渡。 +6. 补齐 `event_logs` 顶层排查字段。 + +## 实施优先级 + +### P0:必须先做 + +- 已完成:新增迁移账本表,停止继续堆 ad hoc 迁移;本轮本地库可直接重建,不写旧结构兼容升级。 +- 已完成:直接对齐 `schema.sql` 与运行库 drift。 +- 已完成:补全 `fingerprints` 表,使 `id = 'current'` 的完整指纹可以从数据库恢复。 +- 已完成:`config.yaml` 增加 `fingerprint` 默认种子。 +- 已完成:启动时 seed 数据库,运行时从数据库加载指纹。 +- 已完成:账号导入按上游身份更新,数据库增加唯一约束。 + +### P1:命名和职责边界 + +- 已完成:数据库物理列使用 `chatgpt_account_id`、`chatgpt_user_id`。 +- 已完成:`event_logs` 补结构化排查字段。 +- 待做:逐步把 `added_at` 迁移为 `created_at`。 +- 待做:明确 `status` 状态机和刷新调度不变量。 +- 待做:如后续确实需要,再把 Rust 领域结构字段也改为 `chatgpt_account_id` / `chatgpt_user_id`。 + +### P2:拆表和长期清洁 + +- 从 `accounts` 拆出 `account_tokens`、`account_quota_state`、`account_recovery_state`。 +- 从 `account_usage` 拆出 totals / windows。 +- 统一 `_ciphertext` 命名。 +- 评估 `account_cookies.id` 是否保留。 + +## 不建议做 + +- 不引入 `provider`、`provider_id`、`proxy_api_key` 之类泛化字段。当前项目只做 OpenAI/Codex。 +- 不把配置文件当作每次启动的覆盖源。配置只是首次种子,数据库才是运行时事实来源。 +- 不把请求头字段继续硬编码在 core 里再由数据库补一部分。要么完整默认,要么完整落库。 +- 不先加唯一索引再清理历史重复数据。否则迁移会直接失败。 +- 不把真实 token、cookie、账号邮箱写进文档或测试 fixture。 + +## 下一步建议 + +下一步不再补旧库兼容迁移,优先继续做真实链路验证和状态机收敛: + +1. 继续用真实账号跑请求链路,验证刷新调度、禁用账号、封禁账号、配额冷却和 websocket/http_sse fallback。 +2. 明确 `status` 状态机,把 disabled/banned/expired/refreshing 的启动扫描和后台调度不变量固化。 +3. 如管理端长期需要窗口级 reasoning/total,再评估是否把 `account_usage` 拆成 totals/windows。