mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): restore Codex Core app-server identity
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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'"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Utc>,
|
||||
}
|
||||
@@ -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<CodexWireProfileConfig> 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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<CodexCredentialCatalogService>,
|
||||
quota_refresh_policy: CodexQuotaRefreshPolicy,
|
||||
oauth_refresh_enabled: bool,
|
||||
cli_release: Arc<CodexCliReleaseService>,
|
||||
desktop_release: Arc<CodexDesktopReleaseService>,
|
||||
) -> Result<Vec<WorkerContribution>, 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<CodexDesktopReleaseService>,
|
||||
}
|
||||
|
||||
struct OpenAiCliReleaseTask {
|
||||
service: Arc<CodexCliReleaseService>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
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<String, CodexCliReleaseError>>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OfficialCodexCliReleaseTransport {
|
||||
client: Client,
|
||||
endpoint: Url,
|
||||
}
|
||||
|
||||
impl OfficialCodexCliReleaseTransport {
|
||||
pub fn new() -> Result<Self, CodexCliReleaseError> {
|
||||
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<String, CodexCliReleaseError>> {
|
||||
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<dyn CodexCliReleaseTransport>,
|
||||
profile: CodexWireProfileState,
|
||||
}
|
||||
|
||||
impl CodexCliReleaseService {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
profile: CodexWireProfileState,
|
||||
transport: Arc<dyn CodexCliReleaseTransport>,
|
||||
) -> Self {
|
||||
Self { transport, profile }
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<String, CodexCliReleaseError> {
|
||||
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 制品。
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<VecDeque<Result<String, CodexCliReleaseError>>>,
|
||||
}
|
||||
|
||||
impl CliReleaseTransport {
|
||||
fn new(outcomes: impl IntoIterator<Item = Result<String, CodexCliReleaseError>>) -> Self {
|
||||
Self {
|
||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexCliReleaseTransport for CliReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>> {
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user