mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor: clean runtime storage schema
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+44
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "a))
|
||||
QuotaVerificationDecision::Ready(Box::new(acquired_with_verified_quota(acquired, "a)))
|
||||
}
|
||||
|
||||
fn acquired_with_verified_quota(mut acquired: AcquiredAccount, quota: &Value) -> AcquiredAccount {
|
||||
@@ -5761,6 +5777,18 @@ pub struct AdminLogFilter {
|
||||
pub model: Option<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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。
|
||||
Reference in New Issue
Block a user