From 5782c71f73bbeca99574a2db30b17d2881af04ee Mon Sep 17 00:00:00 2001 From: Wei HU <37072526+huweiATgithub@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:44:39 +0800 Subject: [PATCH] feat(openai): support complete identities with official release updates (#272) Reuse official daily release checks for complete TUI and Exec identities without third-party catalogs. Keep settings and key overrides on a compact preset and version-policy editor, with matching previews and loading states. Prefill custom versions with the current user agent. Compare settings by value, align toolbar icon backgrounds, and compact the key creation form. Release transaction locks before rejecting stale proxy test results, with a regression covering an immediate edit during connection cleanup. Co-authored-by: Wei Hu Co-authored-by: Codex --- .../gateway-store/src/postgres/proxies.rs | 5 + .../gateway-store/tests/postgres/proxies.rs | 74 +++++ backend/crates/providers/openai/src/admin.rs | 97 ++++--- .../openai/src/credential/catalog.rs | 2 +- backend/crates/providers/openai/src/lib.rs | 2 +- .../providers/openai/src/provider/mod.rs | 2 +- .../openai/src/transport/client_sse.rs | 21 +- .../openai/src/transport/profile/identity.rs | 255 ++++++++++++++++++ .../openai/src/transport/profile/mod.rs | 18 +- .../openai/src/transport/profile/selection.rs | 74 ++++- .../crates/providers/openai/tests/admin.rs | 29 ++ .../openai/tests/credential/catalog.rs | 1 + .../openai/tests/credential/contract.rs | 1 + .../tests/credential/profile_statistics.rs | 1 + .../openai/tests/credential/quota/mod.rs | 1 + .../openai/tests/provider/contract.rs | 1 + .../openai/tests/transport/catalog.rs | 1 + .../openai/tests/transport/headers.rs | 166 ++++++++++++ .../providers/openai/tests/transport/mod.rs | 1 + .../tests/transport/profile/cli_release.rs | 93 +++++++ .../tests/transport/profile/identity.rs | 142 ++++++++++ .../openai/tests/transport/profile/mod.rs | 3 + .../tests/transport/profile/selection.rs | 27 +- .../openai/tests/transport/reset_credits.rs | 1 + .../providers/openai/tests/transport/usage.rs | 1 + docs/api.md | 19 +- docs/architecture.md | 5 +- frontend/src/api/modules/client-profiles.ts | 26 +- .../client-profile/ClientProfileEditor.vue | 158 ++++------- .../ClientProfilePresetFields.vue | 134 +++++++++ .../ClientProfilePreviewPanel.vue | 31 ++- .../key-usage/components/KeyUsageHeader.vue | 4 +- .../keys/components/ApiKeyCreateModal.vue | 46 ++-- .../settings/composables/useSettingsForm.ts | 2 +- frontend/src/views/settings/index.vue | 2 +- 35 files changed, 1222 insertions(+), 224 deletions(-) create mode 100644 backend/crates/providers/openai/src/transport/profile/identity.rs create mode 100644 backend/crates/providers/openai/tests/transport/profile/identity.rs create mode 100644 frontend/src/components/client-profile/ClientProfilePresetFields.vue diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index fe338a1fe..dd61ebb81 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -815,6 +815,11 @@ impl ProxyStore for PgProxyRepository { .await .map_err(store_error)?; if current.revision != revision { + // Drop 只排队回滚,返回冲突前需释放事务锁,避免误挡紧接着的编辑。 + transaction + .rollback() + .await + .map_err(|_| store_error(unavailable()))?; return Err(store_error(conflict(id))); } save_test(&mut transaction, ¤t, result) diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index 6280b8240..aa5309549 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -517,6 +517,80 @@ async fn rejected_import_reservations_release_proxy_lock_before_returning() { database.close().await; } +#[tokio::test] +async fn stale_proxy_test_releases_lock_before_next_update() { + let Some(database) = TestDatabase::create("stale_proxy_test_lock").await else { + return; + }; + let store = PgProxyRepository::new(database.pool.clone()); + let context = context(); + let mut saved = store + .create( + NewProxy { + name: "过期检测结果".to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:19090").unwrap(), + auto_location: true, + location: None, + test: None, + }, + &context, + ) + .await + .unwrap() + .record; + let stale_revision = saved.revision; + saved = store + .record_test(&saved.id, saved.revision, success(), &context) + .await + .unwrap() + .record; + + // 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放。 + let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); + let rejected_pool = database + .pool + .options() + .clone() + .max_connections(1) + .after_release({ + let release_gate = release_gate.clone(); + move |_, _| { + let release_gate = release_gate.clone(); + Box::pin(async move { + let _permit = release_gate.acquire().await.unwrap(); + Ok(true) + }) + } + }) + .connect_with((*database.pool.connect_options()).clone()) + .await + .unwrap(); + let stale_writer = PgProxyRepository::new(rejected_pool.clone()); + let error = stale_writer + .record_test(&saved.id, stale_revision, success(), &context) + .await + .unwrap_err(); + let update = store + .update( + UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: saved.name.clone(), + proxy: None, + auto_location: None, + location: None, + test: None, + }, + &context, + ) + .await; + release_gate.add_permits(1); + rejected_pool.close().await; + database.close().await; + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + assert!(update.is_ok(), "过期检测结果不应阻挡后续编辑: {update:?}"); +} + #[tokio::test] async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() { use gateway_store::postgres::{ diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index eca52d1aa..f527354e0 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -287,47 +287,48 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, configuration: &OpaqueProviderData, ) -> Option { - use crate::transport::profile::selection::{ - ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, - }; - let selection = ClientProfileSelection::parse(configuration).ok()?; + use crate::transport::profile::identity::RequestProfileSelection; + use crate::transport::profile::selection::{ClientKind, ClientPlatform, VersionMode}; + let selection = RequestProfileSelection::parse(configuration).ok()?; let profile = selection.resolve(&self.profile).ok()?; - let custom = selection.version_mode == VersionMode::Fixed; - let (checked_at, error) = self.profile.client_release_status( - selection.client, - selection.platform, - selection.architecture(), - ); - let release = if custom { - None - } else if selection.client == ClientKind::Desktop - && selection.platform == ClientPlatform::Macos - { - Some(dashboard_desktop_release( - &profile, - self.desktop_release.snapshot(), - )) - } else { - Some(DashboardDesktopRelease { - status: if error.is_some() { - DesktopReleaseStatus::Failed - } else if checked_at.is_some() { - DesktopReleaseStatus::Current - } else { - DesktopReleaseStatus::Unchecked - }, - checked_at, - latest_version: Some(profile.codex_version.clone()), - latest_build: None, - published_at: None, - minimum_system_version: None, - hardware_requirements: None, - download_url: None, - download_size: None, - signature_present: None, - error, - }) - }; + let custom = selection.version_mode() == VersionMode::Fixed; + let release = selection.preset().and_then(|selection| { + let (checked_at, error) = self.profile.client_release_status( + selection.client, + selection.platform, + selection.architecture(), + ); + if custom { + None + } else if selection.client == ClientKind::Desktop + && selection.platform == ClientPlatform::Macos + { + Some(dashboard_desktop_release( + &profile, + self.desktop_release.snapshot(), + )) + } else { + Some(DashboardDesktopRelease { + status: if error.is_some() { + DesktopReleaseStatus::Failed + } else if checked_at.is_some() { + DesktopReleaseStatus::Current + } else { + DesktopReleaseStatus::Unchecked + }, + checked_at, + latest_version: Some(profile.codex_version.clone()), + latest_build: None, + published_at: None, + minimum_system_version: None, + hardware_requirements: None, + download_url: None, + download_size: None, + signature_present: None, + error, + }) + } + }); Some(DashboardWireProfile { provider: self.provider_kind.as_str().to_owned(), product: profile.originator.clone(), @@ -343,7 +344,7 @@ impl ProviderAdmin for OpenAiAdminProvider { attributes: vec![ DashboardWireAttribute { label: "客户端标识".to_owned(), - value: if selection.client == ClientKind::Desktop { + value: if profile.client_kind == ClientKind::Desktop { format!("{}; {}", profile.originator, profile.desktop_version) } else { profile.originator @@ -352,14 +353,15 @@ impl ProviderAdmin for OpenAiAdminProvider { DashboardWireAttribute { label: "版本策略".to_owned(), value: if custom { - "固定自定义" + "固定身份" } else { "自动最新" } .to_owned(), }, ], - verified_at: (!custom).then_some(profile.verified_at), + verified_at: (!custom && profile.verified_at != chrono::DateTime::UNIX_EPOCH) + .then_some(profile.verified_at), release, }) } @@ -1934,6 +1936,15 @@ fn map_client_profile_error( use crate::transport::profile::selection::ClientProfileError; let message = match error { ClientProfileError::Invalid => "客户端身份字段或版本组合不合法", + ClientProfileError::InvalidUserAgent => { + "User-Agent 必须是 1 至 4096 字节的单行 ASCII 文本,且首尾不能含空白" + } + ClientProfileError::CompanionHeadersRequired => { + "无法识别 User-Agent,请补充 originator 和有效的 Core version" + } + ClientProfileError::CompanionHeadersConflict => { + "originator 或 Core version 与 User-Agent 不一致" + } ClientProfileError::ReleaseUnavailable => { "此客户端、平台与架构尚无已核验发布版本,请选择固定版本或稍后重试" } diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index 0ff747b48..51f2c5ee4 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -277,7 +277,7 @@ impl CodexCredentialCatalogService { .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; let request_profile = match scope.request_profile(&provider) { Some(configuration) => { - crate::transport::profile::selection::ClientProfileSelection::parse(configuration) + crate::transport::profile::identity::RequestProfileSelection::parse(configuration) .and_then(|selection| selection.resolve(&self.profile)) .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)? } diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 9bb64b314..099c00c70 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -69,7 +69,7 @@ pub async fn initialize( .initialize_request_profile(&provider_kind, initial_profile) .await .map_err(|_| OpenAiInitializeError::RuntimePolicy)?; - transport::profile::selection::ClientProfileSelection::parse(&configured_profile) + transport::profile::identity::RequestProfileSelection::parse(&configured_profile) .map_err(|_| OpenAiInitializeError::RuntimePolicy)?; let credential_state = ports.credential_state(); let profile = diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 4bf006e06..83f77c15e 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -297,7 +297,7 @@ impl Provider for CodexProvider { configuration: &gateway_core::account::OpaqueProviderData, ) -> Result { let selection = - crate::transport::profile::selection::ClientProfileSelection::parse(configuration) + crate::transport::profile::identity::RequestProfileSelection::parse(configuration) .map_err(|_| { provider_error( ProviderErrorKind::InvalidRequest, diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index eff9cf786..cd56b5541 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -651,14 +651,19 @@ fn websocket_connection_profile( headers: &HeaderMap, middleware_headers: &[gateway_core::engine::middleware::MiddlewareHeader], ) -> String { - let mut profile = ["originator", "user-agent", X_OPENAI_MEMGEN_REQUEST_HEADER] - .map(|name| { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - }) - .join("\0"); + let mut profile = [ + "originator", + "user-agent", + "version", + X_OPENAI_MEMGEN_REQUEST_HEADER, + ] + .map(|name| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + }) + .join("\0"); if !middleware_headers.is_empty() { use sha2::{Digest, Sha256}; diff --git a/backend/crates/providers/openai/src/transport/profile/identity.rs b/backend/crates/providers/openai/src/transport/profile/identity.rs new file mode 100644 index 000000000..ccf7489e4 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/profile/identity.rs @@ -0,0 +1,255 @@ +//! 预设与完整自定义 UA 的统一解析边界,身份配套头由 Provider 所有。 + +use chrono::DateTime; +use gateway_core::account::OpaqueProviderData; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::selection::{ + ClientKind, ClientProfileError, ClientProfileSelection, VersionMode, object, +}; +use super::{CodexWireProfile, CodexWireProfileState}; + +/// 已解析的配置;没有 mode 的预设配置复用官方版本与平台合同。 +#[derive(Debug, Clone)] +pub struct RequestProfileSelection(ParsedSelection); + +#[derive(Debug, Clone)] +enum ParsedSelection { + Preset(ClientProfileSelection), + Custom(ExactIdentity), +} + +#[derive(Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +enum SelectionDocument { + Custom { + #[serde(rename = "userAgent")] + user_agent: String, + originator: Option, + #[serde(rename = "codexVersion")] + codex_version: Option, + }, +} + +impl RequestProfileSelection { + pub fn preset(&self) -> Option<&ClientProfileSelection> { + match &self.0 { + ParsedSelection::Preset(selection) => Some(selection), + _ => None, + } + } + + pub fn version_mode(&self) -> VersionMode { + match &self.0 { + ParsedSelection::Preset(selection) => selection.version_mode, + ParsedSelection::Custom(_) => VersionMode::Fixed, + } + } + + pub fn parse(document: &OpaqueProviderData) -> Result { + let fields = document.expose_to_provider(); + if !fields.contains_key("mode") { + return ClientProfileSelection::parse(document) + .map(ParsedSelection::Preset) + .map(Self); + } + let document = serde_json::from_value(Value::Object(fields.clone())) + .map_err(|_| ClientProfileError::Invalid)?; + let selection = match document { + SelectionDocument::Custom { + user_agent, + originator, + codex_version, + } => ParsedSelection::Custom(ExactIdentity::parse( + &user_agent, + originator.as_deref(), + codex_version.as_deref(), + )?), + }; + Ok(Self(selection)) + } + + pub fn resolve( + &self, + state: &CodexWireProfileState, + ) -> Result { + match &self.0 { + ParsedSelection::Preset(selection) => selection.resolve(state), + ParsedSelection::Custom(identity) => Ok(identity.profile(state)), + } + } + + pub(super) fn preview( + &self, + state: &CodexWireProfileState, + ) -> Result { + match &self.0 { + ParsedSelection::Preset(selection) => state.preview_preset_selection(selection), + ParsedSelection::Custom(identity) => identity.preview(state), + } + } +} + +#[derive(Debug, Clone)] +struct ExactIdentity { + user_agent: String, + originator: String, + codex_version: String, + recognized: bool, + environment: Option, + desktop_version: Option, +} + +impl ExactIdentity { + fn parse( + user_agent: &str, + originator: Option<&str>, + codex_version: Option<&str>, + ) -> Result { + if !safe_header(user_agent, 4096) { + return Err(ClientProfileError::InvalidUserAgent); + } + let known = ["Codex Desktop", "codex-tui", "codex_exec", "codex_cli_rs"] + .into_iter() + .find_map(|name| { + user_agent + .strip_prefix(name) + .and_then(|rest| rest.strip_prefix('/')) + .map(|rest| (name, rest)) + }); + let (originator, codex_version, recognized) = if let Some((name, rest)) = known { + let version = rest.split_once(' ').map_or(rest, |(version, _)| version); + parse_core_version(version)?; + if originator.is_some_and(|value| value != name) + || codex_version.is_some_and(|value| value != version) + { + return Err(ClientProfileError::CompanionHeadersConflict); + } + (name.to_owned(), version.to_owned(), true) + } else { + let originator = originator + .filter(|value| safe_header(value, 128)) + .ok_or(ClientProfileError::CompanionHeadersRequired)?; + let version = codex_version.ok_or(ClientProfileError::CompanionHeadersRequired)?; + parse_core_version(version)?; + (originator.to_owned(), version.to_owned(), false) + }; + let environment = known.and_then(|(_, rest)| UaEnvironment::parse(rest)); + let desktop_version = (originator == "Codex Desktop") + .then(|| { + user_agent + .rsplit_once(" (Codex Desktop; ") + .and_then(|(_, version)| version.strip_suffix(')')) + .filter(|version| super::numeric_dotted_version(version)) + .map(ToOwned::to_owned) + }) + .flatten(); + Ok(Self { + user_agent: user_agent.to_owned(), + originator, + codex_version, + recognized, + environment, + desktop_version, + }) + } + + fn profile(&self, state: &CodexWireProfileState) -> CodexWireProfile { + let environment = self.environment.as_ref(); + CodexWireProfile { + client_kind: if self.originator == "Codex Desktop" { + ClientKind::Desktop + } else { + ClientKind::Cli + }, + originator: self.originator.clone(), + codex_version: self.codex_version.clone(), + desktop_version: self.desktop_version.clone().unwrap_or_default(), + desktop_build: String::new(), + os_type: environment + .map(|value| value.os_type.clone()) + .unwrap_or_default(), + os_version: environment + .map(|value| value.os_version.clone()) + .unwrap_or_default(), + arch: environment + .map(|value| value.arch.clone()) + .unwrap_or_default(), + terminal: environment + .map(|value| value.terminal.clone()) + .unwrap_or_default(), + exact_user_agent: Some(self.user_agent.clone()), + residency: state.snapshot().residency, + verified_at: DateTime::UNIX_EPOCH, + } + } + + fn preview( + &self, + state: &CodexWireProfileState, + ) -> Result { + let profile = self.profile(state); + object(&json!({ + "configuration": { + "mode": "custom", "userAgent": self.user_agent, + "originator": self.originator, "codexVersion": self.codex_version, + }, + "originator": profile.originator, + "osType": profile.os_type, + "osVersion": profile.os_version, + "arch": profile.arch, + "terminal": profile.terminal, + "codexVersion": profile.codex_version, + "desktopVersion": self.desktop_version, + "desktopBuild": null, + "userAgent": profile.user_agent(), + "recognized": self.recognized, + "versionSource": "custom", + "verifiedAt": null, + "checkedAt": null, + "error": null, + })) + } +} + +#[derive(Debug, Clone)] +struct UaEnvironment { + os_type: String, + os_version: String, + arch: String, + terminal: String, +} + +impl UaEnvironment { + fn parse(rest: &str) -> Option { + let (_, rest) = rest.split_once(" (")?; + let (environment, tail) = rest.split_once(") ")?; + let (os, arch) = environment.split_once("; ")?; + let (os_type, os_version) = os.rsplit_once(' ')?; + let terminal = tail.split_once(" (").map_or(tail, |(terminal, _)| terminal); + if os_type.is_empty() || os_version.is_empty() || arch.is_empty() || terminal.is_empty() { + return None; + } + Some(Self { + os_type: os_type.to_owned(), + os_version: os_version.to_owned(), + arch: arch.to_owned(), + terminal: terminal.to_owned(), + }) + } +} + +fn safe_header(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value.trim() == value + && value.bytes().all(|byte| (32..=126).contains(&byte)) +} + +fn parse_core_version(value: &str) -> Result { + if value.len() > 64 { + return Err(ClientProfileError::Invalid); + } + semver::Version::parse(value).map_err(|_| ClientProfileError::Invalid) +} diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index 7d828274e..a6dbbe211 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -22,6 +22,7 @@ use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version pub mod cli_release; pub mod desktop_artifact; +pub mod identity; pub mod platform_release; pub mod selection; @@ -48,16 +49,16 @@ pub enum CodexResidency { /// 保留已有配置类型入口;位置规则由出口领域统一维护。 pub use gateway_core::account::RequestLocation as CodexRequestLocation; -/// Codex Desktop 上游请求身份。 +/// Codex 上游请求身份快照。 /// -/// 内置基线提供经源码审计的 Core、运行环境和 Desktop 版本。运行时只会使用 -/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。 +/// 预设按官方配套版本生成 UA;自定义身份保留完整 UA 及其配套请求头。 +/// 官方制品核验只更新独立的基线与预设资料,不改写已冻结的请求身份。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexWireProfile { pub client_kind: ClientKind, /// `originator` 请求头及 User-Agent 产品名。 pub originator: String, - /// Desktop ZIP 内嵌 Core 版本;用于模型请求的 version、client_version 与 UA。 + /// Core 版本,用于模型请求的 version 和 client_version。 pub codex_version: String, /// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。 pub desktop_version: String, @@ -71,6 +72,9 @@ pub struct CodexWireProfile { pub arch: String, /// Codex Core UA 中的终端标记。 pub terminal: String, + /// 入口专属或自定义的完整值;存在时不得用默认预设重新拼接。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exact_user_agent: Option, /// 未配置时不发送 residency 头;不随制品版本更新而改变。 pub residency: Option, /// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。 @@ -89,6 +93,7 @@ impl Default for CodexWireProfile { os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), terminal: "unknown".to_owned(), + exact_user_agent: None, residency: None, // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。 verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), @@ -97,8 +102,11 @@ impl Default for CodexWireProfile { } impl CodexWireProfile { - /// 按 bundled Core app-server 的官方格式生成最终 User-Agent。 + /// 原样返回已解析的完整 UA;默认预设按 bundled Core app-server 格式生成。 pub fn user_agent(&self) -> String { + if let Some(user_agent) = &self.exact_user_agent { + return user_agent.clone(); + } if self.client_kind == ClientKind::Cli { return format!( "{}/{} ({} {}; {}) {}", diff --git a/backend/crates/providers/openai/src/transport/profile/selection.rs b/backend/crates/providers/openai/src/transport/profile/selection.rs index 4980117a8..37effe41a 100644 --- a/backend/crates/providers/openai/src/transport/profile/selection.rs +++ b/backend/crates/providers/openai/src/transport/profile/selection.rs @@ -14,6 +14,23 @@ pub enum ClientKind { Cli, } +/// CLI 共用官方发布版本,但不同入口提供各自的客户端标识和 UA 后缀。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliEntry { + Tui, + Exec, +} + +impl CliEntry { + pub const fn originator(self) -> &'static str { + match self { + Self::Tui => "codex-tui", + Self::Exec => "codex_exec", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ClientPlatform { @@ -61,7 +78,9 @@ pub struct ClientProfileSelection { pub client: ClientKind, pub platform: ClientPlatform, pub version_mode: VersionMode, + pub cli_entry: Option, pub originator: Option, + pub os_type: Option, pub os_version: Option, pub arch: Option, pub terminal: Option, @@ -76,7 +95,9 @@ impl Default for ClientProfileSelection { client: ClientKind::Desktop, platform: ClientPlatform::Macos, version_mode: VersionMode::Latest, + cli_entry: None, originator: None, + os_type: None, os_version: None, arch: None, terminal: None, @@ -104,9 +125,20 @@ impl ClientProfileSelection { self.arch.as_deref().unwrap_or(self.platform.default_arch()) } + fn default_originator(&self) -> &'static str { + match self.client { + ClientKind::Desktop => "Codex Desktop", + ClientKind::Cli => self.cli_entry.map_or("codex_cli_rs", CliEntry::originator), + } + } + pub(super) fn validate(&self) -> Result<(), ClientProfileError> { + if self.client == ClientKind::Desktop && self.cli_entry.is_some() { + return Err(ClientProfileError::Invalid); + } for value in [ self.originator.as_deref(), + self.os_type.as_deref(), self.os_version.as_deref(), self.arch.as_deref(), self.terminal.as_deref(), @@ -185,19 +217,19 @@ impl ClientProfileSelection { verified_at: None, }, }; - Ok(CodexWireProfile { + let mut profile = CodexWireProfile { client_kind: self.client, originator: self .originator .clone() - .unwrap_or_else(|| match self.client { - ClientKind::Desktop => "Codex Desktop".to_owned(), - ClientKind::Cli => "codex_cli_rs".to_owned(), - }), + .unwrap_or_else(|| self.default_originator().to_owned()), codex_version: release.codex_version, desktop_version: release.desktop_version.unwrap_or_default(), desktop_build: release.desktop_build.unwrap_or_default(), - os_type: self.platform.os_type().to_owned(), + os_type: self + .os_type + .clone() + .unwrap_or_else(|| self.platform.os_type().to_owned()), os_version: self .os_version .clone() @@ -207,9 +239,21 @@ impl ClientProfileSelection { .terminal .clone() .unwrap_or_else(|| "unknown".to_owned()), + exact_user_agent: None, residency: state.snapshot().residency, verified_at: release.verified_at.unwrap_or(DateTime::UNIX_EPOCH), - }) + }; + if let Some(entry) = self.cli_entry { + // 入口后缀使用同一次解析得到的 Core 版本,避免每日更新后头部与后缀混用。 + // originator 可单独覆盖;入口名与官方 clientInfo.name 的语义保持一致。 + profile.exact_user_agent = Some(format!( + "{} ({}; {})", + profile.user_agent(), + entry.originator(), + profile.codex_version, + )); + } + Ok(profile) } } @@ -228,7 +272,13 @@ impl CodexWireProfileState { &self, configuration: &OpaqueProviderData, ) -> Result { - let selection = ClientProfileSelection::parse(configuration)?; + super::identity::RequestProfileSelection::parse(configuration)?.preview(self) + } + + pub(super) fn preview_preset_selection( + &self, + selection: &ClientProfileSelection, + ) -> Result { let profile = selection.resolve(self)?; let status = self.client_release_status( selection.client, @@ -271,7 +321,7 @@ impl CodexWireProfileState { "configuration": configuration, "automaticAvailable": available, "reason": (!available).then_some("暂不支持自动更新"), - "defaults": { "originator": if client == ClientKind::Desktop { "Codex Desktop" } else { "codex_cli_rs" }, "osVersion": platform.default_os_version(), "arch": platform.default_arch(), "terminal": "unknown" }, + "defaults": { "originator": configuration.default_originator(), "osType": platform.os_type(), "osVersion": platform.default_os_version(), "arch": platform.default_arch(), "terminal": "unknown" }, })); } } @@ -290,6 +340,12 @@ pub(crate) fn object(value: &impl Serialize) -> Result CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "catalog-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index 17c85894d..b6b9f09fb 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -162,6 +162,7 @@ fn selector_with_runtime( os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "selector-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: chrono::Utc::now(), }); diff --git a/backend/crates/providers/openai/tests/credential/profile_statistics.rs b/backend/crates/providers/openai/tests/credential/profile_statistics.rs index dfdf71b5d..6db4ee460 100644 --- a/backend/crates/providers/openai/tests/credential/profile_statistics.rs +++ b/backend/crates/providers/openai/tests/credential/profile_statistics.rs @@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "profile-statistics-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 28, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index 82f7ec1b8..8f5f43dbe 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -40,6 +40,7 @@ fn wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "quota-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 97829d563..2a6de4692 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -984,6 +984,7 @@ fn wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "provider-contract".to_owned(), + exact_user_agent: None, residency: None, 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 6fc31963d..a3e1804a8 100644 --- a/backend/crates/providers/openai/tests/transport/catalog.rs +++ b/backend/crates/providers/openai/tests/transport/catalog.rs @@ -419,6 +419,7 @@ fn profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "xterm".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index bfd4c5a0a..2e1f0f98b 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -712,6 +712,96 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la assert_ne!(first_user_agent, second_user_agent); } +#[tokio::test] +async fn custom_core_version_change_reconnects_new_chains_and_keeps_exact_continuations() { + use gateway_core::account::OpaqueProviderData; + use provider_openai::transport::profile::identity::RequestProfileSelection; + + const USER_AGENT: &str = "Custom Agent/test"; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut first = accept_codex_test_websocket_with(stream, |request, _response| { + assert_eq!(request.headers()["user-agent"], USER_AGENT); + assert_eq!(request.headers()["originator"], "custom-agent"); + assert_eq!(request.headers()["version"], "1.2.3"); + }) + .await; + for response_id in ["resp_custom_first", "resp_custom_continued"] { + timeout(Duration::from_secs(2), first.next()) + .await + .expect("exact continuation uses the original connection") + .unwrap() + .unwrap(); + first + .send(Message::Text( + completed_websocket_response(response_id, 1, 1).into(), + )) + .await + .unwrap(); + } + let (stream, _) = timeout(Duration::from_secs(2), listener.accept()) + .await + .expect("a Core version change requires a new connection for a new chain") + .unwrap(); + let mut second = accept_codex_test_websocket_with(stream, |request, _response| { + assert_eq!(request.headers()["user-agent"], USER_AGENT); + assert_eq!(request.headers()["originator"], "custom-agent"); + assert_eq!(request.headers()["version"], "1.2.4"); + }) + .await; + second.next().await.unwrap().unwrap(); + second + .send(Message::Text( + completed_websocket_response("resp_custom_second", 1, 1).into(), + )) + .await + .unwrap(); + }); + + let state = test_wire_profile(); + let client = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{address}"), + state.clone(), + ) + .with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)))); + let with_version = |version| { + let configuration = json!({ + "mode": "custom", + "userAgent": USER_AGENT, + "originator": "custom-agent", + "codexVersion": version, + }); + let profile = RequestProfileSelection::parse(&OpaqueProviderData::new( + configuration.as_object().unwrap().clone(), + )) + .unwrap() + .resolve(&state) + .unwrap(); + client.clone().with_request_profile(profile) + }; + let mut request = codex_request("gpt-test", "", Vec::new()); + request.use_websocket = true; + request.local_conversation_id = Some("custom-profile-rotation".to_owned()); + let context = request_context("req_custom_profile", Some("acct-custom-profile")); + with_version("1.2.3") + .create_response(&request, context) + .await + .unwrap(); + let updated = with_version("1.2.4"); + let mut continuation = request.clone(); + continuation.set_previous_response_id(Some("resp_custom_first".to_owned())); + continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal); + updated + .create_response(&continuation, context) + .await + .unwrap(); + updated.create_response(&request, context).await.unwrap(); + server.await.unwrap(); +} + #[tokio::test] async fn concurrent_request_profiles_emit_independent_http_identities() { use provider_openai::transport::profile::selection::{ @@ -786,3 +876,79 @@ async fn concurrent_request_profiles_emit_independent_http_identities() { ); } } + +#[tokio::test] +async fn tui_and_exec_send_the_same_complete_identity_over_http_and_websocket() { + use provider_openai::transport::profile::selection::{ + CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, + }; + for (entry, name) in [(CliEntry::Tui, "codex-tui"), (CliEntry::Exec, "codex_exec")] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let expected = + format!("{name}/0.157.0 (Alpine Linux 3.24.1; x86_64) unknown ({name}; 0.157.0)"); + let server = tokio::spawn(async move { + let (mut http, _) = listener.accept().await.unwrap(); + let headers = read_http_request(&mut http).await; + assert_eq!( + read_header_value(&headers, "user-agent"), + Some(expected.as_str()) + ); + assert_eq!(read_header_value(&headers, "originator"), Some(name)); + assert_eq!(read_header_value(&headers, "version"), Some("0.157.0")); + write_completed_sse_response(&mut http).await; + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_codex_test_websocket_with(stream, |request, _| { + assert_eq!(request.headers()["user-agent"], expected); + assert_eq!(request.headers()["originator"], name); + assert_eq!(request.headers()["version"], "0.157.0"); + }) + .await; + ws.next().await.unwrap().unwrap(); + ws.send(Message::Text( + completed_websocket_response("resp_cli_entry", 1, 1).into(), + )) + .await + .unwrap(); + }); + let state = + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()); + let profile = ClientProfileSelection { + client: ClientKind::Cli, + platform: ClientPlatform::Linux, + cli_entry: Some(entry), + os_type: Some("Alpine Linux".into()), + os_version: Some("3.24.1".into()), + version_mode: VersionMode::Fixed, + codex_version: Some("0.157.0".into()), + ..Default::default() + } + .resolve(&state) + .unwrap(); + let client = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{address}"), + state, + ) + .with_request_profile(profile); + let mut request = codex_request("gpt-test", "", Vec::new()); + request.force_http_sse = true; + client + .create_response( + &request, + request_context("req_cli_http", Some("acct-fixture")), + ) + .await + .unwrap(); + request.force_http_sse = false; + request.use_websocket = true; + client + .create_response( + &request, + request_context("req_cli_ws", Some("acct-fixture")), + ) + .await + .unwrap(); + server.await.unwrap(); + } +} diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index 842f3ee55..08e6e5cac 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -133,6 +133,7 @@ fn test_wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "transport-test".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc::now(), }) diff --git a/backend/crates/providers/openai/tests/transport/profile/cli_release.rs b/backend/crates/providers/openai/tests/transport/profile/cli_release.rs index 1a9f3d33c..3203e0d2b 100644 --- a/backend/crates/providers/openai/tests/transport/profile/cli_release.rs +++ b/backend/crates/providers/openai/tests/transport/profile/cli_release.rs @@ -31,3 +31,96 @@ fn cli_latest_requires_matching_stable_platform_dependencies() { assert!(parse_cli_release(&serde_json::to_vec(&manifest).unwrap()).is_err()); } } + +#[tokio::test] +async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_identities() { + use std::sync::Arc; + use std::time::Duration; + + use gateway_core::account::OpaqueProviderData; + use gateway_core::provider_ports::{ProviderArtifactProfile, ProviderArtifactProfileCachePort}; + use gateway_core::routing::ProviderKind; + use provider_openai::transport::headers::build_codex_model_headers; + use provider_openai::transport::profile::CodexWireProfileState; + use provider_openai::transport::profile::cli_release::CliReleaseService; + use provider_openai::transport::profile::identity::RequestProfileSelection; + use provider_openai::transport::profile::selection::{ + CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, + }; + + let state = CodexWireProfileState::new(super::wire_profile()); + let cache = Arc::new(super::ArtifactProfiles::default()); + let provider = ProviderKind::new("openai").unwrap(); + let service = CliReleaseService::new(provider.clone(), state.clone(), cache.clone()).unwrap(); + let automatic = ClientProfileSelection { + client: ClientKind::Cli, + platform: ClientPlatform::Linux, + cli_entry: Some(CliEntry::Tui), + os_type: Some("Alpine Linux".into()), + os_version: Some("3.24.1".into()), + terminal: Some("xterm-256color".into()), + ..Default::default() + }; + let frozen = automatic.resolve(&state).unwrap(); + let fixed = ClientProfileSelection { + version_mode: VersionMode::Fixed, + codex_version: Some(frozen.codex_version.clone()), + ..automatic.clone() + }; + let custom = RequestProfileSelection::parse(&OpaqueProviderData::new( + json!({"mode":"custom", "userAgent":frozen.user_agent()}) + .as_object() + .unwrap() + .clone(), + )) + .unwrap(); + + // 复用官方版本服务的恢复入口,模拟每日检查已核验并写入的下一份发布资料。 + cache + .replace_if_newer( + ProviderArtifactProfile::new( + provider.clone(), + "cli-linux-x64".into(), + 157_001, + chrono::Utc::now().into(), + OpaqueProviderData::new(json!({"version":"0.157.0"}).as_object().unwrap().clone()), + ), + Duration::from_secs(86400), + ) + .await + .unwrap(); + service.restore().await; + for (entry, originator) in [(CliEntry::Tui, "codex-tui"), (CliEntry::Exec, "codex_exec")] { + let selection = ClientProfileSelection { + cli_entry: Some(entry), + ..automatic.clone() + }; + let current = selection.resolve(&state).unwrap(); + let headers = build_codex_model_headers(¤t, "Bearer fixture", None).unwrap(); + let expected = format!( + "{originator}/0.157.0 (Alpine Linux 3.24.1; x86_64) xterm-256color ({originator}; 0.157.0)" + ); + assert_eq!(headers["user-agent"], expected); + assert_eq!(headers["originator"], originator); + assert_eq!(headers["version"], "0.157.0"); + } + assert_eq!( + fixed.resolve(&state).unwrap().user_agent(), + frozen.user_agent() + ); + assert_eq!( + custom.resolve(&state).unwrap().user_agent(), + frozen.user_agent() + ); + assert_eq!(frozen.codex_version, "0.155.0"); + + let restarted = CodexWireProfileState::new(super::wire_profile()); + CliReleaseService::new(provider, restarted.clone(), cache) + .unwrap() + .restore() + .await; + assert_eq!( + automatic.resolve(&restarted).unwrap(), + automatic.resolve(&state).unwrap() + ); +} diff --git a/backend/crates/providers/openai/tests/transport/profile/identity.rs b/backend/crates/providers/openai/tests/transport/profile/identity.rs new file mode 100644 index 000000000..4d734dd25 --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/profile/identity.rs @@ -0,0 +1,142 @@ +use gateway_core::account::OpaqueProviderData; +use provider_openai::transport::headers::build_codex_model_headers; +use provider_openai::transport::profile::identity::RequestProfileSelection; +use provider_openai::transport::profile::selection::ClientProfileSelection; +use provider_openai::transport::profile::{ + CodexResidency, CodexWireProfile, CodexWireProfileState, +}; +use serde_json::{Value, json}; + +fn document(value: Value) -> OpaqueProviderData { + OpaqueProviderData::new(value.as_object().unwrap().clone()) +} + +#[test] +fn complete_custom_identities_send_exact_user_agent_and_matching_companion_headers() { + let state = CodexWireProfileState::new(CodexWireProfile { + residency: Some(CodexResidency::Us), + ..Default::default() + }); + for (core, originator, user_agent) in [ + ( + "0.155.0-alpha.16.3", + "Codex Desktop", + "Codex Desktop/0.155.0-alpha.16.3 (Ubuntu 24.4.0; x86_64) unknown (Codex Desktop; 26.917.62051)", + ), + ( + "0.156.1", + "codex-tui", + "codex-tui/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex-tui; 0.156.1)", + ), + ( + "0.156.1", + "codex_exec", + "codex_exec/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex_exec; 0.156.1)", + ), + ] { + let selection = document(json!({ + "mode":"custom", "userAgent":user_agent + })); + let profile = RequestProfileSelection::parse(&selection) + .unwrap() + .resolve(&state) + .unwrap(); + let headers = build_codex_model_headers(&profile, "Bearer fixture", None).unwrap(); + assert_eq!(headers["user-agent"], user_agent); + assert_eq!(headers["originator"], originator); + assert_eq!(headers["version"], core); + assert_eq!(headers["x-openai-internal-codex-residency"], "us"); + assert!(profile.desktop_build.is_empty()); + let frozen: CodexWireProfile = + serde_json::from_value(serde_json::to_value(&profile).unwrap()).unwrap(); + assert_eq!(frozen.user_agent(), user_agent); + } + assert!(state.snapshot().exact_user_agent.is_none()); +} + +#[test] +fn custom_known_prefix_derives_headers_and_unknown_identity_requires_them() { + let state = CodexWireProfileState::new(CodexWireProfile::default()); + for (user_agent, originator, core, recognized) in [ + ( + "codex_cli_rs/0.156.1 (Linux 6.8.0; x86_64) custom-terminal", + "codex_cli_rs", + "0.156.1", + true, + ), + ( + "My Agent/7 (custom environment)", + "my-agent", + "0.156.0", + false, + ), + ] { + let mut configuration = json!({"mode":"custom","userAgent":user_agent}); + if !recognized { + assert!(RequestProfileSelection::parse(&document(configuration.clone())).is_err()); + configuration["originator"] = json!(originator); + configuration["codexVersion"] = json!(core); + } + let configuration = document(configuration); + let profile = RequestProfileSelection::parse(&configuration) + .unwrap() + .resolve(&state) + .unwrap(); + let headers = build_codex_model_headers(&profile, "Bearer fixture", None).unwrap(); + assert_eq!(headers["user-agent"], user_agent); + assert_eq!(headers["originator"], originator); + assert_eq!(headers["version"], core); + let preview = state.preview_selection(&configuration).unwrap(); + assert_eq!(preview.expose_to_provider()["recognized"], recognized); + assert_eq!(preview.expose_to_provider()["versionSource"], "custom"); + assert!(preview.expose_to_provider()["verifiedAt"].is_null()); + } +} + +#[test] +fn raw_identity_rejects_injection_conflicting_headers_and_unknown_modes() { + for configuration in [ + json!({"mode":"custom","userAgent":"codex-tui/0.156.1\r\nx-injected: yes"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1\tterminal"}), + json!({"mode":"custom","userAgent":"自定义UA","originator":"custom","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"x".repeat(4097),"originator":"custom","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1","originator":"codex_exec"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1","codexVersion":"0.155.0"}), + json!({"mode":"custom","userAgent":"codex-tui/latest","originator":"codex-tui","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"custom","originator":"agent\r\nx-injected: yes","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"custom","originator":"agent","codexVersion":"latest"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1","extraHeader":"anything"}), + json!({"mode":"unknown","client":"cli","platform":"linux","versionMode":"latest"}), + json!({"mode":null,"client":"cli","platform":"linux","versionMode":"latest"}), + ] { + assert!( + RequestProfileSelection::parse(&document(configuration.clone())).is_err(), + "{configuration}" + ); + } +} + +#[test] +fn old_configuration_and_old_frozen_profile_keep_their_existing_identity() { + let state = CodexWireProfileState::new(CodexWireProfile::default()); + let legacy = ClientProfileSelection::default(); + let resolved = legacy.resolve(&state).unwrap(); + assert_eq!( + RequestProfileSelection::parse(&legacy.document().unwrap()) + .unwrap() + .resolve(&state) + .unwrap(), + resolved + ); + let old_document = serde_json::to_value(&resolved).unwrap(); + assert!(old_document.get("exact_user_agent").is_none()); + let restored: CodexWireProfile = serde_json::from_value(old_document).unwrap(); + assert_eq!(restored.user_agent(), resolved.user_agent()); + assert_eq!( + state + .preview_selection(&legacy.document().unwrap()) + .unwrap() + .expose_to_provider()["versionSource"], + "official" + ); +} diff --git a/backend/crates/providers/openai/tests/transport/profile/mod.rs b/backend/crates/providers/openai/tests/transport/profile/mod.rs index de52e95ea..25da1c85b 100644 --- a/backend/crates/providers/openai/tests/transport/profile/mod.rs +++ b/backend/crates/providers/openai/tests/transport/profile/mod.rs @@ -17,6 +17,7 @@ use provider_openai::transport::profile::{ mod cli_release; mod desktop_artifact; +mod identity; mod platform_release; mod selection; @@ -148,6 +149,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() { os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), terminal: "unknown".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) @@ -352,6 +354,7 @@ fn wire_profile() -> CodexWireProfile { os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), terminal: "unknown".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/transport/profile/selection.rs b/backend/crates/providers/openai/tests/transport/profile/selection.rs index 7635e68fc..a22f6b841 100644 --- a/backend/crates/providers/openai/tests/transport/profile/selection.rs +++ b/backend/crates/providers/openai/tests/transport/profile/selection.rs @@ -1,6 +1,6 @@ use gateway_core::account::OpaqueProviderData; use provider_openai::transport::profile::selection::{ - ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, + CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, }; use provider_openai::transport::profile::{CodexBundledReleaseProfile, CodexWireProfileState}; use serde_json::json; @@ -92,6 +92,9 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() { for patch in [ json!({"originator":"client\r\nx-injected: yes"}), json!({"terminal":"term; forged"}), + json!({"osType":"Linux; forged"}), + json!({"cliEntry":"unknown"}), + json!({"client":"desktop", "cliEntry":"tui"}), json!({"arch":""}), json!({"codexVersion":"0.155.0"}), json!({"desktopVersion":"26.1.0"}), @@ -106,3 +109,25 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() { assert!(ClientProfileSelection::parse(&OpaqueProviderData::new(fields)).is_err()); } } + +#[test] +fn cli_entry_suffix_keeps_its_name_when_originator_is_overridden() { + let state = CodexWireProfileState::new(super::wire_profile()); + let selection = ClientProfileSelection { + client: ClientKind::Cli, + platform: ClientPlatform::Linux, + cli_entry: Some(CliEntry::Exec), + originator: Some("my-agent".into()), + os_type: Some("Alpine Linux".into()), + os_version: Some("3.24.1".into()), + version_mode: VersionMode::Fixed, + codex_version: Some("0.157.0".into()), + ..Default::default() + }; + let profile = selection.resolve(&state).unwrap(); + assert_eq!( + profile.user_agent(), + "my-agent/0.157.0 (Alpine Linux 3.24.1; x86_64) unknown (codex_exec; 0.157.0)" + ); + assert_eq!(profile.originator, "my-agent"); +} diff --git a/backend/crates/providers/openai/tests/transport/reset_credits.rs b/backend/crates/providers/openai/tests/transport/reset_credits.rs index a3e0ece3c..d57600929 100644 --- a/backend/crates/providers/openai/tests/transport/reset_credits.rs +++ b/backend/crates/providers/openai/tests/transport/reset_credits.rs @@ -28,6 +28,7 @@ fn client(base_url: &str) -> CodexBackendClient { os_version: "15.5.0".to_owned(), arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 20, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index 5c2842e6f..043670729 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -823,6 +823,7 @@ fn profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "xterm".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/docs/api.md b/docs/api.md index 39d94923b..02f59faf0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1261,21 +1261,32 @@ models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI 该配置作用于 Client Key 的 OpenAI 模型请求与原生模型目录,适用于 HTTP/SSE、WebSocket、Images 和 Search。 不改变 xAI、入站客户端版本门禁、账号认证或后台 Desktop 专属操作。 -身份对象字段如下,可选字段省略或 `null` 时使用所选预设参数: +自定义配置使用 `{ "mode": "custom", "userAgent": "完整 UA" }`。 +已识别的 `Codex Desktop`、`codex-tui`、`codex_exec`、`codex_cli_rs` 前缀由后端解析 `originator` 和 Core `version`, +显式提供的配套字段必须与识别结果一致。未知前缀须另填 `originator` 和 `codexVersion`,这两个字段与 UA 一起发送。 +UA 须为 1 至 4096 字节的单行可见 ASCII 文本,首尾不能含空白;`originator` 最多 128 字节, +`codexVersion` 最多 64 字节并须符合 SemVer。自定义配置不要求 Desktop 构建号,也不自动更新。 + +没有 `mode` 字段的预设配置按以下合同解析,可选字段省略或 `null` 时使用所选预设参数: | 字段 | 取值与语义 | | --- | --- | | `client` | 必填,`desktop` 或 `cli` | | `platform` | 必填,`macos`、`linux` 或 `windows` | | `versionMode` | 必填,`latest` 或 `fixed` | -| `originator`、`osVersion`、`arch`、`terminal` | 可选自定义参数,非空、最多 128 字节;只接受可见 ASCII,不能包含括号、分号、反斜杠及首尾空白 | +| `cliEntry` | CLI 可选 `tui` 或 `exec`,省略或 `null` 保留 Core 默认身份;Desktop 不接受此字段 | +| `originator`、`osType`、`osVersion`、`arch`、`terminal` | 可选自定义参数,非空、最多 128 字节;只接受可见 ASCII,不能包含括号、分号、反斜杠及首尾空白 | | `codexVersion` | `fixed` 必填的 Core SemVer;`latest` 必须省略或为 `null` | | `desktopVersion`、`desktopBuild` | 仅 Desktop 的 `fixed` 模式必填,分别为数字点分版本和数字构建号;CLI 不接受这些字段 | ```json -{ "client": "cli", "platform": "linux", "versionMode": "latest" } +{ "client": "cli", "platform": "linux", "versionMode": "latest", "cliEntry": "tui", "osType": "Alpine Linux", "osVersion": "3.24.1", "terminal": "xterm-256color" } ``` +TUI 默认标识为 `codex-tui`,Exec 为 `codex_exec`,入口后缀使用同一次解析的 Core 版本。 +`originator` 覆盖只更改产品名前缀和配套头,后缀仍表示所选入口。省略 `osType` 使用平台名称; +自定义运行环境在自动更新时保持不变。旧配置未指定 `cliEntry` 时继续使用 `codex_cli_rs` 默认值且不添加入口后缀。 + 六套预设均支持自动更新:macOS Desktop 支持 arm64,Windows/Linux Desktop 及三套 CLI 支持 arm64、x86_64。 预设接口的 `automaticAvailable`、`reason` 表示当前组合的可用性;自定义架构可能使自动解析不可用。 每 24 小时后台检查官方稳定发布,失败保留同组合上次有效版本;固定值不受后台更新影响。 @@ -1284,7 +1295,9 @@ Windows/Linux 通过 ETag 检查更新,未变化时复用已核验版本;CLI 预览返回 `configuration`、`source`(`global` / `override`)、`userAgent`、解析后的环境和版本字段, 以及 `versionSource`(`official` / `custom`)、`verifiedAt`、`checkedAt`、`error`。 +自定义预览中的 `recognized` 表示是否识别出配套请求头。 `verifiedAt` 只表示版本资料核验,不能代表自定义运行环境或 TLS 已核验;固定版本返回 `null`。 +完整自定义配置不携带官方制品核验时间。 客户端画像配置控制应用层请求字段,不切换操作系统的 TLS 实现。默认 HTTP 使用 native TLS, WebSocket 使用 rustls;配置自定义 CA 时 HTTP 也使用 rustls。TLS 指纹需按实际部署平台与传输路径核验。 未完成本次启动检查时 `checkedAt` 为 `null`。非法或当前不可用的选择返回 `400`,保存失败不提交其他修改。 diff --git a/docs/architecture.md b/docs/architecture.md index 2e375f643..a3baaee25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,7 +326,10 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 各 Provider 唯一负责默认值、校验、版本来源及 UA 生成,Admin 提供管理和生效预览。 首次初始化只写入内置默认选择;YAML 不定义客户端身份,也不作为数据库初始化或请求解析的来源。 官方发布资料与用户选择分开:OpenAI 在 Redis 按 Provider、客户端、平台、架构隔离可重建版本缓存, - Desktop 完整制品元组原子更新,固定配置不被刷新覆盖。普通连接按已有身份键匹配,精确续写保留原连接。 + Desktop 完整制品元组原子更新。CLI 的 TUI/Exec 入口共用官方 CLI 版本资料,Provider 在一次身份解析中 + 生成一致的 UA、入口后缀与配套请求头,后台更新不修改用户选择的运行环境。 + 完整自定义 UA 经 Provider 校验后原样传递,配套头统一解析;固定和自定义配置不受后台更新影响。 + 普通连接按已有身份键匹配,精确续写保留原连接。 后台账号和 Desktop 专属操作使用独立官方 Desktop 画像,不接受 Key 覆盖。 xAI 以内置画像为版本检查基线,在进程内更新 Grok CLI 发布资料;模型与压缩请求使用已保存的用户选择, OAuth、后台目录和额度查询使用内置官方画像。两者均不回写 `config.yaml`。 diff --git a/frontend/src/api/modules/client-profiles.ts b/frontend/src/api/modules/client-profiles.ts index 8b3b45644..29ce291b2 100644 --- a/frontend/src/api/modules/client-profiles.ts +++ b/frontend/src/api/modules/client-profiles.ts @@ -4,10 +4,13 @@ export type ProviderRequestProfile = Record export type ProviderRequestProfiles = Record export type ProviderRequestProfileUpdates = Record -export interface ClientProfileSelection { +export interface PresetClientProfileSelection { + mode?: undefined client: 'desktop' | 'cli' platform: 'macos' | 'linux' | 'windows' versionMode: 'latest' | 'fixed' + cliEntry?: 'tui' | 'exec' | null + osType?: string | null originator: string | null osVersion: string | null arch: string | null @@ -17,6 +20,20 @@ export interface ClientProfileSelection { desktopBuild: string | null } +export interface CustomClientProfileSelection { + mode: 'custom' + userAgent: string + originator?: string | null + codexVersion?: string | null +} + +export type ClientProfileSelection = PresetClientProfileSelection | CustomClientProfileSelection + +export interface ClientProfileOptions { + presets: ClientProfilePreset[] + globalConfiguration: ClientProfileSelection +} + export interface ClientProfilePreview { configuration: ClientProfileSelection source: 'global' | 'override' @@ -30,20 +47,21 @@ export interface ClientProfilePreview { desktopBuild: string | null userAgent: string versionSource: 'official' | 'custom' + recognized?: boolean verifiedAt: string | null checkedAt: string | null error: string | null } export interface ClientProfilePreset { - configuration: ClientProfileSelection + configuration: PresetClientProfileSelection automaticAvailable: boolean reason: string | null - defaults: Pick + defaults: Pick } export function getClientProfileOptions() { - return request<{ presets: ClientProfilePreset[], globalConfiguration: ClientProfileSelection }>({ + return request({ url: '/api/admin/settings/client-profiles/openai', method: 'GET', silent: true, diff --git a/frontend/src/components/client-profile/ClientProfileEditor.vue b/frontend/src/components/client-profile/ClientProfileEditor.vue index ff63fa4cc..ab0b2758f 100644 --- a/frontend/src/components/client-profile/ClientProfileEditor.vue +++ b/frontend/src/components/client-profile/ClientProfileEditor.vue @@ -1,9 +1,14 @@ diff --git a/frontend/src/components/client-profile/ClientProfilePresetFields.vue b/frontend/src/components/client-profile/ClientProfilePresetFields.vue new file mode 100644 index 000000000..5916b79ce --- /dev/null +++ b/frontend/src/components/client-profile/ClientProfilePresetFields.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue index 08df566e8..6f5f0ee8f 100644 --- a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue +++ b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue @@ -12,30 +12,35 @@ defineProps<{