mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
refactor: replace fingerprint with wire profile
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(
|
||||
"a_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(¤t_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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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 定期版本检查失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,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 != ¤t_version)
|
||||
|| build.as_ref().is_some_and(|value| value != ¤t_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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
(
|
||||
|
||||
@@ -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());
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¤t_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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user