refactor: clean runtime storage schema

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-20 18:16:04 +08:00
co-authored by Codex
parent 17de9ffe57
commit e06c90c38c
51 changed files with 2203 additions and 436 deletions
+44
View File
@@ -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
+2 -4
View File
@@ -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::<Vec<_>>();
pairs
.collect::<Vec<_>>()
}
fn websocket_exchange_error_to_client_error(
+178 -63
View File
@@ -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<Fingerprint, sqlx::Error> {
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<Option<Fingerprint>, 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<Option<StoredFingerprint>, 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<Option<Fingerprint>, 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<Fingerprint, sqlx::Error> {
let default_headers_json = row.get::<String, _>("default_headers_json");
let header_order_json = row.get::<String, _>("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<String, String>) -> Result<String, sqlx::Error> {
let headers = headers
.iter()
.map(|(name, value)| StoredHeader {
name: name.clone(),
value: value.clone(),
})
.collect::<Vec<_>>();
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<IndexMap<String, String>, sqlx::Error> {
let headers = serde_json::from_str::<Vec<StoredHeader>>(value).map_err(json_error)?;
Ok(headers
.into_iter()
.map(|header| (header.name, header.value))
.collect())
}
fn encode_header_order(header_order: &[String]) -> Result<String, sqlx::Error> {
serde_json::to_string(header_order).map_err(json_error)
}
fn decode_header_order(value: &str) -> Result<Vec<String>, 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?;
}
+71 -29
View File
@@ -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<Acc
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"),
@@ -1841,6 +1881,8 @@ fn usage_list_from_row(
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"),
+119 -3
View File
@@ -2,6 +2,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
use thiserror::Error;
@@ -51,6 +52,18 @@ pub struct EventLogFilter {
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 搜索关键词。
pub search: Option<String>,
}
@@ -86,7 +99,7 @@ impl SqliteEventLogStore {
) -> SqliteEventLogStoreResult<Page<EventLog>> {
let fetch_limit = i64::from(limit) + 1;
let mut builder = QueryBuilder::<Sqlite>::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<Option<EventLog>> {
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<String>,
attempt_index: Option<i64>,
upstream_status_code: Option<i64>,
failure_class: Option<String>,
response_id: Option<String>,
upstream_request_id: Option<String>,
}
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<String> {
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<i64> {
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<Ev
route: row.get("route"),
model: row.get("model"),
status_code: row.get("status_code"),
transport: row.get("transport"),
attempt_index: row.get("attempt_index"),
upstream_status_code: row.get("upstream_status_code"),
failure_class: row.get("failure_class"),
response_id: row.get("response_id"),
upstream_request_id: row.get("upstream_request_id"),
latency_ms: row.get("latency_ms"),
message: row.get("message"),
metadata: serde_json::from_str(&row.get::<String, _>("metadata_json"))?,
@@ -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());
}
+36 -8
View File
@@ -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<TcpStream> {
accept_codex_test_websocket_with(stream, |_request, response| {
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
})
.await
}
struct TestWebSocketCallback<F>(F);
impl<F> Callback for TestWebSocketCallback<F>
where
F: FnOnce(&WsRequest, &mut WsResponse) + Unpin,
{
fn on_request(
self,
request: &WsRequest,
mut response: WsResponse,
) -> Result<WsResponse, ErrorResponse> {
(self.0)(request, &mut response);
Ok(response)
}
}
async fn accept_codex_test_websocket_with<F>(
stream: TcpStream,
callback: F,
) -> tokio_tungstenite::WebSocketStream<TcpStream>
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
@@ -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);
+38 -58
View File
@@ -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::<Vec<_>>();
*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::<Vec<_>>();
*headers_for_server.lock().unwrap() = headers;
})
.await;
let message = websocket.next().await.unwrap().unwrap();
let payload = serde_json::from_str::<serde_json::Value>(&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,
+32 -49
View File
@@ -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::<serde_json::Value>(&message.into_text().unwrap())
.expect("client payload should be json");
+40
View File
@@ -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;
+1 -1
View File
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
+13
View File
@@ -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");
}
+6
View File
@@ -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,
+18
View File
@@ -39,6 +39,18 @@ pub struct EventLog {
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式,例如 http_sse 或 websocket。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 请求耗时毫秒。
pub latency_ms: Option<i64>,
/// 展示消息。
@@ -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()),
+15
View File
@@ -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<TokenUsage> {
.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<TokenUsage> {
"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<TokenUsage> {
input_tokens,
output_tokens,
cached_tokens,
reasoning_tokens,
image_input_tokens,
image_output_tokens,
total_tokens,
+2
View File
@@ -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,
@@ -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,
+90
View File
@@ -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<FingerprintHeaderConfig>,
/// 请求头排序优先级。
pub header_order: Vec<String>,
}
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 {
+42 -4
View File
@@ -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,
-31
View File
@@ -22,37 +22,6 @@ pub async fn connect_sqlite(database_url: &str) -> Result<SqlitePool, sqlx::Erro
async fn initialize_schema(pool: &SqlitePool) -> Result<(), sqlx::Error> {
let schema = include_str!("schema.sql");
sqlx::raw_sql(schema).execute(pool).await?;
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(())
}
+91
View File
@@ -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:
+104 -44
View File
@@ -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::<Vec<_>>(),
["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::<Vec<_>>(),
[
"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::<Vec<_>>(),
[
"attempt_index",
"failure_class",
"response_id",
"transport",
"upstream_request_id",
"upstream_status_code",
]
);
}
#[tokio::test]
+34 -5
View File
@@ -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<Fingerprint, sqlx::Error> {
repository.ensure_current_seed(default_fingerprint).await
}
/// 从 SQLite 连接池加载运行时当前指纹。
pub async fn load_runtime_fingerprint_from_pool(
pool: SqlitePool,
default_fingerprint: &Fingerprint,
) -> Result<Fingerprint, sqlx::Error> {
let repository = FingerprintRepository::new(pool);
load_runtime_fingerprint(&repository, default_fingerprint).await
}
+50 -8
View File
@@ -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<AcquiredAccount>),
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, &quota))
QuotaVerificationDecision::Ready(Box::new(acquired_with_verified_quota(acquired, &quota)))
}
fn acquired_with_verified_quota(mut acquired: AcquiredAccount, quota: &Value) -> AcquiredAccount {
@@ -5761,6 +5777,18 @@ pub struct AdminLogFilter {
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 搜索关键词。
pub search: Option<String>,
}
@@ -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<AccountUsageListRecord> 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<AccountUsageSummary> 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<AdminLogFilter> 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,
}
}
+2 -1
View File
@@ -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,
+11 -6
View File
@@ -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,
+12
View File
@@ -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,
+5 -3
View File
@@ -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,
+2
View File
@@ -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",
+18 -6
View File
@@ -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]
+36 -8
View File
@@ -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<TcpStream> {
accept_runtime_test_websocket_with(stream, |_request, response| {
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
})
.await
}
struct TestWebSocketCallback<F>(F);
impl<F> Callback for TestWebSocketCallback<F>
where
F: FnOnce(&WsRequest, &mut WsResponse) + Unpin,
{
fn on_request(
self,
request: &WsRequest,
mut response: WsResponse,
) -> Result<WsResponse, ErrorResponse> {
(self.0)(request, &mut response);
Ok(response)
}
}
async fn accept_runtime_test_websocket_with<F>(
stream: TcpStream,
callback: F,
) -> tokio_tungstenite::WebSocketStream<TcpStream>
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
+18
View File
@@ -40,6 +40,18 @@ pub struct LogsQuery {
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 搜索关键词。
pub search: Option<String>,
}
@@ -77,6 +89,12 @@ fn filter_from_query(query: LogsQuery, request_id: &str) -> Result<AdminLogFilte
route: non_empty(query.route),
model: non_empty(query.model),
status_code: query.status_code,
transport: non_empty(query.transport),
attempt_index: query.attempt_index,
upstream_status_code: query.upstream_status_code,
failure_class: non_empty(query.failure_class),
response_id: non_empty(query.response_id),
upstream_request_id: non_empty(query.upstream_request_id),
search: non_empty(query.search),
})
}
+12
View File
@@ -52,6 +52,10 @@ pub struct AdminUsageStatsData {
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 数。
@@ -80,6 +84,10 @@ pub struct AdminUsageStatsSummaryData {
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 总数。
@@ -159,6 +167,8 @@ impl From<AdminUsageRecord> 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<AdminUsageSummary> 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,
+7 -2
View File
@@ -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
@@ -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,
@@ -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"));
@@ -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);
}
@@ -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]
@@ -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,
+1
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+1
View File
@@ -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,
+48 -66
View File
@@ -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<WebSocketStream<TcpStream>, WsError> {
accept_hdr_async(stream, |_request: &WsRequest, response: WsResponse| {
Ok(response)
})
.await
accept_hdr_async(stream, |_request, _response| {}).await
}
async fn accept_hdr_async<C>(
struct TestWebSocketCallback<F>(F);
impl<F> Callback for TestWebSocketCallback<F>
where
F: FnOnce(&WsRequest, &mut WsResponse) + Unpin,
{
fn on_request(
self,
request: &WsRequest,
mut response: WsResponse,
) -> Result<WsResponse, ErrorResponse> {
(self.0)(request, &mut response);
Ok(response)
}
}
async fn accept_hdr_async<F>(
stream: TcpStream,
callback: C,
callback: F,
) -> Result<WebSocketStream<TcpStream>, 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<CapturedWebSocketRequest>) {
@@ -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<HistoryRecoveryCapture>) {
@@ -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::<Value>(&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<TcpStream> {
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,
@@ -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();
@@ -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,
@@ -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,
@@ -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,
+890
View File
@@ -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<Fingerprint>
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。