diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index de41dfb2e..1c2800d82 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -134,7 +134,7 @@ fn config_loader_should_reject_removed_fingerprint_section() { } #[test] -fn config_loader_should_reject_invalid_model_catalog_version() { +fn config_loader_should_reject_invalid_codex_cli_version() { assert_rejected(valid_config().replace("codex_version: '0.146.0'", "codex_version: 'latest'")); } diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 2279245f8..97a112657 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -194,22 +194,23 @@ impl ProviderAdmin for OpenAiAdminProvider { let profile = self.profile.snapshot(); let release = self.desktop_release.snapshot(); let user_agent = profile.user_agent(); + let client_identity = format!("{}; {}", profile.originator, profile.desktop_version); let release = dashboard_desktop_release(&profile, release); Some(DashboardWireProfile { provider: self.provider_kind.as_str().to_owned(), product: profile.originator, - version: profile.desktop_version, - build: Some(profile.desktop_build), + version: profile.codex_version, + build: None, target: DashboardWireTarget { os_type: dashboard_os_type(&profile.os_type), - os_version: "—".to_owned(), + os_version: profile.os_version, arch: profile.arch, - terminal: "—".to_owned(), + terminal: profile.terminal, }, user_agent, attributes: vec![DashboardWireAttribute { label: "客户端标识".to_owned(), - value: "Desktop".to_owned(), + value: client_identity, }], verified_at: Some(profile.verified_at), release: Some(release), diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 93936682d..6e32e8b57 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -278,17 +278,13 @@ impl CodexAuthSettings { #[serde(deny_unknown_fields)] pub struct CodexWireProfileConfig { pub originator: String, - /// bundled Core 的模型目录版本;不参与 Desktop HTTP User-Agent。 + /// npm `@openai/codex` 启动版本;运行时发布任务会更新此画像字段。 pub codex_version: String, pub desktop_version: String, pub desktop_build: String, pub os_type: String, - /// 兼容旧配置:Desktop HTTP 不再使用系统小版本。 - #[serde(default)] pub os_version: String, pub arch: String, - /// 兼容旧配置:Desktop HTTP 不再使用终端标记。 - #[serde(default)] pub terminal: String, pub verified_at: DateTime, } @@ -301,9 +297,9 @@ impl Default for CodexWireProfileConfig { desktop_version: "26.727.51351".to_owned(), desktop_build: "6119".to_owned(), os_type: "Mac OS".to_owned(), - os_version: String::new(), + os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), - terminal: String::new(), + terminal: "unknown".to_owned(), verified_at: Utc::now(), } } @@ -326,7 +322,9 @@ impl CodexWireProfileConfig { self.desktop_build.as_str(), ), ("openai.wire_profile.os_type", self.os_type.as_str()), + ("openai.wire_profile.os_version", self.os_version.as_str()), ("openai.wire_profile.arch", self.arch.as_str()), + ("openai.wire_profile.terminal", self.terminal.as_str()), ] { if value.trim().is_empty() { return Err(OpenAiConfigError::InvalidField(field)); @@ -358,14 +356,16 @@ impl From for CodexWireProfile { codex_version: value.codex_version, desktop_version: value.desktop_version, desktop_build: value.desktop_build, - os_type: canonical_desktop_os_type(value.os_type), + os_type: canonical_codex_os_type(value.os_type), + os_version: value.os_version, arch: value.arch, + terminal: value.terminal, verified_at: value.verified_at, } } } -fn canonical_desktop_os_type(os_type: String) -> String { +fn canonical_codex_os_type(os_type: String) -> String { match os_type.as_str() { "macOS" => "Mac OS".to_owned(), _ => os_type, diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 3716197dd..e78434742 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -24,7 +24,10 @@ use crate::credential::{ OfficialCodexAgentIdentityTaskRegistrar, ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource, }; -use crate::transport::profile::{CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport}; +use crate::transport::profile::{ + CodexCliReleaseService, CodexDesktopReleaseService, OfficialCodexCliReleaseTransport, + OfficialCodexDesktopReleaseTransport, +}; use crate::transport::{CodexWebSocketPool, build_reqwest_client}; pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError}; @@ -75,6 +78,13 @@ pub async fn initialize( ), )); let desktop_release_status = desktop_release.status(); + let cli_release = Arc::new(CodexCliReleaseService::new( + profile.clone(), + Arc::new( + OfficialCodexCliReleaseTransport::new() + .map_err(|_| OpenAiInitializeError::CliRelease)?, + ), + )); let repository = CodexCredentialRepository::new(Arc::clone(&accounts)); let websocket_pool = Arc::new(CodexWebSocketPool::with_config( config.websocket_pool_config(), @@ -200,6 +210,7 @@ pub async fn initialize( catalog, config.quota_refresh_policy(), config.oauth_refresh_enabled(), + cli_release, desktop_release, ) .map_err(|_| OpenAiInitializeError::Worker)?; @@ -257,6 +268,8 @@ pub enum OpenAiInitializeError { Refresh, #[error("OpenAI Desktop release service could not initialize")] DesktopRelease, + #[error("OpenAI CLI release service could not initialize")] + CliRelease, #[error("OpenAI worker plan is invalid")] Worker, } diff --git a/backend/crates/providers/openai/src/provider.rs b/backend/crates/providers/openai/src/provider.rs index 4d739239c..f6956b31e 100644 --- a/backend/crates/providers/openai/src/provider.rs +++ b/backend/crates/providers/openai/src/provider.rs @@ -57,7 +57,8 @@ use crate::transport::diagnostics::{ CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase, }; use crate::transport::profile::{ - APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState, + APPCAST_POLL_INTERVAL, CodexCliReleaseService, CodexDesktopReleaseService, + CodexWireProfileState, }; use crate::transport::protocol::responses::{ CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals, @@ -2337,6 +2338,7 @@ const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60); const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60); const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30); const DESKTOP_RELEASE_WORKER_OWNER: &str = "openai-desktop-release"; +const CLI_RELEASE_WORKER_OWNER: &str = "openai-cli-release"; const MODEL_ETAG_WORKER_OWNER: &str = "openai-model-etag"; pub(crate) fn worker_contributions( @@ -2345,6 +2347,7 @@ pub(crate) fn worker_contributions( catalog: Arc, quota_refresh_policy: CodexQuotaRefreshPolicy, oauth_refresh_enabled: bool, + cli_release: Arc, desktop_release: Arc, ) -> Result, WorkerDefinitionError> { let refresh_id = WorkerId::try_new(WorkerKind::OAuthRefresh, PROVIDER_NAME)?; @@ -2352,6 +2355,8 @@ pub(crate) fn worker_contributions( let etag_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, MODEL_ETAG_WORKER_OWNER)?; let desktop_release_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, DESKTOP_RELEASE_WORKER_OWNER)?; + let cli_release_id = + WorkerId::try_new(WorkerKind::QuotaCatalogHealth, CLI_RELEASE_WORKER_OWNER)?; let mut contributions = Vec::new(); if oauth_refresh_enabled { contributions.push(WorkerContribution::Registration(scheduled_registration( @@ -2379,6 +2384,13 @@ pub(crate) fn worker_contributions( task: Box::new(OpenAiCatalogEtagTask { catalog }), }, )?), + WorkerContribution::Registration(scheduled_registration( + cli_release_id, + APPCAST_POLL_INTERVAL, + Box::new(OpenAiCliReleaseTask { + service: cli_release, + }), + )?), WorkerContribution::Registration(scheduled_registration( desktop_release_id, APPCAST_POLL_INTERVAL, @@ -2488,6 +2500,27 @@ struct OpenAiDesktopReleaseTask { service: Arc, } +struct OpenAiCliReleaseTask { + service: Arc, +} + +impl ScheduledTask for OpenAiCliReleaseTask { + fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { + Box::pin(async move { + let refresh = self.service.refresh(); + tokio::pin!(refresh); + let result = tokio::select! { + () = context.cancellation().cancelled() => return Ok(()), + result = &mut refresh => result, + }; + if let Err(error) = result { + tracing::warn!(error = %error, "OpenAI CLI release check failed"); + } + Ok(()) + }) + } +} + impl ScheduledTask for OpenAiDesktopReleaseTask { fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { Box::pin(async move { diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index f79cd3c61..c363920c6 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -3,7 +3,7 @@ use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AG use super::client::CodexClientResult; -/// 构造 Codex Desktop HTTP 为模型请求设置的稳定身份请求头。 +/// 构造 Codex Core 为模型请求设置的稳定身份请求头。 pub fn build_codex_base_headers( profile: &CodexWireProfile, authorization: &str, diff --git a/backend/crates/providers/openai/src/transport/profile.rs b/backend/crates/providers/openai/src/transport/profile.rs index 6a596c6f3..e841bbe9f 100644 --- a/backend/crates/providers/openai/src/transport/profile.rs +++ b/backend/crates/providers/openai/src/transport/profile.rs @@ -13,42 +13,53 @@ use url::Url; /// Codex Desktop 官方 appcast 地址。 pub const CODEX_DESKTOP_APPCAST_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; -/// 官方制品画像检查周期。 +pub const CODEX_CLI_RELEASE_URL: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest"; +/// 官方发布元数据检查周期。 pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); const APPCAST_TIMEOUT: Duration = Duration::from_secs(30); const MAX_APPCAST_BYTES: usize = 1024 * 1024; +const MAX_CLI_RELEASE_BYTES: usize = 64 * 1024; /// Codex Desktop 上游请求身份。 /// -/// 启动配置提供经制品审计的 Desktop HTTP 身份与模型目录版本。官方 appcast -/// 检查成功后,仅同步 Desktop 版本和构建号。 +/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。npm CLI metadata +/// 与官方 appcast 检查成功后,分别同步 Core 版本和 Desktop 版本及构建号。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CodexWireProfile { /// `originator` 请求头及 User-Agent 产品名。 pub originator: String, - /// 已审计的 bundled Codex Core 模型目录版本,仅用于 `/codex/models?client_version=`。 - /// - /// 它不属于 Desktop HTTP User-Agent,且不能由 npm 的独立 CLI 发布版本推导。 + /// npm `@openai/codex` 启动版本;用于 `/codex/models?client_version=` 与 UA。 pub codex_version: String, - /// Desktop 应用版本,直接构成 Desktop HTTP User-Agent。 + /// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。 pub desktop_version: String, /// Desktop 制品构建号,仅用于发布对齐诊断。 pub desktop_build: String, - /// Desktop HTTP User-Agent 中的官方操作系统标记,例如 `Mac OS`。 + /// Codex Core UA 中的目标操作系统类型。 pub os_type: String, - /// Desktop HTTP User-Agent 中的目标架构。 + /// Codex Core UA 中的目标操作系统版本。 + pub os_version: String, + /// Codex Core UA 中的目标架构。 pub arch: String, + /// Codex Core UA 中的终端标记。 + pub terminal: String, /// 此画像最后一次经制品与源码核验的时间。 pub verified_at: DateTime, } impl CodexWireProfile { - /// 按官方 Codex Desktop HTTP 格式生成最终 User-Agent。 + /// 按 bundled Core app-server 的官方格式生成最终 User-Agent。 pub fn user_agent(&self) -> String { format!( - "{}/{} ({}; {})", - self.originator, self.desktop_version, self.os_type, self.arch, + "{}/{} ({} {}; {}) {} ({}; {})", + self.originator, + self.codex_version, + self.os_type, + self.os_version, + self.arch, + self.terminal, + self.originator, + self.desktop_version, ) } } @@ -87,6 +98,112 @@ impl CodexWireProfileState { profile.desktop_version = desktop_version.to_owned(); profile.desktop_build = desktop_build.to_owned(); } + + /// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时请求画像。 + pub fn update_cli_release(&self, codex_version: &str) { + let mut profile = self + .profile + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if profile.codex_version != codex_version { + profile.codex_version = codex_version.to_owned(); + } + } +} + +pub trait CodexCliReleaseTransport: Send + Sync { + fn fetch(&self) -> BoxFuture<'_, Result>; +} + +#[derive(Clone)] +pub struct OfficialCodexCliReleaseTransport { + client: Client, + endpoint: Url, +} + +impl OfficialCodexCliReleaseTransport { + pub fn new() -> Result { + let endpoint = + Url::parse(CODEX_CLI_RELEASE_URL).map_err(|_| CodexCliReleaseError::InvalidEndpoint)?; + let client = Client::builder() + .https_only(true) + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(APPCAST_TIMEOUT) + .build() + .map_err(|_| CodexCliReleaseError::ClientInitialization)?; + Ok(Self { client, endpoint }) + } +} + +impl CodexCliReleaseTransport for OfficialCodexCliReleaseTransport { + fn fetch(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let response = self.client.get(self.endpoint.clone()).send().await?; + if !response.status().is_success() { + return Err(CodexCliReleaseError::HttpStatus(response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|size| size > MAX_CLI_RELEASE_BYTES as u64) + { + return Err(CodexCliReleaseError::ResponseTooLarge); + } + let bytes = response.bytes().await?; + if bytes.len() > MAX_CLI_RELEASE_BYTES { + return Err(CodexCliReleaseError::ResponseTooLarge); + } + let document: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| CodexCliReleaseError::InvalidDocument)?; + let version = document + .get("version") + .and_then(serde_json::Value::as_str) + .ok_or(CodexCliReleaseError::InvalidDocument)?; + semver::Version::parse(version).map_err(|_| CodexCliReleaseError::InvalidVersion)?; + Ok(version.to_owned()) + }) + } +} + +#[derive(Clone)] +pub struct CodexCliReleaseService { + transport: Arc, + profile: CodexWireProfileState, +} + +impl CodexCliReleaseService { + #[must_use] + pub fn new( + profile: CodexWireProfileState, + transport: Arc, + ) -> Self { + Self { transport, profile } + } + + pub async fn refresh(&self) -> Result { + let version = self.transport.fetch().await?; + self.profile.update_cli_release(&version); + Ok(version) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CodexCliReleaseError { + #[error("Codex CLI release client initialization failed")] + ClientInitialization, + #[error("Codex CLI release endpoint is invalid")] + InvalidEndpoint, + #[error("Codex CLI release request failed")] + Request(#[from] reqwest::Error), + #[error("Codex CLI release endpoint returned HTTP {0}")] + HttpStatus(u16), + #[error("Codex CLI release response exceeded the size limit")] + ResponseTooLarge, + #[error("Codex CLI release document is invalid")] + InvalidDocument, + #[error("Codex CLI release version is invalid")] + InvalidVersion, } /// 官方 appcast 中按顺序出现的首个完整 Desktop 制品。 diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 260510333..1660f0a9b 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -55,7 +55,7 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions assert_eq!(bundle.core_provider().name(), "openai"); assert_eq!(bundle.admin_provider().provider_kind().as_str(), "openai"); let contributions = bundle.take_worker_contributions(); - assert_eq!(contributions.len(), 4); + assert_eq!(contributions.len(), 5); assert!( contributions .iter() @@ -82,6 +82,22 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions panic!("Desktop release worker must be scheduled"); }; assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL); + let cli_release_worker = contributions + .iter() + .find_map(|contribution| match contribution { + WorkerContribution::Registration(registration) + if registration.id.owner() == "openai-cli-release" => + { + Some(registration) + } + WorkerContribution::Registration(_) | WorkerContribution::Disabled { .. } => None, + }) + .expect("CLI release worker"); + assert_eq!(cli_release_worker.id.kind(), WorkerKind::QuotaCatalogHealth); + let WorkerRunnable::Scheduled { schedule, .. } = &cli_release_worker.runnable else { + panic!("CLI release worker must be scheduled"); + }; + assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL); assert!(contributions.iter().any(|contribution| { matches!( contribution, @@ -101,21 +117,22 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() .expect("OpenAI bundle"); let admin = bundle.admin_provider(); let profile = admin.dashboard_wire_profile().expect("wire profile"); + assert_eq!(profile.version, "0.102.0"); + assert_eq!(profile.build, None); + assert_eq!(profile.target.os_type, "macOS"); + assert_eq!(profile.target.os_version, "15.5.0"); + assert_eq!( + profile.user_agent, + "Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)" + ); assert_eq!( profile .attributes .iter() .find(|attribute| attribute.label == "客户端标识") .map(|attribute| attribute.value.as_str()), - Some("Desktop") + Some("Codex Desktop; 1.2026.190") ); - assert_eq!( - profile.user_agent, - "Codex Desktop/1.2026.190 (Mac OS; arm64)" - ); - assert_eq!(profile.target.os_type, "macOS"); - assert_eq!(profile.target.os_version, "—"); - assert_eq!(profile.target.terminal, "—"); assert_eq!( profile.release.as_ref().map(|release| release.status), Some(DesktopReleaseStatus::Unchecked) @@ -778,7 +795,7 @@ fn valid_config() -> TestOpenAiConfig { codex_version: "0.102.0".to_owned(), desktop_version: "1.2026.190".to_owned(), desktop_build: "19012345678".to_owned(), - os_type: "Mac OS".to_owned(), + os_type: "macOS".to_owned(), os_version: "15.5.0".to_owned(), arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), diff --git a/backend/crates/providers/openai/tests/config.rs b/backend/crates/providers/openai/tests/config.rs index 6462cfa62..1a24a761e 100644 --- a/backend/crates/providers/openai/tests/config.rs +++ b/backend/crates/providers/openai/tests/config.rs @@ -13,7 +13,7 @@ fn openai_config_builds_the_audited_wire_profile() { assert_eq!( profile.user_agent(), - "Codex Desktop/1.2026.190 (Mac OS; arm64)" + "Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)" ); assert_eq!(profile.desktop_build, "19012345678"); } @@ -45,20 +45,6 @@ fn openai_config_rejects_noncanonical_versions_and_empty_fields() { ); } -#[test] -fn openai_config_normalizes_the_legacy_macos_wire_label() { - let mut config = valid_config(); - config.wire_profile.os_type = "macOS".to_owned(); - config - .resolve_and_validate(Path::new("/srv/gateway")) - .expect("legacy macOS label remains supported"); - - assert_eq!( - config.wire_profile_state().snapshot().user_agent(), - "Codex Desktop/1.2026.190 (Mac OS; arm64)" - ); -} - #[test] fn openai_config_restricts_upstream_base_url_to_https_or_loopback_http() { for base_url in [ @@ -140,7 +126,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() { ); assert_eq!( config.wire_profile_state().snapshot().user_agent(), - "Codex Desktop/26.727.51351 (Mac OS; arm64)" + "Codex Desktop/0.146.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.727.51351)" ); } @@ -151,7 +137,7 @@ fn valid_config() -> OpenAiConfig { codex_version: "0.102.0".to_owned(), desktop_version: "1.2026.190".to_owned(), desktop_build: "19012345678".to_owned(), - os_type: "Mac OS".to_owned(), + os_type: "macOS".to_owned(), os_version: "15.5.0".to_owned(), arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index 1f1e669d6..819f531ed 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -57,7 +57,9 @@ fn wire_profile() -> CodexWireProfileState { desktop_version: "1.0.0".to_owned(), desktop_build: "1".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "catalog-contract".to_owned(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index 7a015bee5..dc5838b11 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -124,7 +124,9 @@ fn selector_with_runtime( desktop_version: "1.0.0".to_owned(), desktop_build: "1".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "selector-contract".to_owned(), verified_at: chrono::Utc::now(), }); let http = reqwest::Client::builder().build().expect("HTTP client"); diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index df6a293f1..baf786083 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -33,7 +33,9 @@ fn wire_profile() -> CodexWireProfileState { desktop_version: "1.0.0".to_owned(), desktop_build: "1".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "quota-contract".to_owned(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 586020f41..c36d066a9 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -63,7 +63,9 @@ fn wire_profile() -> CodexWireProfileState { desktop_version: "1.0.0".to_owned(), desktop_build: "1".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "provider-contract".to_owned(), verified_at: Utc::now(), }) } diff --git a/backend/crates/providers/openai/tests/transport/catalog.rs b/backend/crates/providers/openai/tests/transport/catalog.rs index 44bfee3c3..74cca3c87 100644 --- a/backend/crates/providers/openai/tests/transport/catalog.rs +++ b/backend/crates/providers/openai/tests/transport/catalog.rs @@ -219,7 +219,10 @@ async fn fetch_should_send_official_catalog_headers_and_query() { .and(header("authorization", "Bearer oauth-access")) .and(header("chatgpt-account-id", "acct_123")) .and(header("originator", "codex_cli_rs")) - .and(header("user-agent", "codex_cli_rs/1.0.0 (linux; x86_64)")) + .and(header( + "user-agent", + "codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)", + )) .and(header("accept", "application/json")) .and(header("x-codex-installation-id", "installation-123")) .respond_with( @@ -300,7 +303,9 @@ fn profile() -> CodexWireProfileState { desktop_version: "1.0.0".to_owned(), desktop_build: "1".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "xterm".to_owned(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index c36cd9158..f43a8ee0f 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -854,7 +854,7 @@ async fn backend_http_should_force_upstream_sse_for_non_streaming_client_request } #[tokio::test] -async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_latest_desktop_wire_profile() +async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_latest_codex_core_wire_profile() { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -947,7 +947,7 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la ) .await .expect("first response"); - profile.update_desktop_release("1.2.4", "124"); + profile.update_cli_release("1.2.4"); let mut continuation = request.clone(); continuation.set_previous_response_id(Some("resp_profile_first".to_owned())); diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index 7ce884c72..5c78af34b 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -117,7 +117,9 @@ fn test_wire_profile() -> CodexWireProfileState { desktop_version: "1.2.3".to_owned(), desktop_build: "123".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "transport-test".to_owned(), verified_at: Utc::now(), }) } diff --git a/backend/crates/providers/openai/tests/transport/profile.rs b/backend/crates/providers/openai/tests/transport/profile.rs index a03afb70d..ea3f67e40 100644 --- a/backend/crates/providers/openai/tests/transport/profile.rs +++ b/backend/crates/providers/openai/tests/transport/profile.rs @@ -4,8 +4,9 @@ use std::sync::{Arc, Mutex}; use chrono::{TimeZone, Utc}; use futures::future::BoxFuture; use provider_openai::transport::profile::{ - CodexDesktopRelease, CodexDesktopReleaseError, CodexDesktopReleaseService, - CodexDesktopReleaseTransport, CodexWireProfile, CodexWireProfileState, parse_desktop_release, + CodexCliReleaseError, CodexCliReleaseService, CodexCliReleaseTransport, CodexDesktopRelease, + CodexDesktopReleaseError, CodexDesktopReleaseService, CodexDesktopReleaseTransport, + CodexWireProfile, CodexWireProfileState, parse_desktop_release, }; struct ReleaseTransport { @@ -34,15 +35,41 @@ impl CodexDesktopReleaseTransport for ReleaseTransport { } } +struct CliReleaseTransport { + outcomes: Mutex>>, +} + +impl CliReleaseTransport { + fn new(outcomes: impl IntoIterator>) -> Self { + Self { + outcomes: Mutex::new(outcomes.into_iter().collect()), + } + } +} + +impl CodexCliReleaseTransport for CliReleaseTransport { + fn fetch(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.outcomes + .lock() + .expect("CLI release outcomes") + .pop_front() + .expect("CLI release outcome") + }) + } +} + #[test] -fn wire_profile_should_generate_codex_desktop_http_user_agent() { +fn wire_profile_should_generate_bundled_core_app_server_user_agent() { let profile = CodexWireProfile { originator: "Codex Desktop".to_owned(), codex_version: "0.144.2".to_owned(), desktop_version: "26.707.72221".to_owned(), desktop_build: "72221".to_owned(), os_type: "Mac OS".to_owned(), + os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), + terminal: "unknown".to_owned(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() @@ -51,11 +78,8 @@ fn wire_profile_should_generate_codex_desktop_http_user_agent() { assert_eq!( profile.user_agent(), - "Codex Desktop/26.707.72221 (Mac OS; arm64)" + "Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)" ); - let mut catalog_version_only = profile.clone(); - catalog_version_only.codex_version = "0.145.0".to_owned(); - assert_eq!(catalog_version_only.user_agent(), profile.user_agent()); } #[test] @@ -128,12 +152,14 @@ async fn wire_profile_release_update_should_change_only_desktop_identity() { assert_eq!(updated.codex_version, original.codex_version); assert_eq!(updated.originator, original.originator); assert_eq!(updated.os_type, original.os_type); + assert_eq!(updated.os_version, original.os_version); assert_eq!(updated.arch, original.arch); + assert_eq!(updated.terminal, original.terminal); assert_eq!(updated.verified_at, original.verified_at); assert_ne!(updated.user_agent(), original_user_agent); assert_eq!( updated.user_agent(), - "Codex Desktop/26.720.1 (Mac OS; arm64)" + "Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.720.1)" ); let status = service.status().snapshot(); assert_eq!(status.latest, Some(release("26.720.1", "72001"))); @@ -141,6 +167,35 @@ async fn wire_profile_release_update_should_change_only_desktop_identity() { assert!(status.last_error.is_none()); } +#[tokio::test] +async fn cli_release_update_should_change_only_codex_core_identity() { + let original = wire_profile(); + let original_user_agent = original.user_agent(); + let state = CodexWireProfileState::new(original.clone()); + let service = CodexCliReleaseService::new( + state.clone(), + Arc::new(CliReleaseTransport::new([Ok("0.145.0".to_owned())])), + ); + + service.refresh().await.expect("CLI release refresh"); + + let updated = state.snapshot(); + assert_eq!(updated.codex_version, "0.145.0"); + assert_eq!(updated.desktop_version, original.desktop_version); + assert_eq!(updated.desktop_build, original.desktop_build); + assert_eq!(updated.originator, original.originator); + assert_eq!(updated.os_type, original.os_type); + assert_eq!(updated.os_version, original.os_version); + assert_eq!(updated.arch, original.arch); + assert_eq!(updated.terminal, original.terminal); + assert_eq!(updated.verified_at, original.verified_at); + assert_ne!(updated.user_agent(), original_user_agent); + assert_eq!( + updated.user_agent(), + "Codex Desktop/0.145.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)" + ); +} + #[tokio::test] async fn failed_release_refresh_should_preserve_the_last_successful_profile_and_release() { let state = CodexWireProfileState::new(wire_profile()); @@ -177,7 +232,9 @@ fn wire_profile() -> CodexWireProfile { desktop_version: "26.707.72221".to_owned(), desktop_build: "72221".to_owned(), os_type: "Mac OS".to_owned(), + os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), + terminal: "unknown".to_owned(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index 53b5dc17a..ce431c1a2 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -167,7 +167,7 @@ async fn fetch_should_use_wham_usage_headers_only() { headers .get("user-agent") .and_then(|value| value.to_str().ok()), - Some("codex_cli_rs/1.0.0 (linux; x86_64)") + Some("codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)") ); let quota_header_names = headers .keys() @@ -420,7 +420,9 @@ fn profile() -> CodexWireProfileState { desktop_version: "1.0.0".to_owned(), desktop_build: "1".to_owned(), os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), + terminal: "xterm".to_owned(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 07ddfdd29..1e755d173 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -57,14 +57,16 @@ api: openai: # 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。 wire_profile: - # Provider 每 24 小时读取官方 Desktop appcast,仅在进程内更新 Desktop 版本与构建号。 - # codex_version 仅用于模型目录的 client_version,来自已审计的 Desktop 内嵌 Core,不参与 UA。 + # codex_version 是 npm CLI 的启动基线,并用于模型目录与请求 UA;Provider 每 24 小时更新它。 + # Desktop appcast 同期只更新 Desktop 版本与构建号。 originator: 'Codex Desktop' codex_version: '0.146.0' desktop_version: '26.727.51351' desktop_build: '6119' os_type: 'Mac OS' + os_version: '15.7.1' arch: 'arm64' + terminal: 'unknown' verified_at: '2026-08-04T00:00:00+08:00' xai: