refactor: replace fingerprint with wire profile

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-15 18:23:59 +08:00
co-authored by Codex
parent 5391fdb31c
commit 0b2da3087b
68 changed files with 1260 additions and 2087 deletions
+10
View File
@@ -371,6 +371,7 @@ dependencies = [
"rand_core 0.6.4",
"redis",
"reqwest",
"roxmltree",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
@@ -1905,6 +1906,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
+1
View File
@@ -25,6 +25,7 @@ rand = "0.10.1"
# Enables password_hash::rand_core::OsRng for argon2 0.5.
rand_core = { version = "0.6.4", features = ["getrandom"] }
redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] }
roxmltree = "0.21.1"
# Pinned to the Codex Desktop 26.707.41301 HTTP stack; re-audit a newer Desktop before upgrading.
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
+93 -100
View File
@@ -28,10 +28,7 @@ use crate::{
},
infra::{
format::{format_duration_ms, format_tokens},
time::{
china_datetime, china_datetime_rfc3339_str, china_day_start, china_quarter_hour_start,
china_relative_time,
},
time::{china_datetime, china_day_start, china_quarter_hour_start, china_relative_time},
},
telemetry::{
account_usage::query::AccountUsageTimeBucket,
@@ -45,7 +42,6 @@ use crate::{
types::{UsageRecord, metadata_string},
},
},
upstream::openai::fingerprint::Fingerprint,
};
const DASHBOARD_ACCOUNT_USAGE_LIMIT: u32 = 4;
@@ -69,7 +65,7 @@ struct DashboardSummaryData {
trend: DashboardTrendData,
health_timeline: DashboardHealthTimelineData,
account_usage: Vec<DashboardAccountUsageData>,
service_statuses: Vec<DashboardServiceStatusData>,
wire_profile: DashboardWireProfileData,
usage_records: Vec<DashboardUsageRecordData>,
pool_summary: AccountPoolDiagnostics,
capacity_info: AccountCapacityDiagnostics,
@@ -114,11 +110,50 @@ struct DashboardUsageRecordData {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardServiceStatusData {
label: String,
value: String,
detail: String,
tone: String,
struct DashboardWireProfileData {
originator: String,
codex_version: String,
desktop_version: String,
desktop_build: String,
target: DashboardWireTargetData,
user_agent: String,
verified_at: DateTime<Utc>,
release: DashboardDesktopReleaseData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardWireTargetData {
os_type: String,
os_version: String,
arch: String,
terminal: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardDesktopReleaseData {
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
checked_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
published_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
minimum_system_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hardware_requirements: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
download_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
download_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
signature_present: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Default, Serialize)]
@@ -142,35 +177,6 @@ struct AccountCapacityDiagnostics {
available_slots: usize,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FingerprintDiagnostics {
source: &'static str,
originator: String,
app_version: String,
build_number: String,
platform: String,
arch: String,
chromium_version: String,
user_agent: String,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
fn fingerprint_diagnostics(fingerprint: &Fingerprint) -> FingerprintDiagnostics {
FingerprintDiagnostics {
source: "runtime",
originator: fingerprint.originator.clone(),
app_version: fingerprint.app_version.clone(),
build_number: fingerprint.build_number.clone(),
platform: fingerprint.platform.clone(),
arch: fingerprint.arch.clone(),
chromium_version: fingerprint.chromium_version.clone(),
user_agent: fingerprint.user_agent(),
updated_at: fingerprint.updated_at.clone(),
}
}
impl From<AccountCapacitySummary> for AccountCapacityDiagnostics {
fn from(summary: AccountCapacitySummary) -> Self {
Self {
@@ -266,7 +272,7 @@ pub(crate) async fn dashboard_summary(
&quota_used_by_account,
now,
),
service_statuses: service_status_data(&state),
wire_profile: wire_profile_data(&state),
usage_records: dashboard_usage_records,
pool_summary,
capacity_info: AccountCapacityDiagnostics::from(capacity),
@@ -554,66 +560,53 @@ fn dashboard_usage_record_data(
}
}
fn service_status_data(state: &AppState) -> Vec<DashboardServiceStatusData> {
let current_fingerprint = state.services.fingerprint.snapshot();
let fingerprint = fingerprint_diagnostics(&current_fingerprint);
vec![
DashboardServiceStatusData {
label: "客户端版本".to_string(),
value: empty_dash(fingerprint.app_version),
detail: if fingerprint.build_number.trim().is_empty() {
"-".to_string()
} else {
format!("Build {}", fingerprint.build_number)
},
tone: "info".to_string(),
},
DashboardServiceStatusData {
label: "平台架构".to_string(),
value: empty_dash(fingerprint.platform),
detail: empty_dash(fingerprint.arch),
tone: "info".to_string(),
},
DashboardServiceStatusData {
label: "Chromium".to_string(),
value: if fingerprint.chromium_version.trim().is_empty() {
"-".to_string()
} else {
format!("v{}", fingerprint.chromium_version)
},
detail: empty_dash(fingerprint.originator),
tone: "normal".to_string(),
},
DashboardServiceStatusData {
label: "更新时间".to_string(),
value: format_fingerprint_updated_at(fingerprint.updated_at),
detail: String::new(),
tone: "normal".to_string(),
},
DashboardServiceStatusData {
label: "User Agent".to_string(),
value: fingerprint.user_agent,
detail: String::new(),
tone: "normal".to_string(),
},
]
}
fn empty_dash(value: String) -> String {
if value.trim().is_empty() {
"-".to_string()
fn wire_profile_data(state: &AppState) -> DashboardWireProfileData {
let profile = &state.services.wire_profile;
let release_snapshot = state.services.desktop_release.snapshot();
let release_status = if release_snapshot.last_error.is_some() {
"check_failed"
} else if let Some(latest) = &release_snapshot.latest {
if latest.version == profile.desktop_version && latest.build == profile.desktop_build {
"aligned"
} else {
"review_required"
}
} else {
value
}
}
fn format_fingerprint_updated_at(value: Option<String>) -> String {
let Some(value) = value else {
return "-".to_string();
"unchecked"
};
let value = value.trim();
if value.is_empty() {
return "-".to_string();
let latest = release_snapshot.latest;
DashboardWireProfileData {
originator: profile.originator.clone(),
codex_version: profile.codex_version.clone(),
desktop_version: profile.desktop_version.clone(),
desktop_build: profile.desktop_build.clone(),
target: DashboardWireTargetData {
os_type: profile.os_type.clone(),
os_version: profile.os_version.clone(),
arch: profile.arch.clone(),
terminal: profile.terminal.clone(),
},
user_agent: profile.user_agent(),
verified_at: profile.verified_at,
release: DashboardDesktopReleaseData {
status: release_status,
checked_at: release_snapshot.checked_at,
latest_version: latest.as_ref().map(|release| release.version.clone()),
latest_build: latest.as_ref().map(|release| release.build.clone()),
published_at: latest.as_ref().and_then(|release| release.published_at),
minimum_system_version: latest
.as_ref()
.and_then(|release| release.minimum_system_version.clone()),
hardware_requirements: latest
.as_ref()
.and_then(|release| release.hardware_requirements.clone()),
download_url: latest
.as_ref()
.and_then(|release| release.download_url.clone()),
download_size: latest.as_ref().and_then(|release| release.download_size),
signature_present: latest.as_ref().map(|release| release.signature_present),
error: release_snapshot.last_error,
},
}
china_datetime_rfc3339_str(value)
}
+6 -2
View File
@@ -27,7 +27,10 @@ use crate::{
usage::query::UsageQueryService,
},
update::service::SystemUpdateService,
upstream::openai::{fingerprint::RuntimeFingerprint, token_client::OpenAiTokenClient},
upstream::openai::{
desktop_release::DesktopReleaseStatus, profile::CodexWireProfile,
token_client::OpenAiTokenClient,
},
};
/// HTTP API 所需的领域服务集合。
@@ -48,7 +51,8 @@ pub struct ApiServices {
pub token_refresh: Arc<TokenRefreshService<OpenAiTokenClient>>,
pub responses: Arc<ResponseDispatchService>,
pub session_affinity: Arc<SessionAffinityService>,
pub fingerprint: RuntimeFingerprint,
pub wire_profile: Arc<CodexWireProfile>,
pub desktop_release: DesktopReleaseStatus,
pub process_control: Arc<dyn ProcessControl>,
pub system_update: Arc<SystemUpdateService>,
pub connection_drain: crate::api::middleware::connection_drain::ConnectionDrain,
+57 -73
View File
@@ -1,16 +1,16 @@
use std::{
collections::{BTreeMap, HashSet},
collections::BTreeMap,
env,
path::{Path, PathBuf},
};
use axum::http::HeaderName;
use chrono::{DateTime, Utc};
use secrecy::{ExposeSecret, SecretBox};
use serde::{Deserialize, de::IgnoredAny};
use tracing_subscriber::EnvFilter;
use url::Url;
const CONFIG_SCHEMA_VERSION: u32 = 1;
const CONFIG_SCHEMA_VERSION: u32 = 2;
const CONFIG_RELATIVE_PATH: &str = "deploy/config.yaml";
const SERVER_HOST_ENV: &str = "CPR_SERVER_HOST";
const SERVER_PORT_ENV: &str = "CPR_SERVER_PORT";
@@ -106,7 +106,7 @@ impl BootstrapConfig {
runtime,
quota,
ws_pool,
fingerprint,
wire_profile,
admin,
logging,
telemetry,
@@ -122,7 +122,7 @@ impl BootstrapConfig {
redis: RedisConfig { url: redis.url },
runtime,
ws_pool,
fingerprint,
wire_profile,
admin: AdminConfig {
session_ttl_minutes: admin.session_ttl_minutes,
default_username: admin.default_username,
@@ -241,8 +241,8 @@ pub struct AppConfig {
pub runtime: RuntimePathsConfig,
/// WebSocket 连接池启动设置。
pub ws_pool: WebSocketPoolSettings,
/// 上游请求指纹默认配置。
pub fingerprint: FingerprintConfig,
/// 经审计固定的 Codex Desktop 上游请求画像。
pub wire_profile: WireProfileConfig,
/// 管理员初始化配置,不包含密码。
pub admin: AdminConfig,
/// 日志配置。
@@ -354,38 +354,28 @@ pub struct WebSocketPoolSettings {
pub initial_event_timeout_ms: u64,
}
/// 上游请求指纹默认配置。
/// 经审计固定的 Codex Desktop 上游请求画像配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FingerprintConfig {
/// 客户端来源名。
pub struct WireProfileConfig {
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// 应用版本。
pub app_version: String,
/// 构建号。
pub build_number: String,
/// 平台名。
pub platform: String,
/// 架构名。
/// bundled Codex Core 版本。
pub codex_version: String,
/// Desktop 应用版本。
pub desktop_version: String,
/// Desktop 制品构建号。
pub desktop_build: String,
/// Codex Core UA 中的目标操作系统类型。
pub os_type: String,
/// Codex Core UA 中的目标操作系统版本。
pub os_version: String,
/// Codex Core UA 中的目标架构。
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>,
}
/// 指纹默认请求头配置项。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FingerprintHeaderConfig {
/// 请求头名称。
pub name: String,
/// 请求头值。
pub value: String,
/// Codex Core UA 中的终端标记。
pub terminal: String,
/// 此画像最后一次经制品与源码核验的时间。
pub verified_at: DateTime<Utc>,
}
/// 管理员初始化与会话配置,不包含初始化密码。
@@ -453,7 +443,7 @@ struct FileAppConfig {
runtime: RuntimePathsConfig,
quota: QuotaConfig,
ws_pool: WebSocketPoolSettings,
fingerprint: FingerprintConfig,
wire_profile: WireProfileConfig,
admin: FileAdminConfig,
logging: LoggingConfig,
telemetry: TelemetryConfig,
@@ -549,7 +539,7 @@ fn validate_file_config(config: &FileAppConfig) -> Result<(), ConfigError> {
if config.ws_pool.max_per_account == 0 {
return Err(ConfigError::InvalidField("ws_pool.max_per_account"));
}
validate_fingerprint(&config.fingerprint)?;
validate_wire_profile(&config.wire_profile)?;
validate_logging(&config.logging)?;
Ok(())
}
@@ -594,54 +584,48 @@ fn validate_admin_password(password: &str) -> Result<(), ConfigError> {
Ok(())
}
fn validate_fingerprint(config: &FingerprintConfig) -> Result<(), ConfigError> {
fn validate_wire_profile(config: &WireProfileConfig) -> Result<(), ConfigError> {
for (field, value) in [
("fingerprint.originator", config.originator.as_str()),
("fingerprint.app_version", config.app_version.as_str()),
("fingerprint.build_number", config.build_number.as_str()),
("fingerprint.platform", config.platform.as_str()),
("fingerprint.arch", config.arch.as_str()),
("wire_profile.originator", config.originator.as_str()),
("wire_profile.codex_version", config.codex_version.as_str()),
(
"fingerprint.chromium_version",
config.chromium_version.as_str(),
),
(
"fingerprint.user_agent_template",
config.user_agent_template.as_str(),
"wire_profile.desktop_version",
config.desktop_version.as_str(),
),
("wire_profile.desktop_build", config.desktop_build.as_str()),
("wire_profile.os_type", config.os_type.as_str()),
("wire_profile.os_version", config.os_version.as_str()),
("wire_profile.arch", config.arch.as_str()),
("wire_profile.terminal", config.terminal.as_str()),
] {
validate_nonempty(field, value)?;
}
if config.default_headers.is_empty() || config.header_order.is_empty() {
return Err(ConfigError::InvalidField("fingerprint"));
if semver::Version::parse(&config.codex_version).is_err() {
return Err(ConfigError::InvalidField("wire_profile.codex_version"));
}
let mut header_names = HashSet::new();
for header in &config.default_headers {
let name = header.name.trim().to_ascii_lowercase();
if name.is_empty()
|| header.value.is_empty()
|| HeaderName::from_bytes(name.as_bytes()).is_err()
|| !header_names.insert(name)
{
return Err(ConfigError::InvalidField("fingerprint.default_headers"));
}
if !is_numeric_dotted_version(&config.desktop_version) {
return Err(ConfigError::InvalidField("wire_profile.desktop_version"));
}
let mut ordered_headers = HashSet::new();
for header in &config.header_order {
let header = header.trim().to_ascii_lowercase();
if header.is_empty()
|| HeaderName::from_bytes(header.as_bytes()).is_err()
|| !ordered_headers.insert(header)
{
return Err(ConfigError::InvalidField("fingerprint.header_order"));
}
if !config
.desktop_build
.bytes()
.all(|byte| byte.is_ascii_digit())
{
return Err(ConfigError::InvalidField("wire_profile.desktop_build"));
}
Ok(())
}
fn is_numeric_dotted_version(value: &str) -> bool {
let mut parts = value.split('.');
let valid_parts = parts
.by_ref()
.filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
.count();
valid_parts >= 2 && valid_parts == value.split('.').count()
}
fn validate_logging(config: &LoggingConfig) -> Result<(), ConfigError> {
EnvFilter::try_new(&config.level).map_err(|_| ConfigError::InvalidField("logging.level"))?;
if !config.stdout && !config.file.enabled {
@@ -699,7 +683,7 @@ pub enum ConfigError {
path: PathBuf,
},
/// 配置格式版本不受支持。
#[error("x-cpr.schema_version must be 1")]
#[error("x-cpr.schema_version must be 2")]
UnsupportedSchemaVersion,
/// 字段值非法。
#[error("configuration field `{0}` is invalid")]
+31 -37
View File
@@ -50,7 +50,8 @@ use crate::{
},
update::service::SystemUpdateService,
upstream::openai::{
fingerprint::{Fingerprint, PgFingerprintStore, RuntimeFingerprint},
desktop_release::DesktopReleaseStatus,
profile::CodexWireProfile,
token_client::{OpenAiTokenClient, TokenClientConfig, default_openai_token_client},
transport::{
CodexBackendClient, CodexModelCatalogClient, CodexWebSocketPool,
@@ -80,7 +81,6 @@ pub struct BackgroundTaskStores {
pub admin_users: PgAdminUserStore,
pub admin_sessions: RedisAdminSessionStore,
pub cookies: PgCookieStore,
pub fingerprints: PgFingerprintStore,
pub session_affinity: RedisSessionAffinityStore,
pub refresh_leases: RedisRefreshLeaseStore,
pub model_snapshots: RedisModelSnapshotStore,
@@ -117,7 +117,8 @@ pub struct Services {
pub session_affinity: Arc<SessionAffinityService>,
pub codex: Arc<CodexBackendClient>,
pub websocket_pool: Option<Arc<CodexWebSocketPool>>,
pub fingerprint: RuntimeFingerprint,
pub wire_profile: Arc<CodexWireProfile>,
pub desktop_release: DesktopReleaseStatus,
pub account_pseudonymizer: Arc<AccountPseudonymizer>,
pub system_update: Arc<SystemUpdateService>,
pub connection_drain: crate::api::middleware::connection_drain::ConnectionDrain,
@@ -145,21 +146,21 @@ impl Services {
pub fn new(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
) -> Self {
Self::try_new(config, stores, fingerprint)
Self::try_new(config, stores, wire_profile)
.expect("failed to build runtime services with configured TLS transport")
}
pub fn try_new(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
) -> Result<Self, CustomCaError> {
Self::try_with_usage_record_options(
config,
stores,
fingerprint,
wire_profile,
UsageRecordOptions::from_config(config),
)
}
@@ -167,7 +168,7 @@ impl Services {
pub fn try_with_usage_record_options(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
usage_record_options: UsageRecordOptions,
) -> Result<Self, CustomCaError> {
let mut identity_secret = [0u8; 32];
@@ -175,7 +176,7 @@ impl Services {
Self::try_with_identity_secret_and_usage_record_options(
config,
stores,
fingerprint,
wire_profile,
identity_secret,
usage_record_options,
)
@@ -184,7 +185,7 @@ impl Services {
fn try_with_identity_secret_and_usage_record_options(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
identity_secret: [u8; 32],
usage_record_options: UsageRecordOptions,
) -> Result<Self, CustomCaError> {
@@ -214,7 +215,7 @@ impl Services {
let client = CodexBackendClient::new(
build_reqwest_client()?,
config.api.base_url.clone(),
fingerprint.clone(),
wire_profile.clone(),
)
.with_websocket_initial_event_timeout(websocket_initial_event_timeout);
if let Some(pool) = &websocket_pool {
@@ -313,6 +314,8 @@ impl Services {
}));
let system_update = Arc::new(SystemUpdateService::from_env());
let desktop_release = DesktopReleaseStatus::default();
Ok(Self {
database,
redis,
@@ -333,7 +336,8 @@ impl Services {
session_affinity,
codex,
websocket_pool,
fingerprint,
wire_profile,
desktop_release,
account_pseudonymizer,
system_update,
connection_drain,
@@ -370,7 +374,8 @@ impl From<&Services> for crate::api::router::ApiServices {
token_refresh: services.token_refresh.clone(),
responses: services.responses.clone(),
session_affinity: services.session_affinity.clone(),
fingerprint: services.fingerprint.clone(),
wire_profile: services.wire_profile.clone(),
desktop_release: services.desktop_release.clone(),
process_control: Arc::new(crate::bootstrap::shutdown::RuntimeProcessControl),
system_update: services.system_update.clone(),
connection_drain: services.connection_drain.clone(),
@@ -419,24 +424,19 @@ pub fn apply_settings_to_config(config: &mut AppConfig, settings: &SettingsSnaps
config.auth.rotation_strategy = settings.rotation_strategy.clone();
}
pub fn fingerprint_from_config(
config: &crate::bootstrap::config::FingerprintConfig,
) -> Fingerprint {
Fingerprint {
pub fn wire_profile_from_config(
config: &crate::bootstrap::config::WireProfileConfig,
) -> CodexWireProfile {
CodexWireProfile {
originator: config.originator.clone(),
app_version: config.app_version.clone(),
build_number: config.build_number.clone(),
platform: config.platform.clone(),
codex_version: config.codex_version.clone(),
desktop_version: config.desktop_version.clone(),
desktop_build: config.desktop_build.clone(),
os_type: config.os_type.clone(),
os_version: config.os_version.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(),
updated_at: None,
terminal: config.terminal.clone(),
verified_at: config.verified_at,
}
}
@@ -560,14 +560,12 @@ async fn build_router(
SettingsService::load_or_initialize(settings_snapshot_from_config(&config), &pool).await?;
apply_settings_to_config(&mut config, &settings);
let fingerprint_store = PgFingerprintStore::new(pool.clone());
let stores = BackgroundTaskStores {
redis: redis.clone(),
accounts: PgAccountStore::new(pool.clone()),
admin_users: PgAdminUserStore::new(pool.clone()),
admin_sessions: RedisAdminSessionStore::new(redis.clone()),
cookies: PgCookieStore::new(pool.clone()),
fingerprints: fingerprint_store.clone(),
session_affinity: RedisSessionAffinityStore::new(redis.clone()),
refresh_leases: RedisRefreshLeaseStore::new(redis.clone()),
model_snapshots: RedisModelSnapshotStore::new(redis.clone()),
@@ -580,16 +578,12 @@ async fn build_router(
let data_dir = ensure_data_dir(&config.runtime.data_directory)?;
let identity_secret = load_or_create_identity_secret(&data_dir)?;
let default_fingerprint = fingerprint_from_config(&config.fingerprint);
let runtime_fingerprint = fingerprint_store
.ensure_current_seed(&default_fingerprint)
.await?;
let runtime_fingerprint = RuntimeFingerprint::new(runtime_fingerprint);
let wire_profile = Arc::new(wire_profile_from_config(&config.wire_profile));
let runtime_config = crate::bootstrap::state::RuntimeConfig::from(&config);
let services = Services::try_with_identity_secret_and_usage_record_options(
&config,
stores,
runtime_fingerprint,
wire_profile,
identity_secret,
UsageRecordOptions::from_config(&config),
)?;
+11 -22
View File
@@ -1,19 +1,13 @@
//! 后台任务协调器。
use std::{future::Future, path::PathBuf, sync::Arc, time::Duration};
use std::{future::Future, sync::Arc, time::Duration};
use tokio::task::JoinHandle;
use super::{
cookie_cleanup::CookieCleanupTask,
fingerprint_update::{
CODEX_DESKTOP_APPCAST_URL, DEFAULT_EXTRACTED_FINGERPRINT_PATH,
start_fingerprint_update_task,
},
model_refresh::ModelRefreshTask,
quota_refresh::QuotaRefreshTask,
retention_trim::RetentionTrimTask,
token_refresh::TokenRefreshTask,
cookie_cleanup::CookieCleanupTask, desktop_release_update::start_desktop_release_update_task,
model_refresh::ModelRefreshTask, quota_refresh::QuotaRefreshTask,
retention_trim::RetentionTrimTask, token_refresh::TokenRefreshTask,
};
/// 后台任务协调器。
@@ -46,7 +40,6 @@ impl TaskCoordinator {
stores.usage_records.clone(),
stores.ops_errors.clone(),
stores.request_buckets.clone(),
stores.fingerprints.clone(),
)
.start(),
);
@@ -94,17 +87,13 @@ impl TaskCoordinator {
.with_cookie_store(stores.cookies.clone())
.start(),
);
coordinator.push("fingerprint_update", {
let fingerprint = services.fingerprint.snapshot();
start_fingerprint_update_task(
stores.fingerprints.clone(),
services.fingerprint.clone(),
CODEX_DESKTOP_APPCAST_URL.to_string(),
PathBuf::from(DEFAULT_EXTRACTED_FINGERPRINT_PATH),
fingerprint.app_version,
fingerprint.build_number,
)
});
coordinator.push(
"desktop_release_update",
start_desktop_release_update_task(
services.desktop_release.clone(),
crate::upstream::openai::desktop_release::CODEX_DESKTOP_APPCAST_URL.to_string(),
),
);
if let Some(pool) = &services.websocket_pool {
coordinator.push(
"websocket_pool",
@@ -0,0 +1,57 @@
//! Codex Desktop 官方发布观测任务接线。
use tracing::{info, warn};
use crate::upstream::openai::desktop_release::{
APPCAST_POLL_INTERVAL, DesktopReleaseChecker, DesktopReleaseStatus,
};
use super::{
coordinator::SchedulerHandle,
periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task},
};
/// 启动 Desktop 官方发布检查后台任务。
pub fn start_desktop_release_update_task(
status: DesktopReleaseStatus,
appcast_url: String,
) -> SchedulerHandle {
let checker = DesktopReleaseChecker::with_client(reqwest::Client::new(), appcast_url, status);
spawn_periodic_task(
DesktopReleaseUpdateTask {
checker,
first_tick: true,
},
PeriodicTaskConfig::new(
APPCAST_POLL_INTERVAL.as_secs(),
"Codex Desktop 发布检查器已启动",
"Codex Desktop 发布检查器已关闭",
),
)
}
struct DesktopReleaseUpdateTask {
checker: DesktopReleaseChecker,
first_tick: bool,
}
impl PeriodicTaskRunner for DesktopReleaseUpdateTask {
fn tick(&mut self) -> super::periodic::TaskFuture<'_, ()> {
Box::pin(async move {
let first_tick = std::mem::take(&mut self.first_tick);
match self.checker.check_and_record().await {
Ok(release) => info!(
version = %release.version,
build = %release.build,
"Codex Desktop 最新发布信息已刷新"
),
Err(error) if first_tick => {
warn!(error = %error, "Codex Desktop 首次发布检查失败");
}
Err(error) => {
warn!(error = %error, "Codex Desktop 定期发布检查失败");
}
}
})
}
}
@@ -1,85 +0,0 @@
//! 指纹更新任务接线。
use std::path::PathBuf;
use tracing::{info, warn};
use crate::upstream::openai::fingerprint::{
APPCAST_POLL_INTERVAL, PgFingerprintStore, RuntimeFingerprint, UpdateChecker,
};
use super::{
coordinator::SchedulerHandle,
periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task},
};
/// Codex Desktop 官方 appcast 地址。
pub const CODEX_DESKTOP_APPCAST_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
/// 本地可选完整指纹提取文件路径。
pub const DEFAULT_EXTRACTED_FINGERPRINT_PATH: &str = "data/extracted-fingerprint.json";
/// 启动指纹自动更新后台任务。
pub fn start_fingerprint_update_task(
store: PgFingerprintStore,
runtime_fingerprint: RuntimeFingerprint,
appcast_url: String,
extracted_fingerprint_path: PathBuf,
current_version: String,
current_build: String,
) -> SchedulerHandle {
let update_checker = UpdateChecker::with_client(
store,
reqwest::Client::new(),
appcast_url,
extracted_fingerprint_path,
current_version,
current_build,
);
spawn_periodic_task(
FingerprintUpdateTask {
checker: update_checker,
runtime_fingerprint,
first_tick: true,
},
PeriodicTaskConfig::new(
APPCAST_POLL_INTERVAL.as_secs(),
"Fingerprint 后台版本检查器已启动",
"Fingerprint 后台版本检查器已关闭",
),
)
}
struct FingerprintUpdateTask {
checker: UpdateChecker,
runtime_fingerprint: RuntimeFingerprint,
first_tick: bool,
}
impl PeriodicTaskRunner for FingerprintUpdateTask {
fn tick(&mut self) -> super::periodic::TaskFuture<'_, ()> {
Box::pin(async move {
let first_tick = std::mem::take(&mut self.first_tick);
match self.checker.check_and_apply_update().await {
Ok(Some(fingerprint)) => {
let app_version = fingerprint.app_version.clone();
let build_number = fingerprint.build_number.clone();
self.runtime_fingerprint.replace(fingerprint);
info!(
app_version = %app_version,
build_number = %build_number,
"Fingerprint 运行时版本已更新"
);
}
Ok(None) => {}
Err(error) => {
if first_tick {
warn!(error = %error, "Fingerprint 首次版本检查失败");
} else {
warn!(error = %error, "Fingerprint 定期版本检查失败");
}
}
}
})
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
mod cleanup;
pub mod cookie_cleanup;
pub mod coordinator;
pub mod fingerprint_update;
pub mod desktop_release_update;
pub mod model_refresh;
mod periodic;
pub mod quota_refresh;
+3 -21
View File
@@ -3,12 +3,9 @@
use chrono::Utc;
use tracing::{info, warn};
use crate::{
telemetry::{
buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore,
usage::store::PgUsageRecordStore,
},
upstream::openai::fingerprint::PgFingerprintStore,
use crate::telemetry::{
buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore,
usage::store::PgUsageRecordStore,
};
use super::{
@@ -17,13 +14,11 @@ use super::{
};
const RETENTION_TRIM_INTERVAL_SECS: u64 = 60 * 60;
const FINGERPRINT_HISTORY_LIMIT: std::num::NonZeroU32 = std::num::NonZeroU32::new(100).unwrap();
pub struct RetentionTrimTask {
usage_records: PgUsageRecordStore,
ops_errors: PgOpsErrorLogStore,
buckets: PgRequestBucketStore,
fingerprints: PgFingerprintStore,
}
impl RetentionTrimTask {
@@ -31,13 +26,11 @@ impl RetentionTrimTask {
usage_records: PgUsageRecordStore,
ops_errors: PgOpsErrorLogStore,
buckets: PgRequestBucketStore,
fingerprints: PgFingerprintStore,
) -> Self {
Self {
usage_records,
ops_errors,
buckets,
fingerprints,
}
}
@@ -70,17 +63,6 @@ impl RetentionTrimTask {
Ok(_) => {}
Err(error) => warn!(error = %error, "清理请求时间桶失败"),
}
match self
.fingerprints
.trim_update_history_to_limit(FINGERPRINT_HISTORY_LIMIT)
.await
{
Ok(deleted) if deleted > 0 => {
info!(deleted, "已清理超限指纹更新历史")
}
Ok(_) => {}
Err(error) => warn!(error = %error, "清理超限指纹更新历史失败"),
}
}
}
@@ -231,35 +231,3 @@ create index idx_account_cookies_account_domain
on account_cookies(account_id, domain);
create index idx_account_cookies_expires
on account_cookies(expires_at) where expires_at is not null;
create table 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 jsonb not null,
header_order_json jsonb not null,
source text not null,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table 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 jsonb,
created_at timestamptz not null
);
create index idx_fingerprint_update_history_created_id
on fingerprint_update_history(created_at desc, id desc);
create index idx_fingerprint_update_history_fingerprint
on fingerprint_update_history(current_fingerprint_id);
@@ -0,0 +1,204 @@
//! Codex Desktop 官方发布制品观测。
use std::{
sync::{Arc, RwLock},
time::Duration,
};
use chrono::{DateTime, Utc};
use roxmltree::{Document, Node};
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
/// Codex Desktop 官方 appcast 地址。
pub const CODEX_DESKTOP_APPCAST_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
/// 官方发布检查周期。
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_hours(24);
/// appcast 中的最新 Desktop 完整制品信息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesktopRelease {
pub version: String,
pub build: String,
pub published_at: Option<DateTime<Utc>>,
pub minimum_system_version: Option<String>,
pub hardware_requirements: Option<String>,
pub download_url: Option<String>,
pub download_size: Option<u64>,
pub signature_present: bool,
}
/// 最近一次 Desktop 发布检查的内存快照。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DesktopReleaseSnapshot {
pub checked_at: Option<DateTime<Utc>>,
pub latest: Option<DesktopRelease>,
pub last_error: Option<String>,
}
/// Dashboard 与后台检查器共享的 Desktop 发布观测状态。
#[derive(Debug, Clone, Default)]
pub struct DesktopReleaseStatus {
snapshot: Arc<RwLock<DesktopReleaseSnapshot>>,
}
impl DesktopReleaseStatus {
pub fn snapshot(&self) -> DesktopReleaseSnapshot {
self.snapshot
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn record_success(&self, checked_at: DateTime<Utc>, latest: DesktopRelease) {
*self
.snapshot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = DesktopReleaseSnapshot {
checked_at: Some(checked_at),
latest: Some(latest),
last_error: None,
};
}
pub fn record_failure(&self, checked_at: DateTime<Utc>, error: String) {
let mut snapshot = self
.snapshot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
snapshot.checked_at = Some(checked_at);
snapshot.last_error = Some(error);
}
}
/// 读取官方 appcast 并更新发布观测状态。
#[derive(Clone)]
pub struct DesktopReleaseChecker {
client: reqwest::Client,
appcast_url: String,
status: DesktopReleaseStatus,
}
impl DesktopReleaseChecker {
pub fn with_client(
client: reqwest::Client,
appcast_url: impl Into<String>,
status: DesktopReleaseStatus,
) -> Self {
Self {
client,
appcast_url: appcast_url.into(),
status,
}
}
pub async fn check_and_record(&self) -> Result<DesktopRelease, DesktopReleaseError> {
let checked_at = Utc::now();
let result = self.fetch_latest().await;
match result {
Ok(release) => {
self.status.record_success(checked_at, release.clone());
Ok(release)
}
Err(error) => {
self.status.record_failure(checked_at, error.to_string());
Err(error)
}
}
}
async fn fetch_latest(&self) -> Result<DesktopRelease, DesktopReleaseError> {
let response = self
.client
.get(&self.appcast_url)
.timeout(APPCAST_TIMEOUT)
.send()
.await?;
if !response.status().is_success() {
return Err(DesktopReleaseError::AppcastFetch(
response.status().as_u16(),
));
}
parse_latest_desktop_release(&response.text().await?)
}
}
/// 解析 appcast 中按顺序出现的首个完整发布项。
pub fn parse_latest_desktop_release(xml: &str) -> Result<DesktopRelease, DesktopReleaseError> {
let document = Document::parse(xml)?;
let item = document
.descendants()
.find(|node| node.is_element() && node.tag_name().name() == "item")
.ok_or(DesktopReleaseError::MissingItem)?;
let enclosure = item
.children()
.find(|node| node.is_element() && node.tag_name().name() == "enclosure");
let version = child_text(item, "shortVersionString")
.or_else(|| local_attribute(enclosure, "shortVersionString"))
.ok_or(DesktopReleaseError::MissingField("shortVersionString"))?;
let build = child_text(item, "version")
.or_else(|| local_attribute(enclosure, "version"))
.ok_or(DesktopReleaseError::MissingField("version"))?;
let published_at = child_text(item, "pubDate")
.map(|value| {
DateTime::parse_from_rfc2822(&value)
.map(|value| value.to_utc())
.map_err(|_| DesktopReleaseError::InvalidField("pubDate"))
})
.transpose()?;
let download_size = local_attribute(enclosure, "length")
.map(|value| {
value
.parse::<u64>()
.map_err(|_| DesktopReleaseError::InvalidField("enclosure.length"))
})
.transpose()?;
Ok(DesktopRelease {
version,
build,
published_at,
minimum_system_version: child_text(item, "minimumSystemVersion"),
hardware_requirements: child_text(item, "hardwareRequirements"),
download_url: local_attribute(enclosure, "url"),
download_size,
signature_present: local_attribute(enclosure, "edSignature").is_some(),
})
}
fn child_text(parent: Node<'_, '_>, local_name: &str) -> Option<String> {
parent
.children()
.find(|node| node.is_element() && node.tag_name().name() == local_name)
.and_then(|node| node.text())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn local_attribute(node: Option<Node<'_, '_>>, local_name: &str) -> Option<String> {
node?
.attributes()
.find(|attribute| attribute.name() == local_name)
.map(|attribute| attribute.value().trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
#[derive(Debug, thiserror::Error)]
pub enum DesktopReleaseError {
#[error("Codex Desktop appcast request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Codex Desktop appcast returned HTTP {0}")]
AppcastFetch(u16),
#[error("Codex Desktop appcast XML is invalid: {0}")]
Xml(#[from] roxmltree::Error),
#[error("Codex Desktop appcast contains no release item")]
MissingItem,
#[error("Codex Desktop appcast release is missing `{0}`")]
MissingField(&'static str),
#[error("Codex Desktop appcast field `{0}` is invalid")]
InvalidField(&'static str),
}
@@ -1,11 +0,0 @@
//! OpenAI 客户端指纹。
mod runtime;
mod store;
mod types;
mod updater;
pub use runtime::*;
pub use store::*;
pub use types::*;
pub use updater::*;
@@ -1,40 +0,0 @@
//! OpenAI 客户端运行时指纹快照。
use std::sync::{Arc, RwLock};
use super::types::Fingerprint;
#[derive(Debug, Clone)]
pub struct RuntimeFingerprint {
current: Arc<RwLock<Arc<Fingerprint>>>,
}
impl RuntimeFingerprint {
pub fn new(fingerprint: Fingerprint) -> Self {
Self {
current: Arc::new(RwLock::new(Arc::new(fingerprint))),
}
}
pub fn current(&self) -> Arc<Fingerprint> {
self.current
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn snapshot(&self) -> Fingerprint {
self.current().as_ref().clone()
}
pub fn replace(&self, fingerprint: Fingerprint) {
*self
.current
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(fingerprint);
}
}
// ---------------------------------------------------------------------------
// 指纹历史记录与仓储
// ---------------------------------------------------------------------------
@@ -1,245 +0,0 @@
//! OpenAI 客户端指纹 PostgreSQL 存储。
use std::num::NonZeroU32;
use chrono::{DateTime, Utc};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use super::types::Fingerprint;
pub const CURRENT_FINGERPRINT_ID: &str = "current";
const AUTO_UPDATE_SOURCE: &str = "auto_update";
const CONFIG_SEED_SOURCE: &str = "config_seed";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredHeader {
name: String,
value: String,
}
/// 指纹自动更新状态。
#[derive(Clone)]
pub struct PgFingerprintStore {
pool: PgPool,
}
impl PgFingerprintStore {
/// 使用给定连接池构造仓储。
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// 写入当前指纹默认值;如果当前槽位已存在,直接读取数据库值。
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();
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
on conflict (id) do nothing
",
)
.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(sqlx::types::Json(encode_default_headers(
&default_fingerprint.default_headers,
)))
.bind(sqlx::types::Json(&default_fingerprint.header_order))
.bind(CONFIG_SEED_SOURCE)
.bind(now)
.execute(&self.pool)
.await?;
self.load_current().await?.ok_or(sqlx::Error::RowNotFound)
}
/// 更新当前指纹中的自动更新版本字段。
pub async fn update_current_version(
&self,
app_version: &str,
build_number: &str,
chromium_version: Option<&str>,
) -> 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 = $1",
)
.bind(CURRENT_FINGERPRINT_ID)
.fetch_one(&self.pool)
.await?
}
};
let result = sqlx::query(
"update fingerprints set app_version = $1, build_number = $2, chromium_version = $3, source = $4, updated_at = $5 where id = $6",
)
.bind(app_version)
.bind(build_number)
.bind(resolved_chromium_version)
.bind(AUTO_UPDATE_SOURCE)
.bind(Utc::now())
.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,
updated_at
from fingerprints
where id = $1
",
)
.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> {
let manifest = manifest_json
.map(serde_json::from_str::<serde_json::Value>)
.transpose()
.map_err(json_error)?;
sqlx::query(
"insert into fingerprint_update_history (id, current_fingerprint_id, app_version, build_number, chromium_version, source, manifest_json, created_at) values ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.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.map(sqlx::types::Json))
.bind(Utc::now())
.execute(&self.pool)
.await?;
Ok(())
}
/// 将指纹更新历史裁剪为最新的指定条数。
pub async fn trim_update_history_to_limit(
&self,
max_entries: NonZeroU32,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r"
delete from fingerprint_update_history as target
using (
select id
from (
select
id,
row_number() over (order by created_at desc, id desc) as position
from fingerprint_update_history
) as ranked
where position > $1
) as expired
where target.id = expired.id
",
)
.bind(i64::from(max_entries.get()))
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
fn fingerprint_from_row(row: &sqlx::postgres::PgRow) -> Result<Fingerprint, sqlx::Error> {
let default_headers = row
.try_get::<sqlx::types::Json<Vec<StoredHeader>>, _>("default_headers_json")?
.0;
let header_order = row
.try_get::<sqlx::types::Json<Vec<String>>, _>("header_order_json")?
.0;
let updated_at = row.try_get::<DateTime<Utc>, _>("updated_at")?;
Ok(Fingerprint {
originator: row.try_get("originator")?,
app_version: row.try_get("app_version")?,
build_number: row.try_get("build_number")?,
platform: row.try_get("platform")?,
arch: row.try_get("arch")?,
chromium_version: row.try_get("chromium_version")?,
user_agent_template: row.try_get("user_agent_template")?,
default_headers: decode_default_headers(default_headers),
header_order,
updated_at: Some(updated_at.to_rfc3339()),
})
}
fn encode_default_headers(headers: &IndexMap<String, String>) -> Vec<StoredHeader> {
headers
.iter()
.map(|(name, value)| StoredHeader {
name: name.clone(),
value: value.clone(),
})
.collect()
}
fn decode_default_headers(headers: Vec<StoredHeader>) -> IndexMap<String, String> {
headers
.into_iter()
.map(|header| (header.name, header.value))
.collect()
}
fn json_error(error: serde_json::Error) -> sqlx::Error {
sqlx::Error::Decode(Box::new(error))
}
@@ -1,92 +0,0 @@
//! OpenAI 客户端指纹领域类型。
use chrono::{DateTime, Utc};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Fingerprint {
/// 客户端来源名。
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: IndexMap<String, String>,
/// 请求头顺序。
pub header_order: Vec<String>,
/// DB 最后更新时间。
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl Fingerprint {
/// 根据模板展开最终 User-Agent。
pub fn user_agent(&self) -> String {
self.user_agent_template
.replace("{version}", &self.app_version)
.replace("{platform}", &self.platform)
.replace("{arch}", &self.arch)
}
/// 生成 `sec-ch-ua` 头值。
pub fn sec_ch_ua(&self) -> String {
format!(
"\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"",
self.chromium_version
)
}
}
/// 运行时共享指纹快照。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateState {
/// 最近检查时间。
pub last_check: DateTime<Utc>,
/// 最新版本。
pub latest_version: Option<String>,
/// 最新构建号。
pub latest_build: Option<String>,
/// 下载地址。
pub download_url: Option<String>,
/// 是否有可用更新。
pub update_available: bool,
/// 当前版本。
pub current_version: String,
/// 当前构建号。
pub current_build: String,
}
/// Appcast 检查错误。
#[derive(Debug, Error)]
pub enum UpdateError {
/// HTTP 请求失败。
#[error("HTTP 请求失败: {0}")]
Http(#[from] reqwest::Error),
/// 获取 appcast 失败。
#[error("获取 appcast 失败,状态码: {0}")]
AppcastFetch(u16),
/// 解析 appcast 失败。
#[error("解析 appcast 失败")]
AppcastParse,
/// JSON 序列化失败。
#[error("JSON 序列化失败: {0}")]
Json(#[from] serde_json::Error),
/// 文件操作失败。
#[error("文件操作失败: {0}")]
Io(#[from] std::io::Error),
/// 数据库存储失败。
#[error("数据库操作失败: {0}")]
Database(#[from] sqlx::Error),
}
@@ -1,215 +0,0 @@
//! OpenAI 客户端指纹更新检查器。
use std::{path::PathBuf, sync::Arc, time::Duration};
use chrono::Utc;
use tokio::sync::Mutex;
use tracing::info;
use super::{
store::PgFingerprintStore,
types::{Fingerprint, UpdateError, UpdateState},
};
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_hours(24);
type AppcastFields = (Option<String>, Option<String>, Option<String>);
/// Appcast 指纹更新检查器。
#[derive(Clone)]
pub struct UpdateChecker {
store: PgFingerprintStore,
client: reqwest::Client,
appcast_url: String,
extracted_fingerprint_path: PathBuf,
state: Arc<Mutex<InternalState>>,
}
struct InternalState {
current_version: String,
current_build: String,
}
impl UpdateChecker {
/// 使用自定义 client 和路径构造检查器。
pub fn with_client(
store: PgFingerprintStore,
client: reqwest::Client,
appcast_url: impl Into<String>,
extracted_fingerprint_path: PathBuf,
current_version: impl Into<String>,
current_build: impl Into<String>,
) -> Self {
Self {
store,
client,
appcast_url: appcast_url.into(),
extracted_fingerprint_path,
state: Arc::new(Mutex::new(InternalState {
current_version: current_version.into(),
current_build: current_build.into(),
})),
}
}
/// 检查 appcast 是否存在更新。
pub async fn check_for_update(&self) -> Result<UpdateState, UpdateError> {
let state = self.state.lock().await;
let current_version = state.current_version.clone();
let current_build = state.current_build.clone();
drop(state);
let xml = self.fetch_appcast().await?;
let (version, build, download_url) = parse_appcast(&xml)?;
let update_available = version
.as_ref()
.is_some_and(|value| value != &current_version)
|| build.as_ref().is_some_and(|value| value != &current_build);
Ok(UpdateState {
last_check: Utc::now(),
latest_version: version,
latest_build: build,
download_url,
update_available,
current_version,
current_build,
})
}
/// 检查并在需要时应用更新。
pub async fn check_and_apply_update(&self) -> Result<Option<Fingerprint>, UpdateError> {
let update_state = self.check_for_update().await?;
if !update_state.update_available {
return Ok(None);
}
let (Some(version), Some(build)) = (
update_state.latest_version.as_deref(),
update_state.latest_build.as_deref(),
) else {
return Ok(None);
};
let state = self.state.lock().await;
let current_version = state.current_version.clone();
let current_build = state.current_build.clone();
drop(state);
info!(
version = %version,
build = %build,
current_version = %current_version,
current_build = %current_build,
"发现新的 fingerprint 版本"
);
let updated_fingerprint = self.apply_version_update(version, build).await?;
{
let mut state = self.state.lock().await;
state.current_version = version.to_string();
state.current_build = build.to_string();
}
Ok(Some(updated_fingerprint))
}
async fn fetch_appcast(&self) -> Result<String, UpdateError> {
let response = self
.client
.get(&self.appcast_url)
.timeout(APPCAST_TIMEOUT)
.send()
.await?;
if !response.status().is_success() {
return Err(UpdateError::AppcastFetch(response.status().as_u16()));
}
response.text().await.map_err(UpdateError::from)
}
async fn apply_version_update(
&self,
version: &str,
build: &str,
) -> Result<Fingerprint, UpdateError> {
let chromium_version =
load_matching_chromium_version(&self.extracted_fingerprint_path, version, build);
self.store
.update_current_version(version, build, chromium_version.as_deref())
.await?;
self.store
.insert_update_history(version, build, chromium_version.as_deref(), None)
.await?;
let updated = self
.store
.load_current()
.await?
.ok_or(sqlx::Error::RowNotFound)?;
Ok(updated)
}
}
fn parse_appcast(xml: &str) -> Result<AppcastFields, UpdateError> {
let item_start = xml.find("<item>").ok_or(UpdateError::AppcastParse)?;
let item_end = xml.find("</item>").ok_or(UpdateError::AppcastParse)?;
let item = &xml[item_start..item_end];
let version = extract_sparkle_field(item, "shortVersionString");
let build = extract_sparkle_field(item, "version");
let download_url = item.find("url=\"").and_then(|position| {
let start = position + 5;
item[start..]
.find('"')
.map(|end| item[start..start + end].to_string())
});
Ok((version, build, download_url))
}
fn extract_sparkle_field(item: &str, field: &str) -> Option<String> {
let attr_pattern = format!("sparkle:{}=\"", field);
if let Some(position) = item.find(&attr_pattern) {
let start = position + attr_pattern.len();
if let Some(end) = item[start..].find('"') {
return Some(item[start..start + end].to_string());
}
}
let elem_start = format!("<sparkle:{}>", field);
let elem_end = format!("</sparkle:{}>", field);
if let Some(start_position) = item.find(&elem_start) {
let content_start = start_position + elem_start.len();
if let Some(end_position) = item[content_start..].find(&elem_end) {
return Some(item[content_start..content_start + end_position].to_string());
}
}
None
}
fn load_matching_chromium_version(
extracted_fingerprint_path: &PathBuf,
version: &str,
build: &str,
) -> Option<String> {
let content = std::fs::read_to_string(extracted_fingerprint_path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
let extracted_version = parsed.get("app_version")?.as_str()?;
let extracted_build = parsed.get("build_number")?.as_str()?;
if extracted_version == version && extracted_build == build {
parsed
.get("chromium_version")?
.as_str()
.map(ToString::to_string)
} else {
None
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
//! OpenAI/Codex 上游客户端。
pub mod desktop_release;
pub mod failure;
pub mod fingerprint;
pub mod profile;
pub mod protocol;
pub mod token_client;
pub mod transport;
+46
View File
@@ -0,0 +1,46 @@
//! Codex Desktop 上游请求画像。
use chrono::{DateTime, Utc};
/// 经制品与 Codex 源码核验后固定的上游请求身份。
///
/// 该画像是启动配置,不会被在线发布检查自动改写。Desktop 发布变化后应先重新审计
/// bundled Codex Core,再整体更新此画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexWireProfile {
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// bundled Codex Core 版本;同时用于 `/codex/models?client_version=`。
pub codex_version: String,
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
pub desktop_version: String,
/// Desktop 制品构建号,仅用于发布对齐诊断。
pub desktop_build: String,
/// Codex Core UA 中的目标操作系统类型。
pub os_type: String,
/// Codex Core UA 中的目标操作系统版本。
pub os_version: String,
/// Codex Core UA 中的目标架构。
pub arch: String,
/// Codex Core UA 中的终端标记。
pub terminal: String,
/// 此画像最后一次经制品与源码核验的时间。
pub verified_at: DateTime<Utc>,
}
impl CodexWireProfile {
/// 按 Codex Core 的官方格式生成最终 User-Agent。
pub fn user_agent(&self) -> String {
format!(
"{}/{} ({} {}; {}) {} ({}; {})",
self.originator,
self.codex_version,
self.os_type,
self.os_version,
self.arch,
self.terminal,
self.originator,
self.desktop_version,
)
}
}
@@ -13,15 +13,15 @@ use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::{
Client, Response as ReqwestResponse, StatusCode,
header::{
ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName,
HeaderValue, RETRY_AFTER, USER_AGENT,
ACCEPT, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, HeaderValue,
RETRY_AFTER, USER_AGENT,
},
};
use serde_json::{Value, map::Map};
use thiserror::Error;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use crate::upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint};
use crate::upstream::openai::profile::CodexWireProfile;
use crate::upstream::openai::protocol::events::{extract_sse_usage, retry_after_seconds_from_body};
use crate::upstream::openai::protocol::responses::{
CodexResponsesRequest, TransportRequirement, transport_requirement,
@@ -33,10 +33,7 @@ use crate::upstream::openai::protocol::websocket::{
use super::diagnostics::CodexUpstreamDiagnostics;
use super::endpoints::{CODEX_RESPONSES_PATH, endpoint_url};
use super::headers::{
build_ordered_codex_base_headers, insert_optional_header, insert_ordered_headers,
websocket_header_pairs,
};
use super::headers::{build_codex_base_headers, insert_optional_header, websocket_header_pairs};
use super::response_meta;
use super::tls::{CustomCaError, build_reqwest_client_with_custom_ca, custom_ca_env_cache_key};
use super::websocket::{
@@ -385,7 +382,7 @@ pub struct CodexBackendStreamingResponse {
pub struct CodexBackendClient {
pub(super) client: Client,
pub(super) base_url: String,
fingerprint: RuntimeFingerprint,
profile: Arc<CodexWireProfile>,
websocket_pool: Option<Arc<CodexWebSocketPool>>,
websocket_initial_event_timeout: Option<Duration>,
websocket_fast_path_budget: Duration,
@@ -5,14 +5,14 @@ impl CodexBackendClient {
pub fn new(
client: Client,
base_url: impl Into<String>,
fingerprint: RuntimeFingerprint,
profile: Arc<CodexWireProfile>,
) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
client,
websocket_origin_key: websocket_origin_key(&base_url),
base_url,
fingerprint,
profile,
websocket_pool: None,
websocket_initial_event_timeout: Some(DEFAULT_INITIAL_EVENT_TIMEOUT),
websocket_fast_path_budget: WEBSOCKET_FAST_PATH_BUDGET,
@@ -500,13 +500,12 @@ impl CodexBackendClient {
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<Vec<Value>> {
let fingerprint = self.fingerprint.current();
let endpoint = endpoint_url(&self.base_url, "codex/models");
let headers = self.auxiliary_request_headers(&fingerprint, context)?;
let headers = self.auxiliary_request_headers(context)?;
let response = self
.client
.get(endpoint)
.query(&[("client_version", fingerprint.app_version.as_str())])
.query(&[("client_version", self.profile.codex_version.as_str())])
.headers(headers)
.send()
.await?;
@@ -571,16 +570,9 @@ impl CodexBackendClient {
}
fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult<HeaderMap> {
let fingerprint = self.fingerprint.current();
let request_id = context.client_request_id.unwrap_or(context.request_id);
let ordered_headers = build_ordered_codex_base_headers(
&fingerprint,
context.access_token,
context.account_id,
);
let mut headers = HeaderMap::new();
insert_ordered_headers(&mut headers, &ordered_headers)?;
let mut headers =
build_codex_base_headers(&self.profile, context.access_token, context.account_id)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
@@ -625,19 +617,14 @@ impl CodexBackendClient {
fn auxiliary_request_headers(
&self,
fingerprint: &Fingerprint,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let ordered_headers =
build_ordered_codex_base_headers(fingerprint, context.access_token, context.account_id);
let mut headers = HeaderMap::new();
insert_ordered_headers(&mut headers, &ordered_headers)?;
let mut headers =
build_codex_base_headers(&self.profile, context.access_token, context.account_id)?;
if let Some(cookie_header) = context.cookie_header {
headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?);
}
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate"));
insert_optional_header(
&mut headers,
"x-codex-installation-id",
@@ -650,11 +637,10 @@ impl CodexBackendClient {
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let fingerprint = self.fingerprint.current();
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&fingerprint.user_agent())?,
HeaderValue::from_str(&self.profile.user_agent())?,
);
headers.insert(
AUTHORIZATION,
@@ -662,7 +648,7 @@ impl CodexBackendClient {
);
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_str(&fingerprint.originator)?,
HeaderValue::from_str(&self.profile.originator)?,
);
insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?;
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
@@ -1,102 +1,49 @@
use indexmap::IndexMap;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use crate::upstream::openai::fingerprint::Fingerprint;
use crate::upstream::openai::profile::CodexWireProfile;
use super::client::CodexClientResult;
/// 构造标准 Codex 请求头。
/// 构造 Codex Core 为模型请求设置的稳定身份请求头。
pub fn build_codex_base_headers(
fingerprint: &Fingerprint,
profile: &CodexWireProfile,
access_token: &str,
account_id: Option<&str>,
) -> IndexMap<String, String> {
let mut headers = IndexMap::new();
) -> CodexClientResult<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
"authorization".to_string(),
format!("Bearer {access_token}"),
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access_token}"))?,
);
if let Some(account_id) = account_id {
headers.insert("chatgpt-account-id".to_string(), account_id.to_string());
}
headers.insert("originator".to_string(), fingerprint.originator.clone());
headers.insert("user-agent".to_string(), fingerprint.user_agent());
headers.insert("sec-ch-ua".to_string(), fingerprint.sec_ch_ua());
for (key, value) in &fingerprint.default_headers {
let key_lower = key.to_ascii_lowercase();
if !headers.contains_key(&key_lower) {
headers.insert(key_lower, value.clone());
}
}
headers
insert_optional_header(&mut headers, "chatgpt-account-id", account_id)?;
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_str(&profile.originator)?,
);
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
Ok(headers)
}
/// 构造包含标准请求头和额外请求头值的完整请求头集合。
/// 构造含通用请求上下文的 Codex 模型请求头。
pub fn build_codex_headers(
fingerprint: &Fingerprint,
profile: &CodexWireProfile,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let mut headers = build_codex_base_headers(fingerprint, access_token, account_id);
) -> CodexClientResult<HeaderMap> {
let mut headers = build_codex_base_headers(profile, access_token, account_id)?;
headers.insert(
"x-openai-internal-codex-residency".to_string(),
"us".to_string(),
HeaderName::from_static("x-openai-internal-codex-residency"),
HeaderValue::from_static("us"),
);
headers.insert("x-client-request-id".to_string(), request_id.to_string());
if let Some(turn_state) = turn_state {
headers.insert("x-codex-turn-state".to_string(), turn_state.to_string());
}
headers.insert("accept".to_string(), "text/event-stream".to_string());
headers
}
/// 按给定顺序重排请求头。
pub fn order_headers(
headers: IndexMap<String, String>,
order: &[String],
) -> IndexMap<String, String> {
let mut ordered = IndexMap::new();
for key in order {
if let Some(value) = headers.get(key) {
ordered.insert(key.clone(), value.clone());
}
}
for (key, value) in headers {
if !ordered.contains_key(&key) {
ordered.insert(key, value);
}
}
ordered
}
/// 构造并按指纹顺序重排完整请求头。
pub fn build_ordered_codex_headers(
fingerprint: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let headers = build_codex_headers(
fingerprint,
access_token,
account_id,
turn_state,
request_id,
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(request_id)?,
);
order_headers(headers, &fingerprint.header_order)
}
/// 构造并按指纹顺序重排基础请求头(不含请求 ID 和 turn state)。
pub fn build_ordered_codex_base_headers(
fingerprint: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
) -> IndexMap<String, String> {
let headers = build_codex_base_headers(fingerprint, access_token, account_id);
order_headers(headers, &fingerprint.header_order)
insert_optional_header(&mut headers, "x-codex-turn-state", turn_state)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
Ok(headers)
}
pub(super) fn insert_optional_header(
@@ -111,19 +58,6 @@ pub(super) fn insert_optional_header(
Ok(())
}
pub(super) fn insert_ordered_headers(
headers: &mut HeaderMap,
ordered_headers: &IndexMap<String, String>,
) -> CodexClientResult<()> {
for (name, value) in ordered_headers {
headers.insert(
HeaderName::from_bytes(name.as_bytes())?,
HeaderValue::from_str(value)?,
);
}
Ok(())
}
fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
headers
.iter()
+1 -4
View File
@@ -26,10 +26,7 @@ pub use self::{
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, WHAM_USAGE_PATH,
endpoint_request_path, endpoint_url, usage_endpoint_urls,
},
headers::{
build_codex_base_headers, build_codex_headers, build_ordered_codex_base_headers,
build_ordered_codex_headers, order_headers,
},
headers::{build_codex_base_headers, build_codex_headers},
websocket_pool::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
@@ -18,12 +18,8 @@ async fn admin_accounts_import_should_store_cpr_account_tokens() {
);
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -618,12 +614,8 @@ async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_q
let mut config = test_config(test_database_url());
config.api.base_url = format!("{}/backend-api", server.uri());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
let access_token = test_jwt_with_exp(
@@ -830,12 +822,8 @@ async fn admin_accounts_import_should_require_admin_session_cookie() {
let redis = create_test_redis("admin-accounts-import-auth").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -11,12 +11,8 @@ async fn admin_accounts_lifecycle_should_update_and_delete_accounts() {
seed_account_related_rows(&pool, "acct_lifecycle").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis.clone());
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let session_affinity =
codex_proxy_rs::dispatch::affinity::RedisSessionAffinityStore::new(redis);
@@ -873,12 +869,8 @@ async fn admin_account_create_should_derive_claims_and_store_plain_tokens() {
);
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state.clone());
@@ -907,12 +899,8 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un
seed_admin_session(&pool, &redis, "session_1").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -15,12 +15,8 @@ async fn admin_accounts_list_should_not_expose_account_tokens() {
.execute(&pool).await.unwrap();
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -23,9 +23,9 @@ use crate::support::jwt::unsigned_jwt;
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
mod export_routes;
@@ -196,12 +196,8 @@ async fn admin_accounts_test_app_with_overrides(
config.auth.oauth_token_endpoint = oauth_token_endpoint;
}
let stores = background_task_stores(pool.clone(), redis.clone());
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state.clone());
(
@@ -9,9 +9,9 @@ use tower::util::ServiceExt;
use crate::support::{
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -237,11 +237,11 @@ async fn admin_login_test_app(
seed_admin_user(&pool, password).await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(codex_proxy_rs::bootstrap::services::Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
wire_profile(profile),
));
let state = codex_proxy_rs::api::AppState::from(services.as_ref());
(
+60 -106
View File
@@ -8,7 +8,7 @@ use codex_proxy_rs::{
bootstrap::services::Services,
infra::time::{china_datetime, china_day_start, china_quarter_hour_start},
telemetry::{usage::store::PgUsageRecordStore, usage::types::UsageRecord},
upstream::openai::fingerprint::Fingerprint,
upstream::openai::profile::CodexWireProfile,
};
use serde_json::{Value, json};
use sqlx::PgPool;
@@ -16,49 +16,54 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::test_wire_profile,
};
#[tokio::test]
async fn dashboard_summary_should_render_fingerprint_updated_at_as_beijing_time() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-fingerprint-updated",
crate::support::fingerprint::test_fingerprint_with_updated_at(Some(
"2026-06-23T11:36:46.965574590+00:00",
)),
)
.await;
async fn dashboard_summary_should_expose_effective_wire_profile() {
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-wire-profile", test_wire_profile()).await;
let body = dashboard_summary(app).await;
assert_eq!(
service_status_value(&body, "更新时间"),
"2026-06-23 19:36:46"
body["data"]["wireProfile"]["userAgent"],
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
assert_eq!(body["data"]["wireProfile"]["codexVersion"], "0.144.2");
assert_eq!(
body["data"]["wireProfile"]["desktopVersion"],
"26.707.72221"
);
assert_eq!(body["data"]["wireProfile"]["desktopBuild"], "5307");
assert_eq!(body["data"]["wireProfile"]["target"]["osType"], "Mac OS");
assert_eq!(body["data"]["wireProfile"]["target"]["arch"], "arm64");
assert_eq!(
body["data"]["wireProfile"]["verifiedAt"],
"2026-07-14T18:25:50Z"
);
}
#[tokio::test]
async fn dashboard_summary_should_render_dash_when_fingerprint_updated_at_is_missing() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-fingerprint-missing",
crate::support::fingerprint::test_fingerprint(),
)
.await;
async fn dashboard_summary_should_keep_release_observation_separate_from_wire_profile() {
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-release-unchecked", test_wire_profile()).await;
let body = dashboard_summary(app).await;
assert_eq!(service_status_value(&body, "更新时间"), "-");
assert_eq!(
body["data"]["wireProfile"]["release"]["status"],
"unchecked"
);
assert!(body["data"].get("serviceStatuses").is_none());
}
#[tokio::test]
async fn dashboard_summary_should_report_database_failure() {
let (app, _store, pool, _dir) = dashboard_test_app(
"dashboard-summary-database-failure",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, pool, _dir) =
dashboard_test_app("dashboard-summary-database-failure", test_wire_profile()).await;
pool.close().await;
let response = dashboard_response(app, "/api/admin/dashboard/summary").await;
@@ -72,11 +77,8 @@ async fn dashboard_summary_should_report_database_failure() {
#[tokio::test]
async fn dashboard_trend_should_report_database_failure() {
let (app, _store, pool, _dir) = dashboard_test_app(
"dashboard-trend-database-failure",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, pool, _dir) =
dashboard_test_app("dashboard-trend-database-failure", test_wire_profile()).await;
pool.close().await;
let response = dashboard_response(app, "/api/admin/dashboard/trend?kind=usage").await;
@@ -91,11 +93,8 @@ async fn dashboard_trend_should_report_database_failure() {
#[tokio::test]
async fn dashboard_summary_should_calculate_today_traffic_and_average_first_token_latency_from_time_buckets()
{
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-traffic-latency",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-traffic-latency", test_wire_profile()).await;
let now = Utc::now();
let today_start = china_day_start(now);
let today_record_at = if now - today_start > Duration::minutes(1) {
@@ -151,7 +150,7 @@ async fn dashboard_summary_should_calculate_today_traffic_and_average_first_toke
async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-time-buckets-survive-usage-record-clear",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
let mut record = usage_record_with_tokens(Utc::now(), 12);
@@ -177,11 +176,8 @@ async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() {
#[tokio::test]
async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-china-quarter-hour",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-trend-china-quarter-hour", test_wire_profile()).await;
let record_at = Utc::now();
store
.append(&usage_record_with_token_parts(record_at, 40, 60, 10))
@@ -205,11 +201,8 @@ async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() {
#[tokio::test]
async fn dashboard_trend_should_only_include_china_today() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-china-today",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-trend-china-today", test_wire_profile()).await;
let now = Utc::now();
let today_start = china_day_start(now);
store
@@ -237,11 +230,8 @@ async fn dashboard_trend_should_only_include_china_today() {
#[tokio::test]
async fn dashboard_latency_trend_should_use_first_token_latency() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-first-token-latency",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-trend-first-token-latency", test_wire_profile()).await;
let now = Utc::now();
let mut slow_completion = usage_record_with_tokens(now, 10);
slow_completion.latency_ms = Some(10_000);
@@ -277,11 +267,8 @@ async fn dashboard_latency_trend_should_use_first_token_latency() {
#[tokio::test]
async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-empty-latency",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-trend-empty-latency", test_wire_profile()).await;
let body = dashboard_trend(app, "latency").await;
let point = &body["data"]["points"][0];
@@ -312,11 +299,8 @@ async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() {
#[tokio::test]
async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-empty-success-rate",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-trend-empty-success-rate", test_wire_profile()).await;
let body = dashboard_trend(app, "errors").await;
let point = &body["data"]["points"][0];
@@ -346,7 +330,7 @@ async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() {
async fn dashboard_summary_should_return_requested_trend_kind() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-summary-requested-trend-kind",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
let now = Utc::now();
@@ -443,11 +427,8 @@ async fn dashboard_summary_should_match_versioned_model_names_for_billing() {
#[tokio::test]
async fn dashboard_summary_should_return_backend_formatted_time_fields() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-backend-formatted-time",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-backend-formatted-time", test_wire_profile()).await;
let record_at = Utc::now() - Duration::seconds(1);
let mut record = usage_record_with_tokens(record_at, 10);
record.metadata = json!({
@@ -470,11 +451,8 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() {
#[tokio::test]
async fn dashboard_summary_should_only_return_today_account_usage_and_usage_records() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-summary-today-records",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-summary-today-records", test_wire_profile()).await;
seed_dashboard_account(&pool, "acct_today", "today@example.com").await;
seed_dashboard_account(&pool, "acct_yesterday", "yesterday@example.com").await;
let now = Utc::now();
@@ -519,11 +497,8 @@ async fn dashboard_summary_should_only_return_today_account_usage_and_usage_reco
#[tokio::test]
async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-account-usage-recent-order",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-account-usage-recent-order", test_wire_profile()).await;
seed_dashboard_account(&pool, "acct_old_heavy", "old-heavy@example.com").await;
seed_dashboard_account(&pool, "acct_recent_light", "recent-light@example.com").await;
let now = Utc::now();
@@ -563,7 +538,7 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-account-usage-five-hour-quota",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
seed_dashboard_account(&pool, "acct_quota_five_hour", "quota-five-hour@example.com").await;
@@ -612,11 +587,8 @@ async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage()
#[tokio::test]
async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hour_missing() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-account-usage-weekly-quota",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-account-usage-weekly-quota", test_wire_profile()).await;
seed_dashboard_account(&pool, "acct_quota_weekly", "quota-weekly@example.com").await;
store
.append(&usage_record_with_account_tokens(
@@ -663,11 +635,8 @@ async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hou
#[tokio::test]
async fn dashboard_summary_should_return_today_health_timeline() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-health-timeline",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-health-timeline", test_wire_profile()).await;
store
.append(&usage_record_with_tokens(Utc::now(), 10))
.await
@@ -693,7 +662,7 @@ async fn dashboard_summary_should_return_today_health_timeline() {
async fn dashboard_summary_should_include_retained_history_in_total_billing() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-total-billing-retained-history",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
let metadata = json!({
@@ -731,8 +700,7 @@ async fn dashboard_summary_total_billing_for_usage_record(
model: &str,
metadata: Value,
) -> String {
let (app, store, _pool, _dir) =
dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await;
let (app, store, _pool, _dir) = dashboard_test_app(db_name, test_wire_profile()).await;
let mut record = usage_record(Utc::now(), model, metadata.clone());
record.input_tokens = metadata
.pointer("/usage/inputTokens")
@@ -761,7 +729,7 @@ async fn dashboard_summary_total_billing_for_usage_record(
async fn dashboard_test_app(
db_name: &str,
fingerprint: Fingerprint,
wire_profile: std::sync::Arc<CodexWireProfile>,
) -> (
axum::Router,
PgUsageRecordStore,
@@ -773,11 +741,7 @@ async fn dashboard_test_app(
seed_admin_session(&pool, &redis, "session_1").await;
let config = crate::support::config::test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile));
let state = AppState::from(services.as_ref());
(
codex_proxy_rs::api::router::router().with_state(state),
@@ -888,16 +852,6 @@ async fn set_account_quota_json(pool: &PgPool, account_id: &str, quota_json: Val
.unwrap();
}
fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str {
body["data"]["serviceStatuses"]
.as_array()
.unwrap()
.iter()
.find(|status| status["label"] == label)
.and_then(|status| status["value"].as_str())
.unwrap()
}
fn usage_record_with_tokens(created_at: chrono::DateTime<Utc>, total_tokens: u64) -> UsageRecord {
usage_record_with_token_parts(created_at, total_tokens, 0, 0)
}
+3 -7
View File
@@ -10,9 +10,9 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
mod authorization;
@@ -30,12 +30,8 @@ async fn admin_client_key_test_app(
seed_admin_session(&pool, &redis, "session_1").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
(
codex_proxy_rs::api::router::router().with_state(state),
@@ -25,9 +25,9 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
config::test_config as base_test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -261,8 +261,8 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
let config = test_config(test_database_url());
seed_admin_session(&pool, &redis, "session_1").await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Services::new(&config, stores, runtime_fingerprint(fingerprint));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Services::new(&config, stores, wire_profile(profile));
let services = std::sync::Arc::new(services);
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -356,12 +356,8 @@ async fn admin_settings_update_should_apply_runtime_services() {
seed_schedulable_account(&pool, "acct_runtime_a").await;
seed_schedulable_account(&pool, "acct_runtime_b").await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let settings_tasks = TaskCoordinator::start_settings_subscriptions(&services);
services.account_pool.restore_from_store().await.unwrap();
let state = AppState::from(services.as_ref());
@@ -466,12 +462,8 @@ async fn admin_settings_update_should_reject_unsupported_or_invalid_fields() {
let config = test_config(test_database_url());
seed_admin_session(&pool, &redis, "session_1").await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -501,12 +493,8 @@ async fn admin_settings_test_app(
seed_admin_session(&pool, &redis, "session_1").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
(codex_proxy_rs::api::router::router().with_state(state), dir)
}
+2 -2
View File
@@ -28,8 +28,8 @@ use wiremock::{
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::{runtime_fingerprint, test_fingerprint},
http::response_json,
wire_profile::{test_wire_profile_value, wire_profile},
};
const VERSION_OLD: &str = "0.1.0";
@@ -1269,7 +1269,7 @@ async fn admin_system_test_app_with_process_control(
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(test_fingerprint()),
wire_profile(test_wire_profile_value()),
));
let mut state = AppState::from(services.as_ref());
state.services.system_update = Arc::new(SystemUpdateService::new(update_config));
+2 -2
View File
@@ -16,9 +16,9 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -365,7 +365,7 @@ async fn monitoring_test_app(
let services = Services::new(
&config,
stores,
runtime_fingerprint(crate::support::fingerprint::test_fingerprint()),
wire_profile(crate::support::wire_profile::test_wire_profile_value()),
);
let app = codex_proxy_rs::api::router::router().with_state(AppState::from(&services));
(
+3 -3
View File
@@ -12,12 +12,12 @@ use tower::ServiceExt;
use crate::support::{
config::test_config as base_test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{
TestDatabaseGuard, background_task_stores, create_test_redis, init_test_db,
test_database_url,
},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -185,11 +185,11 @@ async fn router_with_dist_and_database(
let redis = create_test_redis(label).await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(codex_proxy_rs::bootstrap::services::Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
wire_profile(profile),
));
let state = codex_proxy_rs::api::AppState::from(services.as_ref());
(
+3 -7
View File
@@ -11,9 +11,9 @@ use tower::util::ServiceExt;
use crate::support::{
client_keys::insert_client_api_key,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -154,12 +154,8 @@ async fn build_test_app(
let redis = create_test_redis(label).await;
seed_model_snapshot(&redis).await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
services
.initialize_hot_path_state()
.await
+3 -7
View File
@@ -8,9 +8,9 @@ use tower::util::ServiceExt;
use crate::support::{
client_keys::insert_client_api_key,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
mod websocket;
@@ -165,12 +165,8 @@ pub(super) async fn test_app_with_client_api_key() -> (
insert_model_snapshot(&redis).await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
services
.initialize_hot_path_state()
.await
+20 -11
View File
@@ -22,8 +22,9 @@ fn config_loader_should_load_complete_example() {
config.server.host.as_str(),
config.server.port,
config.api.base_url.as_str(),
config.fingerprint.app_version.as_str(),
config.fingerprint.build_number.as_str(),
config.wire_profile.codex_version.as_str(),
config.wire_profile.desktop_version.as_str(),
config.wire_profile.desktop_build.as_str(),
config.quota.refresh_interval_minutes,
config.ws_pool.initial_event_timeout_ms,
config.admin.default_username.as_str(),
@@ -33,8 +34,9 @@ fn config_loader_should_load_complete_example() {
"127.0.0.1",
8080,
"https://chatgpt.com/backend-api",
"26.707.51957",
"5175",
"0.144.2",
"26.707.72221",
"5307",
5,
20_000,
"admin@cpr.local",
@@ -158,7 +160,7 @@ fn config_loader_should_reject_missing_explicit_fields() {
#[test]
fn config_loader_should_reject_unsupported_schema_version() {
let yaml = complete_config().replace("schema_version: 1", "schema_version: 2");
let yaml = complete_config().replace("schema_version: 2", "schema_version: 1");
let directory = write_config(&yaml);
let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err();
@@ -223,18 +225,25 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
}
#[test]
fn config_loader_should_reject_duplicate_fingerprint_headers() {
let yaml = complete_config().replace(
" - name: 'Accept-Language'",
" - name: 'Accept-Encoding'",
);
fn config_loader_should_reject_legacy_fingerprint_section() {
let yaml = complete_config().replace(" wire_profile:", " fingerprint:");
let directory = write_config(&yaml);
let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err();
assert!(matches!(error, ConfigError::InvalidDocument { .. }));
}
#[test]
fn config_loader_should_reject_invalid_codex_core_version() {
let yaml = complete_config().replace("codex_version: '0.144.2'", "codex_version: 'Desktop'");
let directory = write_config(&yaml);
let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err();
assert_eq!(
error,
ConfigError::InvalidField("fingerprint.default_headers")
ConfigError::InvalidField("wire_profile.codex_version")
);
}
+3 -3
View File
@@ -21,7 +21,7 @@ async fn services_try_new_should_use_configured_tls_transport_builder() {
let Err(error) = Services::try_new(
&config,
stores,
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
) else {
panic!("invalid custom CA should fail service transport construction");
};
@@ -59,7 +59,7 @@ async fn services_try_new_should_expose_websocket_pool_when_enabled() {
let services = Services::try_new(
&config,
background_task_stores(pool, redis),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.expect("services should build");
@@ -75,7 +75,7 @@ async fn services_try_new_should_not_expose_websocket_pool_when_disabled() {
let services = Services::try_new(
&config,
background_task_stores(pool, redis),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.expect("services should build");
@@ -0,0 +1,86 @@
use super::*;
#[tokio::test]
async fn desktop_release_update_task_should_start_background_checker() {
let status = DesktopReleaseStatus::default();
let handle =
codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task(
status,
"https://example.invalid/appcast.xml".to_string(),
);
handle.shutdown().await;
}
#[tokio::test]
async fn desktop_release_update_task_should_record_initial_appcast_observation() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<item>
<pubDate>Tue, 14 Jul 2026 07:56:40 +0000</pubDate>
<sparkle:shortVersionString>26.900.1</sparkle:shortVersionString>
<sparkle:version>7001</sparkle:version>
<sparkle:minimumSystemVersion>12.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<enclosure url="https://example.invalid/download" length="1234"
sparkle:edSignature="signature" />
</item>
</channel>
</rss>
"#,
)
.await;
let status = DesktopReleaseStatus::default();
let handle =
codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task(
status.clone(),
format!("{}/appcast.xml", server.uri()),
);
let snapshot = wait_for_latest_desktop_release(&status, "26.900.1").await;
handle.shutdown().await;
let release = snapshot.latest.expect("latest release");
assert_eq!(release.build, "7001");
assert_eq!(release.minimum_system_version.as_deref(), Some("12.0"));
assert_eq!(release.hardware_requirements.as_deref(), Some("arm64"));
assert_eq!(release.download_size, Some(1234));
assert!(release.signature_present);
assert!(snapshot.checked_at.is_some());
assert!(snapshot.last_error.is_none());
}
#[tokio::test]
async fn desktop_release_update_task_should_record_fetch_failure_without_mutating_wire_profile() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/appcast.xml"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let profile = crate::support::wire_profile::test_wire_profile();
let original_user_agent = profile.user_agent();
let status = DesktopReleaseStatus::default();
let handle =
codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task(
status.clone(),
format!("{}/appcast.xml", server.uri()),
);
wait_for_appcast_requests(&server, 1).await;
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2);
while status.snapshot().last_error.is_none() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(StdDuration::from_millis(25)).await;
}
handle.shutdown().await;
assert_eq!(profile.user_agent(), original_user_agent);
assert!(status.snapshot().latest.is_none());
}
@@ -1,131 +0,0 @@
use super::*;
#[tokio::test]
async fn fingerprint_update_task_should_start_background_checker() {
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-task-start").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await;
let handle =
codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task(
repo,
runtime_fingerprint,
"https://example.invalid/appcast.xml".to_string(),
dir.path().join("extracted-fingerprint.json"),
"26.800.1".to_string(),
"6001".to_string(),
);
handle.shutdown().await;
}
#[tokio::test]
async fn fingerprint_update_task_should_apply_initial_appcast_update_to_store() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-task-update").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await;
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("extracted fingerprint should be written");
let handle =
codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task(
repo.clone(),
runtime_fingerprint.clone(),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1".to_string(),
"6001".to_string(),
);
let stored = wait_for_current_fingerprint_version(&repo).await;
handle.shutdown().await;
assert_eq!(
(
stored.app_version.as_str(),
stored.build_number.as_str(),
stored.chromium_version.as_str()
),
("26.900.1", "7001", "147")
);
let runtime_snapshot = runtime_fingerprint.snapshot();
assert_eq!(
(
runtime_snapshot.app_version.as_str(),
runtime_snapshot.build_number.as_str(),
runtime_snapshot.chromium_version.as_str()
),
("26.900.1", "7001", "147")
);
}
#[tokio::test]
async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-task-no-update").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await;
let handle =
codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task(
repo.clone(),
runtime_fingerprint,
format!("{}/appcast.xml", server.uri()),
dir.path().join("missing-extracted-fingerprint.json"),
"26.900.1".to_string(),
"7001".to_string(),
);
wait_for_appcast_requests(&server, 1).await;
handle.shutdown().await;
let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history")
.fetch_one(&pool)
.await
.expect("fingerprint history count should load");
assert_eq!(history_count, 0);
}
+16 -22
View File
@@ -11,7 +11,9 @@ use codex_proxy_rs::telemetry::account_usage::store::{
AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, AccountUsageStoreError,
AccountUsageWindow,
};
use codex_proxy_rs::upstream::openai::fingerprint::{PgFingerprintStore, RuntimeFingerprint};
use codex_proxy_rs::upstream::openai::desktop_release::{
DesktopReleaseSnapshot, DesktopReleaseStatus,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
@@ -21,7 +23,7 @@ use crate::support::storage::init_test_db;
mod cleanup;
mod coordinator;
mod fingerprint_update;
mod desktop_release_update;
mod model_refresh;
mod retention_trim;
@@ -124,37 +126,29 @@ async fn mount_appcast(server: &MockServer, body: &str) {
.await;
}
async fn wait_for_current_fingerprint_version(
repo: &PgFingerprintStore,
) -> codex_proxy_rs::upstream::openai::fingerprint::Fingerprint {
async fn wait_for_latest_desktop_release(
status: &DesktopReleaseStatus,
expected_version: &str,
) -> DesktopReleaseSnapshot {
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2);
loop {
if let Some(stored) = repo
.load_current()
.await
.expect("current fingerprint should load")
.filter(|stored| stored.app_version == "26.900.1")
let snapshot = status.snapshot();
if snapshot
.latest
.as_ref()
.is_some_and(|release| release.version == expected_version)
{
return stored;
return snapshot;
}
assert!(
tokio::time::Instant::now() < deadline,
"fingerprint update task did not persist auto update before timeout"
"Desktop release task did not refresh appcast state before timeout"
);
tokio::time::sleep(StdDuration::from_millis(25)).await;
}
}
async fn runtime_fingerprint_from_repo(repo: &PgFingerprintStore) -> RuntimeFingerprint {
RuntimeFingerprint::new(
repo.load_current()
.await
.expect("current fingerprint should load")
.expect("current fingerprint should exist"),
)
}
async fn wait_for_appcast_requests(server: &MockServer, expected_count: usize) {
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2);
loop {
@@ -171,7 +165,7 @@ async fn wait_for_appcast_requests(server: &MockServer, expected_count: usize) {
assert!(
tokio::time::Instant::now() < deadline,
"fingerprint update task did not request appcast before timeout"
"Desktop release task did not request appcast before timeout"
);
tokio::time::sleep(StdDuration::from_millis(25)).await;
}
@@ -6,7 +6,6 @@ use codex_proxy_rs::{
ops::{store::PgOpsErrorLogStore, types::OpsErrorLog},
usage::{store::PgUsageRecordStore, types::UsageRecord},
},
upstream::openai::fingerprint::PgFingerprintStore,
};
use crate::support::storage::init_test_db;
@@ -28,11 +27,6 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() {
let usage_store = PgUsageRecordStore::new(pool.clone());
let ops_store = PgOpsErrorLogStore::new(pool.clone());
let bucket_store = PgRequestBucketStore::new(pool.clone());
let fingerprint_store = PgFingerprintStore::new(pool.clone());
fingerprint_store
.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.unwrap();
let now = Utc::now();
let mut old_usage = UsageRecord::new("request", "old", "acct", "gpt-old", 200);
@@ -55,19 +49,7 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() {
current_error.created_at = now;
ops_store.append(&current_error).await.unwrap();
sqlx::query(
"insert into fingerprint_update_history (
id, current_fingerprint_id, app_version, build_number, source, created_at
)
select 'history_' || value, 'current', '1.0.' || value, value::text, 'test',
now() + value * interval '1 microsecond'
from generate_series(1, 101) as value",
)
.execute(&pool)
.await
.unwrap();
RetentionTrimTask::new(usage_store, ops_store, bucket_store, fingerprint_store)
RetentionTrimTask::new(usage_store, ops_store, bucket_store)
.run_once()
.await;
@@ -85,13 +67,7 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() {
.fetch_one(&pool)
.await
.unwrap();
let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage_ids, vec!["usage_current".to_string()]);
assert_eq!(ops_ids, vec!["ops_current".to_string()]);
assert_eq!(old_buckets, 0);
assert_eq!(history_count, 100);
}
+1 -1
View File
@@ -243,7 +243,7 @@ async fn test_app_state_with_storage(
let services = Services::try_with_usage_record_options(
&config,
stores,
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
usage_record_options,
)
.expect("failed to build runtime services with configured TLS transport");
+8 -8
View File
@@ -327,7 +327,7 @@ async fn quota_refresh_service_should_send_usage_cookie_when_cookie_store_is_con
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store,
@@ -410,7 +410,7 @@ async fn quota_refresh_service_should_fetch_usage_for_quota_locked_accounts_and_
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store.clone(),
@@ -468,7 +468,7 @@ async fn quota_refresh_service_should_ban_deactivated_workspace() {
let codex = Arc::new(CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
));
let service = QuotaRefreshService::new(
store,
@@ -530,7 +530,7 @@ async fn quota_refresh_service_should_refresh_quota_exhausted_accounts() {
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store,
@@ -567,7 +567,7 @@ async fn quota_refresh_service_should_skip_recent_locked_account_before_cooldown
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::with_min_refresh_interval_secs(
store,
@@ -608,7 +608,7 @@ async fn quota_refresh_service_should_skip_recent_locked_account_inside_cooldown
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::with_min_refresh_interval_secs(
store,
@@ -666,7 +666,7 @@ async fn quota_refresh_service_should_bypass_recent_skip_after_cooldown_grace()
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::with_min_refresh_interval_secs(
store,
@@ -722,7 +722,7 @@ async fn quota_refresh_service_should_stagger_multiple_locked_account_requests()
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store,
-2
View File
@@ -25,8 +25,6 @@ async fn postgres_schema_is_terminal_v1_without_unused_storage() {
"accounts",
"admin_users",
"client_api_keys",
"fingerprint_update_history",
"fingerprints",
"ops_error_logs",
"request_time_buckets",
"runtime_settings",
+1 -1
View File
@@ -42,7 +42,7 @@ pub(crate) fn test_config(database_url: String) -> AppConfig {
max_per_account: 8,
initial_event_timeout_ms: 20_000,
},
fingerprint: crate::support::fingerprint::test_fingerprint_config(),
wire_profile: crate::support::wire_profile::test_wire_profile_config(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
-75
View File
@@ -1,75 +0,0 @@
use codex_proxy_rs::{
bootstrap::{
config::{FingerprintConfig, FingerprintHeaderConfig},
services::fingerprint_from_config,
},
upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint},
};
pub(crate) fn test_fingerprint_config() -> FingerprintConfig {
let header = |name: &str, value: &str| FingerprintHeaderConfig {
name: name.to_string(),
value: value.to_string(),
};
FingerprintConfig {
originator: "Codex Desktop".to_string(),
app_version: "26.707.51957".to_string(),
build_number: "5175".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![
header("Accept-Encoding", "gzip, deflate, br, zstd"),
header("Accept-Language", "en-US,en;q=0.9"),
header("sec-ch-ua-mobile", "?0"),
header("sec-ch-ua-platform", "\"macOS\""),
header("sec-fetch-site", "same-origin"),
header("sec-fetch-mode", "cors"),
header("sec-fetch-dest", "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",
]
.map(str::to_string)
.to_vec(),
}
}
pub(crate) fn test_fingerprint() -> Fingerprint {
fingerprint_from_config(&test_fingerprint_config())
}
pub(crate) fn test_fingerprint_with_updated_at(updated_at: Option<&str>) -> Fingerprint {
let mut fingerprint = test_fingerprint();
fingerprint.updated_at = updated_at.map(ToString::to_string);
fingerprint
}
pub(crate) fn runtime_test_fingerprint() -> RuntimeFingerprint {
RuntimeFingerprint::new(test_fingerprint())
}
pub(crate) fn runtime_fingerprint(fingerprint: Fingerprint) -> RuntimeFingerprint {
RuntimeFingerprint::new(fingerprint)
}
+1 -1
View File
@@ -3,7 +3,7 @@ pub(crate) mod admin;
pub(crate) mod assertions;
pub(crate) mod client_keys;
pub(crate) mod config;
pub(crate) mod fingerprint;
pub(crate) mod http;
pub(crate) mod jwt;
pub(crate) mod storage;
pub(crate) mod wire_profile;
-2
View File
@@ -13,7 +13,6 @@ use codex_proxy_rs::{
account_usage::store::PgAccountUsageStore, buckets::store::PgRequestBucketStore,
ops::store::PgOpsErrorLogStore, usage::store::PgUsageRecordStore,
},
upstream::openai::fingerprint::PgFingerprintStore,
};
use sqlx::{Connection, PgConnection, PgPool, postgres::PgPoolOptions};
use uuid::Uuid;
@@ -174,7 +173,6 @@ pub(crate) fn background_task_stores(pool: PgPool, redis: RedisConnection) -> Ba
admin_users: PgAdminUserStore::new(pool.clone()),
admin_sessions: RedisAdminSessionStore::new(redis.clone()),
cookies: PgCookieStore::new(pool.clone()),
fingerprints: PgFingerprintStore::new(pool.clone()),
session_affinity: RedisSessionAffinityStore::new(redis.clone()),
refresh_leases: RedisRefreshLeaseStore::new(redis.clone()),
model_snapshots: RedisModelSnapshotStore::new(redis),
+34
View File
@@ -0,0 +1,34 @@
use std::sync::Arc;
use codex_proxy_rs::{
bootstrap::{config::WireProfileConfig, services::wire_profile_from_config},
upstream::openai::profile::CodexWireProfile,
};
use crate::support::storage::timestamp;
pub(crate) fn test_wire_profile_config() -> WireProfileConfig {
WireProfileConfig {
originator: "Codex Desktop".to_string(),
codex_version: "0.144.2".to_string(),
desktop_version: "26.707.72221".to_string(),
desktop_build: "5307".to_string(),
os_type: "Mac OS".to_string(),
os_version: "15.7.1".to_string(),
arch: "arm64".to_string(),
terminal: "unknown".to_string(),
verified_at: timestamp("2026-07-14T18:25:50Z"),
}
}
pub(crate) fn test_wire_profile_value() -> CodexWireProfile {
wire_profile_from_config(&test_wire_profile_config())
}
pub(crate) fn test_wire_profile() -> Arc<CodexWireProfile> {
Arc::new(test_wire_profile_value())
}
pub(crate) fn wire_profile(profile: CodexWireProfile) -> Arc<CodexWireProfile> {
Arc::new(profile)
}
@@ -0,0 +1,94 @@
use codex_proxy_rs::upstream::openai::desktop_release::{
APPCAST_POLL_INTERVAL, DesktopReleaseChecker, DesktopReleaseStatus,
parse_latest_desktop_release,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
const APPCAST: &str = r#"
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<item>
<title>26.707.72221</title>
<pubDate>Tue, 14 Jul 2026 07:56:40 +0000</pubDate>
<sparkle:version>5307</sparkle:version>
<sparkle:shortVersionString>26.707.72221</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>12.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<enclosure url="https://example.invalid/Codex.zip" length="565564803"
sparkle:edSignature="signature" />
<sparkle:deltas>
<enclosure url="https://example.invalid/Codex.delta" length="12" />
</sparkle:deltas>
</item>
<item>
<sparkle:version>5263</sparkle:version>
<sparkle:shortVersionString>26.707.71524</sparkle:shortVersionString>
</item>
</channel>
</rss>
"#;
#[test]
fn desktop_release_poll_interval_should_be_one_day() {
assert_eq!(APPCAST_POLL_INTERVAL, std::time::Duration::from_hours(24));
}
#[test]
fn appcast_parser_should_return_first_full_release_and_ignore_deltas() {
let release = parse_latest_desktop_release(APPCAST).expect("parse appcast");
assert_eq!(release.version, "26.707.72221");
assert_eq!(release.build, "5307");
assert_eq!(
release.published_at,
Some(crate::support::storage::timestamp("2026-07-14T07:56:40Z"))
);
assert_eq!(release.minimum_system_version.as_deref(), Some("12.0"));
assert_eq!(release.hardware_requirements.as_deref(), Some("arm64"));
assert_eq!(
release.download_url.as_deref(),
Some("https://example.invalid/Codex.zip")
);
assert_eq!(release.download_size, Some(565_564_803));
assert!(release.signature_present);
}
#[test]
fn appcast_parser_should_reject_release_without_codex_desktop_version() {
let error = parse_latest_desktop_release("<rss><channel><item /></channel></rss>")
.expect_err("missing version should fail");
assert!(error.to_string().contains("shortVersionString"));
}
#[tokio::test]
async fn desktop_release_checker_should_update_observation_only() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/appcast.xml"))
.respond_with(ResponseTemplate::new(200).set_body_raw(APPCAST, "application/xml"))
.mount(&server)
.await;
let status = DesktopReleaseStatus::default();
let checker = DesktopReleaseChecker::with_client(
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
status.clone(),
);
checker.check_and_record().await.expect("check appcast");
let snapshot = status.snapshot();
assert_eq!(
snapshot
.latest
.as_ref()
.map(|release| release.version.as_str()),
Some("26.707.72221")
);
assert!(snapshot.checked_at.is_some());
assert!(snapshot.last_error.is_none());
}
@@ -1,190 +0,0 @@
use std::{num::NonZeroU32, time::Duration};
use codex_proxy_rs::upstream::openai::fingerprint::{
APPCAST_POLL_INTERVAL, PgFingerprintStore, UpdateChecker,
};
use crate::support::storage::init_test_db;
#[test]
fn fingerprint_appcast_poll_interval_should_be_one_day() {
assert_eq!(APPCAST_POLL_INTERVAL, Duration::from_hours(24));
}
#[tokio::test]
async fn fingerprint_store_should_update_current_record() {
let (pool, _guard) = init_test_db("fingerprint-store").await;
let repo = PgFingerprintStore::new(pool.clone());
let default_fingerprint = crate::support::fingerprint::test_fingerprint();
repo.ensure_current_seed(&default_fingerprint)
.await
.expect("seed current fingerprint");
repo.update_current_version("26.800.1", "6001", Some("147"))
.await
.expect("first update");
repo.update_current_version("26.800.2", "6002", None)
.await
.expect("second update");
let stored = repo
.load_current()
.await
.expect("load current")
.expect("stored fingerprint");
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");
assert_eq!(stored.build_number, "6002");
assert_eq!(stored.chromium_version, "147");
}
#[tokio::test]
async fn fingerprint_store_should_keep_latest_update_history_rows() {
let (pool, _guard) = init_test_db("fingerprint-history-limit").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
sqlx::query(
"insert into fingerprint_update_history (
id, current_fingerprint_id, app_version, build_number, source, created_at
)
select
'history-' || lpad(sequence::text, 3, '0'),
'current',
'26.900.' || sequence::text,
sequence::text,
'auto_update',
$1 + sequence * interval '1 second'
from generate_series(0, 101) as sequence",
)
.bind(chrono::Utc::now())
.execute(&pool)
.await
.expect("insert fingerprint history rows");
let deleted = repo
.trim_update_history_to_limit(NonZeroU32::new(100).unwrap())
.await
.expect("trim fingerprint history rows");
let (remaining, oldest_remaining): (i64, i64) = sqlx::query_as(
"select
count(*),
count(*) filter (where id in ('history-000', 'history-001'))
from fingerprint_update_history",
)
.fetch_one(&pool)
.await
.expect("count retained fingerprint history rows");
assert_eq!((deleted, remaining, oldest_remaining), (2, 100, 0));
}
#[tokio::test]
async fn update_checker_should_report_available_update_from_appcast() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/appcast.xml"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
"application/xml",
))
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-update-check").await;
let repo = PgFingerprintStore::new(pool);
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let checker = UpdateChecker::with_client(
repo,
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
dir.path().join("extracted-fingerprint.json"),
"26.800.1",
"6001",
);
let state = checker.check_for_update().await.expect("update state");
assert!(state.update_available);
assert_eq!(state.latest_version.as_deref(), Some("26.900.1"));
assert_eq!(state.latest_build.as_deref(), Some("7001"));
}
#[tokio::test]
async fn update_checker_should_apply_available_update_to_store() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/appcast.xml"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
"application/xml",
))
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-update-apply").await;
let repo = PgFingerprintStore::new(pool);
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("write extracted fingerprint");
let checker = UpdateChecker::with_client(
repo.clone(),
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1",
"6001",
);
let updated = checker
.check_and_apply_update()
.await
.expect("apply update")
.expect("available update should return current fingerprint");
let stored = repo
.load_current()
.await
.expect("load current")
.expect("stored fingerprint");
assert_eq!(updated.app_version, "26.900.1");
assert_eq!(updated.build_number, "7001");
assert_eq!(updated.chromium_version, "147");
assert_eq!(stored.app_version, "26.900.1");
assert_eq!(stored.build_number, "7001");
assert_eq!(stored.chromium_version, "147");
}
+2 -1
View File
@@ -1,4 +1,5 @@
pub(crate) mod fingerprint;
mod desktop_release;
mod profile;
pub(crate) mod protocol;
pub(crate) mod token_client;
pub(crate) mod transport;
+9
View File
@@ -0,0 +1,9 @@
#[test]
fn wire_profile_should_generate_codex_core_user_agent() {
let profile = crate::support::wire_profile::test_wire_profile_value();
assert_eq!(
profile.user_agent(),
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
}
@@ -59,7 +59,7 @@ async fn codex_backend_client_should_apply_configured_websocket_pool() {
let backend = CodexBackendClient::new(
reqwest::Client::new(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(pool));
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", Vec::new());
@@ -1,28 +1,46 @@
use super::*;
use codex_proxy_rs::upstream::openai::fingerprint::RuntimeFingerprint;
use codex_proxy_rs::upstream::openai::transport::build_ordered_codex_headers;
use codex_proxy_rs::upstream::openai::transport::build_codex_headers;
use codex_proxy_rs::upstream::openai::transport::websocket::CodexWebSocketConnection;
use serde_json::Value;
#[test]
fn ordered_codex_headers_should_preserve_fingerprint_priority_and_request_fields() {
let fingerprint = crate::support::fingerprint::test_fingerprint();
fn codex_headers_should_use_wire_profile_without_browser_metadata() {
let profile = crate::support::wire_profile::test_wire_profile_value();
let headers = build_ordered_codex_headers(
&fingerprint,
let headers = build_codex_headers(
&profile,
"access-token",
Some("acct-1"),
Some("turn-1"),
"req-1",
);
let keys = headers.keys().cloned().collect::<Vec<_>>();
)
.expect("valid profile headers");
assert_eq!(headers["authorization"], "Bearer access-token");
assert_eq!(headers["chatgpt-account-id"], "acct-1");
assert_eq!(headers["x-client-request-id"], "req-1");
assert_eq!(headers["x-codex-turn-state"], "turn-1");
assert_eq!(headers["accept"], "text/event-stream");
assert_eq!(keys.first().map(String::as_str), Some("authorization"));
let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
assert_eq!(value("authorization"), Some("Bearer access-token"));
assert_eq!(value("chatgpt-account-id"), Some("acct-1"));
assert_eq!(value("originator"), Some("Codex Desktop"));
assert_eq!(
value("user-agent"),
Some("Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)")
);
assert_eq!(value("x-client-request-id"), Some("req-1"));
assert_eq!(value("x-codex-turn-state"), Some("turn-1"));
assert_eq!(value("accept"), Some("text/event-stream"));
for browser_header in [
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"accept-language",
] {
assert!(
headers.get(browser_header).is_none(),
"sent {browser_header}"
);
}
}
#[test]
@@ -282,7 +300,7 @@ async fn codex_backend_client_websocket_should_forward_headers_and_preserve_payl
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
@@ -433,7 +451,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -490,7 +508,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
}
#[tokio::test]
async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requests() {
async fn codex_backend_client_should_send_effective_wire_profile_user_agent() {
let server = wiremock::MockServer::start().await;
let sse_body = include_str!("../../../fixtures/responses/http_sse/completed_usage_basic.sse");
wiremock::Mock::given(wiremock::matchers::method("POST"))
@@ -503,13 +521,12 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ
.mount(&server)
.await;
let mut initial = crate::support::fingerprint::test_fingerprint();
initial.app_version = "26.800.1".to_string();
let runtime_fingerprint = RuntimeFingerprint::new(initial);
let profile = crate::support::wire_profile::test_wire_profile();
let expected_user_agent = profile.user_agent();
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
runtime_fingerprint.clone(),
profile,
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -538,19 +555,9 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ
};
client
.create_response(&request, request_context("req_fingerprint_1"))
.create_response(&request, request_context("req_profile"))
.await
.expect("first response should succeed");
let mut updated = runtime_fingerprint.snapshot();
updated.app_version = "26.900.1".to_string();
updated.build_number = "7001".to_string();
runtime_fingerprint.replace(updated);
client
.create_response(&request, request_context("req_fingerprint_2"))
.await
.expect("second response should succeed");
.expect("response should succeed");
let user_agents = server
.received_requests()
@@ -568,13 +575,7 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ
})
.collect::<Vec<_>>();
assert_eq!(
user_agents,
vec![
"Codex Desktop/26.800.1 (darwin; arm64)".to_string(),
"Codex Desktop/26.900.1 (darwin; arm64)".to_string(),
]
);
assert_eq!(user_agents, vec![expected_user_agent]);
}
#[tokio::test]
@@ -592,7 +593,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() {
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let usage = client
@@ -636,7 +637,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() {
headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some("Codex Desktop/26.707.51957 (darwin; arm64)")
Some("Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)")
);
assert_eq!(
headers.get("accept").and_then(|value| value.to_str().ok()),
@@ -679,12 +680,12 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() {
})))
.mount(&server)
.await;
let fingerprint = crate::support::fingerprint::runtime_test_fingerprint();
let client_version = fingerprint.current().app_version.clone();
let profile = crate::support::wire_profile::test_wire_profile();
let client_version = profile.codex_version.clone();
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
fingerprint,
profile,
);
let models = client
@@ -732,7 +733,7 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() {
}
#[tokio::test]
async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order() {
async fn codex_backend_client_should_send_codex_context_without_browser_headers() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
@@ -758,7 +759,7 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order(
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
client
@@ -788,36 +789,38 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order(
let raw_request = server.await.unwrap();
let header_names = read_header_names(&raw_request);
assert_header_subsequence(
&header_names,
&[
"authorization",
"chatgpt-account-id",
"originator",
"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",
"cookie",
"accept",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"session-id",
"x-codex-window-id",
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-codex-beta-features",
"x-responsesapi-include-timing-metrics",
"version",
"x-codex-parent-thread-id",
],
);
for required_header in [
"authorization",
"chatgpt-account-id",
"originator",
"user-agent",
"content-type",
"cookie",
"accept",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"session-id",
"x-codex-window-id",
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-codex-beta-features",
"x-responsesapi-include-timing-metrics",
"version",
"x-codex-parent-thread-id",
] {
assert!(header_names.iter().any(|name| name == required_header));
}
for browser_header in [
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
] {
assert!(!header_names.iter().any(|name| name == browser_header));
}
}
@@ -126,7 +126,7 @@ async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() {
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -183,7 +183,7 @@ async fn codex_backend_client_should_parse_retry_after_from_rate_limit_error_bod
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -247,7 +247,7 @@ async fn codex_backend_http_sse_should_fail_after_five_minutes_without_stream_da
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
base_url,
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -298,7 +298,7 @@ async fn codex_backend_client_should_capture_only_safe_response_metadata() {
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -36,7 +36,7 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect(
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -91,7 +91,7 @@ async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_requ
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -146,7 +146,7 @@ async fn shared_websocket_opening_should_keep_the_original_fast_path_deadline()
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(200));
@@ -215,7 +215,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -280,7 +280,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -323,7 +323,7 @@ async fn store_false_warmup_should_never_fall_back_to_http() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let mut body = serde_json::Map::new();
@@ -380,7 +380,7 @@ async fn websocket_opening_upstream_error_should_not_fall_back_to_http() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
@@ -438,7 +438,7 @@ async fn connection_local_continuation_should_use_the_exact_socket() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let first_request = new_chain_request("conversation-exact");
@@ -478,7 +478,7 @@ async fn missing_exact_socket_should_fail_without_opening_a_connection() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let mut request = new_chain_request("conversation-exact-missing");
@@ -532,7 +532,7 @@ async fn concurrent_same_key_should_singleflight_websocket_opening() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()))
.with_websocket_fast_path_budget(Duration::from_millis(200));
@@ -584,7 +584,7 @@ async fn payload_send_failure_should_not_open_http_fallback() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
@@ -731,7 +731,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30))
@@ -812,7 +812,7 @@ async fn half_open_upstream_response_should_close_origin_breaker() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()))
.with_websocket_fast_path_budget(Duration::from_millis(100))
@@ -113,19 +113,6 @@ pub(crate) fn read_header_names(request: &str) -> Vec<String> {
.collect()
}
pub(crate) fn assert_header_subsequence(actual: &[String], expected: &[&str]) {
let mut offset = 0;
for expected_name in expected {
let Some(position) = actual[offset..]
.iter()
.position(|actual_name| actual_name == expected_name)
else {
panic!("missing header {expected_name}; actual order: {actual:?}");
};
offset += position + 1;
}
}
pub(crate) async fn read_http_request(stream: &mut TcpStream) -> String {
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
@@ -130,7 +130,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let mut request =
@@ -728,7 +728,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-rate-limit-reuse");
@@ -986,7 +986,7 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_initial_event_timeout(Some(Duration::from_millis(30)));
let mut request =
@@ -1154,7 +1154,7 @@ async fn codex_backend_client_stream_should_reject_binary_websocket_event() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1228,7 +1228,7 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_initial_event_timeout(Some(Duration::from_millis(30)));
let request = pooled_websocket_request("conversation-structural-no-pool");
@@ -1289,7 +1289,7 @@ async fn codex_backend_client_stream_should_error_when_websocket_closes_before_t
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1386,7 +1386,7 @@ async fn codex_backend_client_stream_should_preserve_burst_during_downstream_bac
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-backpressure");
@@ -1476,7 +1476,7 @@ async fn codex_backend_client_stream_should_cancel_while_inbound_is_backpressure
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let request = pooled_websocket_request("conversation-backpressure-cancel");
@@ -1536,7 +1536,7 @@ async fn codex_backend_client_stream_should_wait_for_terminal_after_active_webso
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1603,7 +1603,7 @@ async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1700,7 +1700,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
@@ -39,7 +39,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -121,7 +121,7 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -215,7 +215,7 @@ async fn codex_backend_client_stream_should_keep_fresh_socket_after_structural_a
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-structural-fresh");
@@ -304,7 +304,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-structural-reuse");
@@ -362,7 +362,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-structural-bypass");
@@ -417,7 +417,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -496,7 +496,7 @@ async fn websocket_pool_should_use_http_while_exact_key_is_busy() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-busy");
@@ -589,7 +589,7 @@ async fn websocket_pool_should_release_slot_when_client_drops_stream() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-drop");
@@ -669,7 +669,7 @@ async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let first_request = pooled_websocket_request("conversation-cap-one");
@@ -782,7 +782,7 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-no-pong");
@@ -871,7 +871,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-gc");
@@ -990,7 +990,7 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1069,7 +1069,7 @@ async fn codex_backend_client_should_treat_active_business_frames_as_ping_livene
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-active-without-pong");
@@ -1158,7 +1158,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_when_account_is
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1243,7 +1243,7 @@ async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdo
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1357,7 +1357,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1456,7 +1456,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let mut request =
@@ -1538,7 +1538,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-pool-unknown-failed");
+12 -46
View File
@@ -8,7 +8,7 @@
# 三个密码建议分别执行 `openssl rand -hex 24` 生成。
x-cpr:
schema_version: 1
schema_version: 2
server:
# 本地 cargo run 使用;Docker 内由 compose.yaml 固定覆盖为 0.0.0.0:8080。
@@ -47,53 +47,19 @@ x-cpr:
# 首个真实输出的绝对超时,单位毫秒;0 表示禁用。
initial_event_timeout_ms: 20000
fingerprint:
# PostgreSQL 中不存在当前指纹时,使用这里的数据初始化。
wire_profile:
# 该画像来自 Desktop 制品与 bundled Codex Core 的联合审计。
# appcast 只报告新 Desktop 制品,不会自动改写这里的请求身份。
originator: 'Codex Desktop'
app_version: '26.707.51957'
build_number: '5175'
platform: 'darwin'
codex_version: '0.144.2'
desktop_version: '26.707.72221'
desktop_build: '5307'
# 固定参考主机画像;必须在重新抓包核验后再修改。
os_type: 'Mac OS'
os_version: '15.7.1'
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
terminal: 'unknown'
verified_at: '2026-07-15T02:25:50+08:00'
admin:
session_ttl_minutes: 1440
+7 -6
View File
@@ -18,7 +18,7 @@ Codex Proxy RS 是一个单进程 Rust 网关,同时提供:
| 依赖 | 责任 |
| --- | --- |
| PostgreSQL | 账号、Key、设置、Cookie、指纹和遥测事实的权威存储 |
| PostgreSQL | 账号、Key、设置、Cookie 和遥测事实的权威存储 |
| Redis | 管理会话、刷新租约、模型快照、响应归属和会话级短期状态 |
| ChatGPT Codex | Responses、模型目录、配额和 OAuth token 刷新 |
| `.runtime/` | 身份派生密钥、更新状态、日志及本地 PostgreSQL/Redis 数据 |
@@ -105,6 +105,8 @@ backend/src/
upstream/openai/
├── token_client.rs
├── failure.rs
├── profile.rs
├── desktop_release.rs
├── protocol/
│ ├── responses.rs
│ ├── events.rs
@@ -124,13 +126,13 @@ upstream/openai/
│ ├── websocket_pool.rs
│ ├── websocket_breaker.rs
│ └── websocket_pump.rs
└── fingerprint/
```
- `protocol` 定义 Responses body、canonical SSE/WS 事实和序列化,不包含账号策略。
- `failure.rs` 将各 transport 错误规范化为稳定的 `UpstreamFailureFacts`,不解释账号状态。
- `transport` 拥有 HTTP/SSE、WebSocket、TLS、header、连接池、熔断和 transport metrics。
- `fingerprint` 拥有 Codex Desktop 指纹的存储、运行时快照和更新。
- `profile.rs` 定义配置驱动、启动后不可变的 Codex Desktop 上游请求画像。
- `desktop_release.rs` 只观测官方 appcast;发布变化不会自动改写生效请求画像。
- `token_client.rs` 只实现 OAuth token 刷新协议。
### fleet
@@ -409,7 +411,6 @@ PostgreSQL 保存:
| 失败请求事实 | `ops_error_logs` |
| 请求聚合桶 | `request_time_buckets` |
| Cloudflare Cookie | `account_cookies` |
| Codex 指纹 | `fingerprints`、`fingerprint_update_history` |
| 迁移记录 | `schema_migrations` |
时间列使用 `timestamptz`,应用按 UTC 时间点读写,中国自然日和展示时区由 `infra::time` 定义。迁移版本严格递增并校验名称与 SQL checksum。
@@ -459,7 +460,7 @@ Compose 将应用配置只读挂载到 `/app/deploy/config.yaml`。应用数据
6. 读取 `runtime_settings`。
7. 创建领域 store、service、OpenAI client 和账号池。
8. 读取或创建 `identity_hmac_secret`。
9. 初始化指纹、管理员和模型快照。
9. 构造上游请求画像并初始化管理员和模型快照。
10. 启动后台任务并挂载 HTTP router。
后台任务由 `TaskCoordinator` 管理:
@@ -467,7 +468,7 @@ Compose 将应用配置只读挂载到 `/app/deploy/config.yaml`。应用数据
- Cookie 清理和事实表 retention。
- 模型目录刷新。
- token 与 quota 刷新。
- Codex 指纹更新。
- Codex Desktop 官方发布观测。
- settings 订阅。
- WebSocket pool 生命周期。
@@ -1,82 +0,0 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
defineProps<{
items: any[]
}>()
const iconToneClasses: Record<string, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
info: 'bg-(--cp-info-bg) text-(--cp-info)',
success: 'bg-(--cp-success-bg) text-(--cp-success)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
}
const valueToneClasses: Record<string, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-info-text)',
success: 'text-(--cp-success-text)',
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
</script>
<template>
<BaseCard as="article" variant="dashboard" title="指纹信息" class="h-95 w-full">
<template #body>
<div class="mt-5.75 grid gap-2">
<BaseEmpty
v-if="items.length === 0"
compact
title="暂无指纹数据"
class="h-72 place-content-center"
/>
<template v-else>
<div
v-for="item in items"
:key="item.label"
class="grid h-12.5 w-full grid-cols-[22px_12px_minmax(128px,0.82fr)_minmax(0,1.08fr)_minmax(72px,0.92fr)] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span
class="inline-flex size-6 items-center justify-center rounded-lg"
:class="iconToneClasses[item.tone]"
>
<component :is="item.icon" :size="14" />
</span>
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
<strong
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ item.label }}</strong
>
<span
class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate"
>{{ item.value }}</span
>
</template>
<template v-else>
<strong
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ item.label }}</strong
>
<div
class="col-start-4 col-span-2 grid min-w-0 grid-cols-1 justify-items-end gap-1.5 sm:grid-cols-[minmax(0,1.08fr)_minmax(72px,0.92fr)] sm:items-center sm:justify-items-stretch sm:gap-0"
>
<span
class="max-w-full min-w-0 wrap-break-word text-right text-[13px] leading-[1.15] font-bold sm:text-left"
:class="valueToneClasses[item.tone]"
>{{ item.value }}</span
>
<span
class="text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
>{{ item.detail }}</span
>
</div>
</template>
</div>
</template>
</div>
</template>
</BaseCard>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { Binary, Box, CheckCircle2, Monitor, RefreshCw, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
profile: any
}>()
const releaseLabel = computed(() => {
const release = props.profile?.release
if (!release?.latestVersion) return '尚未检查'
return release.latestBuild
? `${release.latestVersion} · Build ${release.latestBuild}`
: release.latestVersion
})
const releaseStatus = computed(() => {
const status = props.profile?.release?.status ?? 'unchecked'
if (status === 'aligned') {
return {
label: '制品一致',
title: '当前 Desktop 版本与官方发布一致',
tone: 'bg-(--cp-success-bg) text-(--cp-success-text)',
icon: CheckCircle2,
}
}
if (status === 'review_required') {
return {
label: '发现新版',
title: `官方最新版本 ${releaseLabel.value}`,
tone: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
icon: TriangleAlert,
}
}
if (status === 'check_failed') {
return {
label: '检查失败',
title: props.profile?.release?.error || 'Desktop 发布检查失败',
tone: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
icon: TriangleAlert,
}
}
return {
label: '待检查',
title: '尚未检查 Desktop 官方发布',
tone: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
icon: RefreshCw,
}
})
const verifiedAt = computed(() =>
props.profile?.verifiedAt ? formatDateTime(props.profile.verifiedAt) : '—',
)
const checkedAt = computed(() =>
props.profile?.release?.checkedAt ? formatDateTime(props.profile.release.checkedAt) : '—',
)
</script>
<template>
<BaseCard as="article" variant="dashboard" title="上游请求身份" class="min-h-95 w-full">
<template #actions>
<span
class="inline-flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12px] leading-none font-[720]"
:class="releaseStatus.tone"
:title="releaseStatus.title"
>
<component :is="releaseStatus.icon" class="size-3.5" />
{{ releaseStatus.label }}
</span>
</template>
<template #body>
<BaseEmpty v-if="!profile" compact title="暂无请求身份" class="h-67 place-content-center" />
<div
v-else
class="mt-5 grid gap-3 max-[767px]:pl-5 sm:h-[287px] sm:grid-rows-[minmax(0,1fr)_24px]"
>
<section
aria-label="请求身份组成"
class="grid min-w-0 content-between gap-6 rounded-[14px] bg-(--cp-bg-subtle) px-5 py-5.5 sm:h-full sm:grid-rows-[auto_minmax(0,1fr)_auto] sm:px-6 sm:py-5"
>
<div class="flex min-w-0 items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-2 text-(--cp-success-text)">
<span
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-(--cp-success-bg)"
>
<Box class="size-3.75 text-(--cp-success)" />
</span>
<span class="truncate text-[11px] leading-none font-[760]">Codex Desktop</span>
</div>
<span
class="shrink-0 font-mono text-[10px] leading-none font-[650] tabular-nums text-(--cp-text-muted)"
>
Build {{ profile.desktopBuild }}
</span>
</div>
<div class="grid min-h-0 content-center">
<strong
class="block max-w-full wrap-break-word font-mono text-[27px] leading-[1.05] font-[790] tabular-nums text-(--cp-text-primary)"
:title="profile.desktopVersion"
>
{{ profile.desktopVersion }}
</strong>
</div>
<dl class="m-0 grid min-w-0 gap-5 sm:grid-cols-[0.72fr_1.28fr] sm:gap-7">
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-[720] text-(--cp-text-muted)"
>
<Binary class="size-3.25 text-(--cp-info)" />
Codex Core
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-[760] tabular-nums text-(--cp-text-primary)"
:title="profile.codexVersion"
>
{{ profile.codexVersion }}
</dd>
</div>
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-[720] text-(--cp-text-muted)"
>
<Monitor class="size-3.25 text-(--cp-normal)" />
模拟运行环境
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-[720] tabular-nums text-(--cp-text-primary)"
:title="`${profile.target.osType} ${profile.target.osVersion} · ${profile.target.arch}`"
>
{{ profile.target.osType }} {{ profile.target.osVersion }}
<span class="text-[11px] font-[650] text-(--cp-text-secondary)">
· {{ profile.target.arch }}
</span>
</dd>
</div>
</dl>
</section>
<footer
class="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] leading-none font-[650] text-(--cp-text-muted)"
>
<time :datetime="profile.verifiedAt">源码审计 {{ verifiedAt }}</time>
<time :datetime="profile.release?.checkedAt">发布检查 {{ checkedAt }}</time>
</footer>
</div>
</template>
</BaseCard>
</template>
@@ -2,16 +2,7 @@ import { useIntervalFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { onMounted, ref } from 'vue'
import {
Activity,
CloudCheck,
FileText,
Gauge,
MonitorCheck,
RefreshCw,
Timer,
Users,
} from '@lucide/vue'
import { Activity, FileText, Timer, Users } from '@lucide/vue'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { withMinimumDuration } from '@/utils/async'
@@ -26,7 +17,7 @@ export function useDashboard(): any {
const trendSummary = ref<any[]>([])
const healthTimeline = ref<any>(emptyDashboardSummary().healthTimeline)
const accountUsage = ref<any[]>([])
const serviceStatuses = ref<any[]>([])
const wireProfile = ref<any>(null)
const usageRecords = ref<any[]>([])
const poolSummary = ref<any>(null)
const capacityInfo = ref<any>(null)
@@ -109,7 +100,7 @@ export function useDashboard(): any {
metrics.value = metricCards(summary)
healthTimeline.value = summary.healthTimeline
accountUsage.value = summary.accountUsage.map(accountUsageItem)
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
wireProfile.value = summary.wireProfile ?? null
usageRecords.value = summary.usageRecords
poolSummary.value = summary.poolSummary
capacityInfo.value = summary.capacityInfo
@@ -162,7 +153,7 @@ export function useDashboard(): any {
points: '',
},
accountUsage: [],
serviceStatuses: [],
wireProfile: null,
usageRecords: [],
poolSummary: {
total: 0,
@@ -453,17 +444,6 @@ export function useDashboard(): any {
return 'normal'
}
function serviceStatusItem(item: any, index: number) {
const icons = [MonitorCheck, MonitorCheck, CloudCheck, RefreshCw, Gauge]
return {
label: item.label,
value: item.value || '-',
detail: item.detail || '-',
tone: item.tone,
icon: icons[index] ?? MonitorCheck,
}
}
function trendState(current: number, previous: number, fallbackTone: string) {
if (current > previous) return { direction: 'up', tone: 'success' }
if (current < previous) return { direction: 'down', tone: 'danger' }
@@ -524,7 +504,7 @@ export function useDashboard(): any {
trendSummary,
healthTimeline,
accountUsage,
serviceStatuses,
wireProfile,
usageRecords,
poolSummary,
capacityInfo,
+3 -3
View File
@@ -8,7 +8,7 @@ import UsageRecordCard from './components/UsageRecordCard.vue'
import MetricCard from './components/MetricCard.vue'
import RequestHealthTimelineCard from './components/RequestHealthTimelineCard.vue'
import RequestTrendCard from './components/RequestTrendCard.vue'
import ServiceStatusCard from './components/ServiceStatusCard.vue'
import WireProfileCard from './components/WireProfileCard.vue'
import { useDashboard } from './composables/useDashboard'
const {
@@ -21,7 +21,7 @@ const {
trendSummary,
healthTimeline,
accountUsage,
serviceStatuses,
wireProfile,
usageRecords,
poolSummary,
capacityInfo,
@@ -72,7 +72,7 @@ const {
:loading="trendLoading"
@trend-change="loadTrend"
/>
<ServiceStatusCard :items="serviceStatuses" />
<WireProfileCard :profile="wireProfile" />
</section>
<AccountOverviewCard