diff --git a/backend/crates/gateway-core/src/provider_ports/runtime_policy.rs b/backend/crates/gateway-core/src/provider_ports/runtime_policy.rs index 2f13a523b..3c7de220d 100644 --- a/backend/crates/gateway-core/src/provider_ports/runtime_policy.rs +++ b/backend/crates/gateway-core/src/provider_ports/runtime_policy.rs @@ -5,6 +5,14 @@ use crate::{account::OpaqueProviderData, identity::ProviderKind, routing::Config use futures::future::BoxFuture; pub trait ProviderRuntimePolicyPort: Send + Sync { + /// 后台模型操作读取 Provider 全局画像,不读取单个 Client Key 的覆盖 + fn load_request_profile<'a>( + &'a self, + _provider: &'a ProviderKind, + ) -> BoxFuture<'a, Result, ProviderStoreError>> { + Box::pin(async { Ok(None) }) + } + /// 原子推进预热执行游标,重启或时钟回拨后不重复领取已消费的时刻 fn claim_warmup_slot<'a>( &'a self, diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 754020006..1cc7f0ec7 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -1247,13 +1247,13 @@ impl RuntimeSnapshot { operation: &Operation, context: &RoutingContext, ) -> Result { - self.plan_inner( - public_model, - operation, - self.all_account_scope(), - context, - false, - ) + // 探测没有 Client Key,沿用同一发布快照的全局画像 + let scope = self + .all_account_scope() + .as_ref() + .clone() + .with_request_profiles(self.settings().request_profiles().clone()); + self.plan_inner(public_model, operation, Arc::new(scope), context, false) } fn plan_inner( diff --git a/backend/crates/gateway-core/tests/engine/execution/mod.rs b/backend/crates/gateway-core/tests/engine/execution/mod.rs index dbd0e350a..5ed974046 100644 --- a/backend/crates/gateway-core/tests/engine/execution/mod.rs +++ b/backend/crates/gateway-core/tests/engine/execution/mod.rs @@ -3443,7 +3443,7 @@ fn provider_local_probe_failure_should_remain_distinct_from_upstream() { } #[test] -fn diagnostic_probe_does_not_apply_data_plane_account_model_policy() { +fn diagnostic_probe_uses_global_profile_without_data_plane_account_model_policy() { let provider = ProviderKind::new("openai").expect("provider"); let account_id = ProviderAccountId::new("acct_probe").expect("account"); let snapshot = probe_snapshot().with_account_directory(Arc::new(RuntimeAccountDirectory::new( @@ -3458,10 +3458,21 @@ fn diagnostic_probe_does_not_apply_data_plane_account_model_policy() { ), )]), ))); + let profile = gateway_core::account::OpaqueProviderData::new( + json!({"identity":"global-probe"}) + .as_object() + .unwrap() + .clone(), + ); + let settings = snapshot + .settings() + .clone() + .with_request_profiles(BTreeMap::from([(provider.clone(), profile.clone())])); + let snapshot = snapshot.with_settings(&settings).unwrap(); let service = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(TrackingExecutionStore::default()), - ProviderRegistry::new([Arc::new(LocalFailingProvider) as Arc]) + ProviderRegistry::new([Arc::new(ProfileCheckingProvider(profile)) as Arc]) .expect("providers"), Arc::new(UnusedAdmissions), Arc::new(UnusedContinuation), @@ -3862,6 +3873,36 @@ impl Provider for FailingProvider { struct LocalFailingProvider; +struct ProfileCheckingProvider(gateway_core::account::OpaqueProviderData); + +#[async_trait] +impl Provider for ProfileCheckingProvider { + fn name(&self) -> &'static str { + "openai" + } + + fn catalog_generation(&self) -> ProviderCatalogGeneration { + ProviderCatalogGeneration::default() + } + + async fn query_model_capabilities( + &self, + ) -> Result, ProviderError> { + Ok(Vec::new()) + } + + async fn execute( + self: Arc, + request: ProviderRequest, + context: AttemptContext, + ) -> Result { + assert_eq!(context.request_profile(), Some(&self.0)); + Arc::new(LocalFailingProvider) + .execute(request, context) + .await + } +} + #[async_trait] impl Provider for LocalFailingProvider { fn name(&self) -> &'static str { diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index 07ad3894f..f88f020b2 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -195,6 +195,30 @@ impl ProviderRuntimePolicyPort for PgRuntimeSettingsRepository { Ok(claimed) }) } + fn load_request_profile<'a>( + &'a self, + provider: &'a gateway_core::routing::ProviderKind, + ) -> futures::future::BoxFuture< + 'a, + Result, ProviderStoreError>, + > { + Box::pin(async move { + let document = sqlx::query_scalar::< + _, + Option>>, + >( + "select provider_request_profiles_json -> $1 from runtime_settings where id = 1", + ) + .bind(provider.as_str()) + .fetch_one(&self.pool) + .await + .map_err(|source| { + crate::provider_unavailable("load Provider request profile", source) + })?; + Ok(document.map(|document| gateway_core::account::OpaqueProviderData::new(document.0))) + }) + } + fn initialize_request_profile<'a>( &'a self, provider: &'a gateway_core::routing::ProviderKind, diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 4efec51db..1c888c823 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -1015,6 +1015,13 @@ async fn request_profile_projection_is_revision_consistent_and_includes_key_over }; let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); let provider = ProviderKind::new("xai").unwrap(); + assert!( + repository + .load_request_profile(&provider) + .await + .unwrap() + .is_none() + ); let document = |marker: &str| { OpaqueProviderData::new( serde_json::json!({"marker":marker}) @@ -1056,6 +1063,10 @@ async fn request_profile_projection_is_revision_consistent_and_includes_key_over assert_eq!(profiles.len(), 2); assert!(profiles.contains(&document("global"))); assert!(profiles.contains(&document("key"))); + assert_eq!( + repository.load_request_profile(&provider).await.unwrap(), + Some(document("global")) + ); repository .update_runtime_settings(settings_with_margin(7200)) diff --git a/backend/crates/providers/openai/src/admin/mod.rs b/backend/crates/providers/openai/src/admin/mod.rs index 8f3c4ef8a..ee0a7bf04 100644 --- a/backend/crates/providers/openai/src/admin/mod.rs +++ b/backend/crates/providers/openai/src/admin/mod.rs @@ -1957,6 +1957,11 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError { use CodexCredentialQuotaError as Error; use ProviderAdminErrorKind as Kind; let (kind, public_message, upstream_message) = match error { + Error::Profile(source) => { + return provider_admin_error(Kind::Unavailable) + .with_public_message("OpenAI 账号预热客户端身份不可用") + .with_source(source); + } Error::InvalidCredentialData => ( Kind::Invalid, "OpenAI 额度查询凭据无效,请检查账号授权", @@ -2131,6 +2136,11 @@ fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError { "OpenAI 模型查询失败,请检查出站连接与上游服务", ), Error::Cache => (Kind::Unavailable, "OpenAI 模型目录缓存暂不可用"), + Error::Profile(source) => { + return provider_admin_error(Kind::Unavailable) + .with_public_message("OpenAI 模型目录客户端身份不可用") + .with_source(source); + } }; provider_admin_error(kind).with_public_message(message) } diff --git a/backend/crates/providers/openai/src/credential/catalog/mod.rs b/backend/crates/providers/openai/src/credential/catalog/mod.rs index cff9ae85e..2479bf8d1 100644 --- a/backend/crates/providers/openai/src/credential/catalog/mod.rs +++ b/backend/crates/providers/openai/src/credential/catalog/mod.rs @@ -15,6 +15,7 @@ use gateway_core::account::{ }; use gateway_core::provider_ports::{ ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogScope, + ProviderRuntimePolicyPort, }; use gateway_core::routing::{ FrozenAccountScope, ModelPresentation, ProviderCatalogGeneration, ProviderKind, @@ -243,6 +244,8 @@ pub enum CodexCredentialCatalogError { InvalidEtag, #[error("Codex model catalog changed during refresh")] ConcurrentUpdate, + #[error("Codex model catalog client profile is unavailable")] + Profile(#[source] gateway_core::error::ErrorSource), } #[derive(Default)] @@ -273,6 +276,7 @@ pub struct CodexCredentialCatalogService { http: reqwest::Client, base_url: String, account_catalog_cache: Arc, + runtime_policy: Arc, cache: Arc>, refresh_notification: Arc, refresh_lock: Arc>, @@ -286,6 +290,7 @@ impl CodexCredentialCatalogService { http: reqwest::Client, base_url: String, account_catalog_cache: Arc, + runtime_policy: Arc, ) -> Self { Self { repository, @@ -293,6 +298,7 @@ impl CodexCredentialCatalogService { http, base_url, account_catalog_cache, + runtime_policy, cache: Arc::new(RwLock::new(CatalogCacheState::default())), refresh_notification: Arc::new(Notify::new()), refresh_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -300,6 +306,21 @@ impl CodexCredentialCatalogService { } } + /// 每轮发现冻结一次全局画像,使目录的版本参数与请求头使用同一身份 + async fn discovery_client(&self) -> Result { + let profile = self + .profile + .resolve_global_request_profile(self.runtime_policy.as_ref()) + .await + .map_err(CodexCredentialCatalogError::Profile)?; + let client = CodexBackendClient::new( + self.http.clone(), + self.base_url.clone(), + self.profile.clone(), + ); + Ok(client.with_request_profile(profile)) + } + /// 原生条目取自后台发现的账号目录;用当前账号版本和冻结的 Key 政策双重过滤 pub async fn client_model_catalog( &self, @@ -500,11 +521,7 @@ impl CodexCredentialCatalogService { .filter(|account| account.provider().as_str() == "openai") .ok_or(CodexCredentialCatalogError::NoEligibleCredential)?; let scope = CodexCatalogScope::for_account(&account); - let client = CodexBackendClient::new( - self.http.clone(), - self.base_url.clone(), - self.profile.clone(), - ); + let client = self.discovery_client().await?; let revision = self.state_revision()?; let (catalog, entry) = self .fetch_observation(&client, &account, &scope, None) @@ -573,11 +590,7 @@ impl CodexCredentialCatalogService { &self, account: &ProviderAccount, ) -> Result<(Vec, SystemTime), CodexCredentialCatalogError> { - let client = CodexBackendClient::new( - self.http.clone(), - self.base_url.clone(), - self.profile.clone(), - ); + let client = self.discovery_client().await?; let fetched = self.fetch_account_models(&client, account).await?; if fetched.models.is_empty() { return Err(CodexCredentialCatalogError::NoEligibleCredential); diff --git a/backend/crates/providers/openai/src/credential/catalog/refresh.rs b/backend/crates/providers/openai/src/credential/catalog/refresh.rs index 66ccb4697..ae08b840e 100644 --- a/backend/crates/providers/openai/src/credential/catalog/refresh.rs +++ b/backend/crates/providers/openai/src/credential/catalog/refresh.rs @@ -289,11 +289,7 @@ impl CodexCredentialCatalogService { .cached()? .ok_or(CodexCredentialCatalogError::NoEligibleCredential); } - let client = CodexBackendClient::new( - self.http.clone(), - self.base_url.clone(), - self.profile.clone(), - ); + let client = self.discovery_client().await?; let mut pace = tokio::time::interval(REFRESH_SPACING); pace.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let starts = stream::unfold(pace, |mut clock| async move { diff --git a/backend/crates/providers/openai/src/credential/quota/mod.rs b/backend/crates/providers/openai/src/credential/quota/mod.rs index 673ebee2f..a7d6ab4ea 100644 --- a/backend/crates/providers/openai/src/credential/quota/mod.rs +++ b/backend/crates/providers/openai/src/credential/quota/mod.rs @@ -140,6 +140,8 @@ pub struct CodexWarmupSummary { #[derive(Debug, Error)] pub enum CodexCredentialQuotaError { + #[error("Codex warmup client profile is unavailable")] + Profile(#[source] gateway_core::error::ErrorSource), #[error("Codex quota response is invalid")] InvalidCredentialData, #[error("Codex OAuth access token must be refreshed before querying quota")] diff --git a/backend/crates/providers/openai/src/credential/quota/warmup.rs b/backend/crates/providers/openai/src/credential/quota/warmup.rs index 94ee59611..b8718d243 100644 --- a/backend/crates/providers/openai/src/credential/quota/warmup.rs +++ b/backend/crates/providers/openai/src/credential/quota/warmup.rs @@ -140,11 +140,17 @@ impl CodexCredentialQuotaService { }) .collect::>(); + let profile = self + .profile + .resolve_global_request_profile(self.runtime_policy.as_ref()) + .await + .map_err(CodexCredentialQuotaError::Profile)?; let client = CodexBackendClient::new( self.http.clone(), self.base_url.clone(), self.profile.clone(), - ); + ) + .with_request_profile(profile); for account in accounts { if account.quota().is_exhausted() { diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 28ea1437f..4aef89b19 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -152,6 +152,7 @@ pub async fn initialize( http.clone(), config.base_url().to_owned(), ports.catalog_cache(), + ports.runtime_policy(), )); if let Err(error) = catalog.restore().await { tracing::warn!(%error,"OpenAI model catalog could not be restored"); diff --git a/backend/crates/providers/openai/src/transport/client_catalog.rs b/backend/crates/providers/openai/src/transport/client_catalog.rs index 691c8bd65..cd11fd620 100644 --- a/backend/crates/providers/openai/src/transport/client_catalog.rs +++ b/backend/crates/providers/openai/src/transport/client_catalog.rs @@ -15,7 +15,7 @@ use super::{ }; impl CodexBackendClient { - /// 客户端目录按调用方版本协商;后台目录仍使用经过核验的服务端画像版本 + /// 目录优先使用显式协商版本,否则沿用调用方冻结的画像版本 pub async fn fetch_models_with_context( &self, context: CodexRequestContext<'_>, diff --git a/backend/crates/providers/openai/src/transport/profile/baseline.rs b/backend/crates/providers/openai/src/transport/profile/baseline.rs new file mode 100644 index 000000000..13c313fcf --- /dev/null +++ b/backend/crates/providers/openai/src/transport/profile/baseline.rs @@ -0,0 +1,117 @@ +//! OpenAI 客户端的离线版本、制品身份与预设环境默认值 +//! +//! 更新时一并核验配套版本、构建号、时间及各架构制品身份 +//! 各平台分别核验,版本相同不表示可以借用另一平台的资料 + +use chrono::{DateTime, Utc}; + +pub(super) const DESKTOP_ORIGINATOR: &str = "Codex Desktop"; +pub(super) const CLI_ORIGINATOR: &str = "codex_cli_rs"; +pub(super) const TUI_ORIGINATOR: &str = "codex-tui"; +pub(super) const EXEC_ORIGINATOR: &str = "codex_exec"; +pub(super) const TERMINAL: &str = "unknown"; + +pub(super) struct Environment { + pub os_type: &'static str, + pub os_version: &'static str, + pub arch: &'static str, +} + +pub(super) const MACOS_ENVIRONMENT: Environment = Environment { + os_type: "Mac OS", + os_version: "15.7.1", + arch: "arm64", +}; +pub(super) const LINUX_ENVIRONMENT: Environment = Environment { + os_type: "Linux", + os_version: "6.8.0", + arch: "x86_64", +}; +pub(super) const WINDOWS_ENVIRONMENT: Environment = Environment { + os_type: "Windows", + os_version: "10.0.26100", + arch: "x86_64", +}; + +pub(super) struct CoreRelease { + pub version: &'static str, + pub verified_at_millis: i64, +} + +impl CoreRelease { + pub fn verified_at(&self) -> DateTime { + DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(self.verified_at_millis) + } +} + +pub(super) struct DesktopRelease { + pub core: CoreRelease, + pub version: &'static str, + pub build: &'static str, +} + +pub(super) struct DesktopArtifact { + pub arch: &'static str, + pub size: u64, + pub etag: &'static str, +} + +// 官方 npm 稳定标签及六个平台依赖;核验于 2026-11-14T02:09:52Z +pub(super) const CLI_RELEASE: CoreRelease = CoreRelease { + version: "0.169.0", + verified_at_millis: 1_794_622_192_000, +}; + +// macOS arm64 ZIP;核验于 2026-11-14T02:09:52Z +pub(super) const MACOS_RELEASE: DesktopRelease = DesktopRelease { + core: CoreRelease { + version: "0.169.0-alpha.11.1", + verified_at_millis: 1_794_622_192_000, + }, + version: "26.1111.21308", + build: "27194", +}; + +// Windows 两种架构的 MSIX;核验于 2026-11-14T02:09:52Z +pub(super) const WINDOWS_RELEASE: DesktopRelease = DesktopRelease { + core: CoreRelease { + version: "0.167.0-alpha.4.1", + verified_at_millis: 1_794_622_192_000, + }, + version: "26.1106.51428", + build: "26605", +}; +pub(super) const WINDOWS_ARTIFACTS: [DesktopArtifact; 2] = [ + DesktopArtifact { + arch: "x86_64", + size: 1_057_680_859, + etag: "\"0x8DF3FEB5404E49A\"", + }, + DesktopArtifact { + arch: "arm64", + size: 1_158_775_140, + etag: "\"0x8DF3FEB5ABF50ED\"", + }, +]; + +// Linux 两种架构的 DEB;核验于 2026-11-14T02:09:52Z +pub(super) const LINUX_RELEASE: DesktopRelease = DesktopRelease { + core: CoreRelease { + version: "0.169.0-alpha.11.1", + verified_at_millis: 1_794_622_192_000, + }, + version: "26.1111.21308", + build: "27194", +}; +pub(super) const LINUX_ARTIFACTS: [DesktopArtifact; 2] = [ + DesktopArtifact { + arch: "x86_64", + size: 567_877_422, + etag: "\"0x8DF4126A0871331\"", + }, + DesktopArtifact { + arch: "arm64", + size: 553_033_238, + etag: "\"0x8DF41269D464E6A\"", + }, +]; diff --git a/backend/crates/providers/openai/src/transport/profile/cli_release.rs b/backend/crates/providers/openai/src/transport/profile/cli_release.rs index 50075d470..4493f50c9 100644 --- a/backend/crates/providers/openai/src/transport/profile/cli_release.rs +++ b/backend/crates/providers/openai/src/transport/profile/cli_release.rs @@ -12,7 +12,7 @@ use reqwest::{Client, redirect::Policy}; use serde::Deserialize; use super::selection::{ClientKind, ClientPlatform, ClientRelease, object}; -use super::{ARTIFACT_PROFILE_CACHE_TTL, CodexWireProfileState}; +use super::{ARTIFACT_PROFILE_CACHE_TTL, CodexWireProfileState, baseline}; const ENDPOINT: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest"; const MAX_BYTES: usize = 256 * 1024; @@ -218,19 +218,16 @@ impl CliReleaseService { } pub(super) fn seed_releases(state: &CodexWireProfileState) { - // 2026-09-18 核对官方 npm latest 及六个平台依赖;这里只提供离线启动资料 for (platform, arch, _) in TARGETS { state.seed_client_release( ClientKind::Cli, platform, arch, ClientRelease { - codex_version: "0.155.0".to_owned(), + codex_version: baseline::CLI_RELEASE.version.to_owned(), desktop_version: None, desktop_build: None, - verified_at: chrono::DateTime::parse_from_rfc3339("2026-09-18T00:00:00Z") - .ok() - .map(|time| time.with_timezone(&Utc)), + verified_at: Some(baseline::CLI_RELEASE.verified_at()), }, ); } diff --git a/backend/crates/providers/openai/src/transport/profile/identity.rs b/backend/crates/providers/openai/src/transport/profile/identity.rs index 9970310a6..c0761c72a 100644 --- a/backend/crates/providers/openai/src/transport/profile/identity.rs +++ b/backend/crates/providers/openai/src/transport/profile/identity.rs @@ -2,6 +2,9 @@ use chrono::DateTime; use gateway_core::account::OpaqueProviderData; +use gateway_core::error::ErrorSource; +use gateway_core::provider_ports::ProviderRuntimePolicyPort; +use gateway_core::routing::ProviderKind; use serde::Deserialize; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -11,6 +14,26 @@ use super::selection::{ }; use super::{CodexWireProfile, CodexWireProfileState}; +impl CodexWireProfileState { + /// 后台模型操作没有 Client Key,按当前全局选择冻结整套身份 + pub(crate) async fn resolve_global_request_profile( + &self, + policy: &dyn ProviderRuntimePolicyPort, + ) -> Result { + let provider = ProviderKind::new("openai").map_err(ErrorSource::new)?; + let Some(configuration) = policy + .load_request_profile(&provider) + .await + .map_err(ErrorSource::new)? + else { + return Ok(self.snapshot()); + }; + RequestProfileSelection::parse(&configuration) + .and_then(|selection| selection.resolve(self)) + .map_err(ErrorSource::new) + } +} + /// 已解析的配置;没有 mode 的预设配置复用官方版本与平台合同 #[derive(Debug, Clone)] pub struct RequestProfileSelection { diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index 8dfcee65d..d9c4aef24 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -20,6 +20,7 @@ use url::Url; use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version}; +mod baseline; pub mod cli_release; mod context; pub mod desktop_artifact; @@ -82,22 +83,23 @@ pub struct CodexWireProfile { impl Default for CodexWireProfile { fn default() -> Self { + let release = baseline::MACOS_RELEASE; + let environment = baseline::MACOS_ENVIRONMENT; Self { client_kind: ClientKind::Desktop, - originator: "Codex Desktop".to_owned(), - codex_version: "0.153.4".to_owned(), - desktop_version: "26.901.51231".to_owned(), - desktop_build: "8109".to_owned(), - os_type: "Mac OS".to_owned(), - os_version: "15.7.1".to_owned(), - arch: "arm64".to_owned(), - terminal: "unknown".to_owned(), + originator: baseline::DESKTOP_ORIGINATOR.to_owned(), + codex_version: release.core.version.to_owned(), + desktop_version: release.version.to_owned(), + desktop_build: release.build.to_owned(), + os_type: environment.os_type.to_owned(), + os_version: environment.os_version.to_owned(), + arch: environment.arch.to_owned(), + terminal: baseline::TERMINAL.to_owned(), exact_user_agent: None, installation_seed: None, residency: None, account_routing_override: None, - // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验 - verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), + verified_at: release.core.verified_at(), } } } diff --git a/backend/crates/providers/openai/src/transport/profile/platform_release/mod.rs b/backend/crates/providers/openai/src/transport/profile/platform_release/mod.rs index 1360d9892..ffb78cfef 100644 --- a/backend/crates/providers/openai/src/transport/profile/platform_release/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/platform_release/mod.rs @@ -3,7 +3,7 @@ use super::selection::{ ClientKind, ClientPlatform, ClientProfileSelection, ClientRelease, VersionMode, object, }; -use super::{ARTIFACT_PROFILE_CACHE_TTL, CodexWireProfileState}; +use super::{ARTIFACT_PROFILE_CACHE_TTL, CodexWireProfileState, baseline}; use chrono::Utc; use futures::future::BoxFuture; use gateway_core::provider_ports::{ProviderArtifactProfile, ProviderArtifactProfileCachePort}; @@ -280,43 +280,44 @@ fn sequence(target: DesktopTarget, release: &ClientRelease) -> io::Result { } fn baseline_artifacts() -> BTreeMap { - // 2026-09-18 分别读取四个官方安装包,版本元组与 ETag 均来自同一个包 - // 离线可直接选择自动模式;ETag 变化后必须重新核验,不能延用另一平台的版本 - let identities = [ - (774_919_598, "\"0x8DF141FFDF86290\""), - (771_727_321, "\"0x8DF14202C49201F\""), - (417_337_930, "\"0x8DF150AE1091545\""), - (395_993_890, "\"0x8DF150ADC810C82\""), - ]; - TARGETS - .into_iter() - .zip(identities) - .map(|(target, (size, etag))| { - let (core, version, build) = if target.platform == ClientPlatform::Windows { - ("0.154.0-alpha.6.2", "26.908.70816", "9275") - } else { - ("0.155.0-alpha.9", "26.915.31029", "9771") - }; + [ + ( + ClientPlatform::Windows, + baseline::WINDOWS_RELEASE, + baseline::WINDOWS_ARTIFACTS, + ), + ( + ClientPlatform::Linux, + baseline::LINUX_RELEASE, + baseline::LINUX_ARTIFACTS, + ), + ] + .into_iter() + .flat_map(|(platform, release, artifacts)| { + artifacts.into_iter().map(move |artifact| { ( - target, + DesktopTarget { + platform, + arch: artifact.arch, + }, VerifiedArtifact { - identity: ArtifactIdentity { - size, - etag: etag.to_owned(), - }, release: ClientRelease { - codex_version: core.to_owned(), - desktop_version: Some(version.to_owned()), - desktop_build: Some(build.to_owned()), - verified_at: chrono::DateTime::parse_from_rfc3339("2026-09-18T00:00:00Z") - .ok() - .map(|time| time.with_timezone(&Utc)), + codex_version: release.core.version.to_owned(), + desktop_version: Some(release.version.to_owned()), + desktop_build: Some(release.build.to_owned()), + verified_at: Some(release.core.verified_at()), + }, + identity: ArtifactIdentity { + size: artifact.size, + etag: artifact.etag.to_owned(), }, }, ) }) - .collect() + }) + .collect() } + pub(super) fn seed_releases(state: &CodexWireProfileState) { for (target, artifact) in baseline_artifacts() { state.seed_client_release( diff --git a/backend/crates/providers/openai/src/transport/profile/selection.rs b/backend/crates/providers/openai/src/transport/profile/selection.rs index e9772a0cc..db6d4caff 100644 --- a/backend/crates/providers/openai/src/transport/profile/selection.rs +++ b/backend/crates/providers/openai/src/transport/profile/selection.rs @@ -5,7 +5,7 @@ use gateway_core::account::OpaqueProviderData; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use super::{CodexWireProfile, CodexWireProfileState}; +use super::{CodexWireProfile, CodexWireProfileState, baseline}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -25,8 +25,8 @@ pub enum CliEntry { impl CliEntry { pub const fn originator(self) -> &'static str { match self { - Self::Tui => "codex-tui", - Self::Exec => "codex_exec", + Self::Tui => baseline::TUI_ORIGINATOR, + Self::Exec => baseline::EXEC_ORIGINATOR, } } } @@ -41,25 +41,22 @@ pub enum ClientPlatform { impl ClientPlatform { pub const fn os_type(self) -> &'static str { - match self { - Self::Macos => "Mac OS", - Self::Linux => "Linux", - Self::Windows => "Windows", - } + self.default_environment().os_type } fn default_os_version(self) -> &'static str { - match self { - Self::Macos => "15.7.1", - Self::Linux => "6.8.0", - Self::Windows => "10.0.26100", - } + self.default_environment().os_version } fn default_arch(self) -> &'static str { + self.default_environment().arch + } + + const fn default_environment(self) -> baseline::Environment { match self { - Self::Macos => "arm64", - Self::Linux | Self::Windows => "x86_64", + Self::Macos => baseline::MACOS_ENVIRONMENT, + Self::Linux => baseline::LINUX_ENVIRONMENT, + Self::Windows => baseline::WINDOWS_ENVIRONMENT, } } } @@ -138,8 +135,10 @@ impl ClientProfileSelection { 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), + ClientKind::Desktop => baseline::DESKTOP_ORIGINATOR, + ClientKind::Cli => self + .cli_entry + .map_or(baseline::CLI_ORIGINATOR, CliEntry::originator), } } @@ -275,7 +274,7 @@ impl ClientProfileSelection { terminal: self .terminal .clone() - .unwrap_or_else(|| "unknown".to_owned()), + .unwrap_or_else(|| baseline::TERMINAL.to_owned()), exact_user_agent: None, installation_seed: None, residency: deployment.residency, @@ -370,7 +369,7 @@ impl CodexWireProfileState { "configuration": configuration, "automaticAvailable": available, "reason": (!available).then_some("暂不支持自动更新"), - "defaults": { "originator": configuration.default_originator(), "osType": platform.os_type(), "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": baseline::TERMINAL }, })); } } diff --git a/backend/crates/providers/openai/tests/credential/catalog/mod.rs b/backend/crates/providers/openai/tests/credential/catalog/mod.rs index 57ae7901f..ebd3132db 100644 --- a/backend/crates/providers/openai/tests/credential/catalog/mod.rs +++ b/backend/crates/providers/openai/tests/credential/catalog/mod.rs @@ -23,6 +23,82 @@ use crate::support::{MemoryAccountStore, catalog_cache, profile, secret}; const OFFICIAL_FIXTURE: &[u8] = include_bytes!("../../transport/fixtures/official_models_snapshot.json"); +#[tokio::test] +async fn discovery_refresh_and_export_use_the_current_global_profile() { + use gateway_core::account::OpaqueProviderData; + use serde_json::json; + + let document = + |value: serde_json::Value| OpaqueProviderData::new(value.as_object().unwrap().clone()); + let fixed = |version| { + document( + json!({"client":"cli","platform":"macos","versionMode":"fixed","codexVersion":version}), + ) + }; + let store = Arc::new(MemoryAccountStore::default()); + let first = seed_account(&store, "acct_profile_a").await; + let second = seed_account(&store, "acct_profile_b").await; + let server = MockServer::start().await; + for (version, originator, count) in [ + ("0.169.0", "codex_cli_rs", 2), + ("0.170.0", "codex_cli_rs", 1), + ("0.171.0", "catalog-test", 1), + ] { + Mock::given(method("GET")) + .and(path("/codex/models")) + .and(query_param("client_version", version)) + .and(header("version", version)) + .and(header("originator", originator)) + .and(move |request: &Request| request.headers.get("user-agent").and_then(|v| v.to_str().ok()).is_some_and(|ua| ua.starts_with(&format!("{originator}/{version}")))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"models":[{"slug":format!("model-{version}"),"display_name":"Native","future_field":true}]}))) + .expect(count).mount(&server).await; + } + let policy = Arc::new(crate::support::RequestProfileRuntimePolicy( + std::sync::Mutex::new(fixed("0.169.0")), + )); + let baseline = wire_profile(); + let service = CodexCredentialCatalogService::new( + store.repository(), + baseline.clone(), + reqwest::Client::builder().no_proxy().build().unwrap(), + server.uri(), + catalog_cache(), + policy.clone(), + ); + service.refresh_catalogs().await.unwrap(); + for account in [&first, &second] { + assert_eq!( + service.cached_account_models(account).unwrap().unwrap(), + ["model-0.169.0"] + ); + } + *policy.0.lock().unwrap() = fixed("0.170.0"); + assert_eq!( + service + .refresh_account_catalog(first.id()) + .await + .unwrap() + .models(), + ["model-0.170.0"] + ); + *policy.0.lock().unwrap() = document( + json!({"mode":"custom","userAgent":"catalog-test/0.171.0","originator":"catalog-test","codexVersion":"0.171.0"}), + ); + let (models, _) = service.account_catalog_documents(&first).await.unwrap(); + assert_eq!(models[0].request_model().as_str(), "model-0.171.0"); + assert_eq!(baseline.snapshot().codex_version, "0.144.0"); + + *policy.0.lock().unwrap() = document(json!({"mode":"custom","userAgent":"invalid"})); + assert!(matches!( + service.refresh_account_catalog(first.id()).await, + Err(CodexCredentialCatalogError::Profile(_)) + )); + assert_eq!( + service.cached_account_models(&first).unwrap().unwrap(), + ["model-0.170.0"] + ); +} + struct ReplacingCatalogResponder { calls: Arc, } @@ -95,6 +171,7 @@ fn service_with_catalog_cache( .expect("client"), base_url, catalog_cache, + crate::support::runtime_policy(), ) } diff --git a/backend/crates/providers/openai/tests/credential/contract/mod.rs b/backend/crates/providers/openai/tests/credential/contract/mod.rs index 55f0fcc1d..436bb098a 100644 --- a/backend/crates/providers/openai/tests/credential/contract/mod.rs +++ b/backend/crates/providers/openai/tests/credential/contract/mod.rs @@ -242,6 +242,7 @@ fn selector_with_runtime( http.clone(), OFFICIAL_CODEX_BASE_URL.to_owned(), crate::support::catalog_cache(), + crate::support::runtime_policy(), ), ); let quota = Arc::new(CodexCredentialQuotaService::new( diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index a5215eac2..f345ef071 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -218,6 +218,8 @@ async fn complete_with_name( #[tokio::test] async fn authorize_url_matches_the_official_desktop_parameter_contract() { + let wire_profile = profile(); + let desktop_version = wire_profile.snapshot().desktop_version; let service = CodexOAuthAdminService::new( Arc::new(PendingStore::default()), Arc::new(Exchanger { @@ -225,7 +227,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() { }), Arc::new(MemoryAccountStore::default()), CodexCredentialAdmin, - profile(), + wire_profile, ); let started = service .start_authorization(StartCodexOAuthAuthorization { @@ -326,7 +328,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() { ); assert_eq!( parameters.get("codex_app_version").map(String::as_str), - Some("26.901.51231") + Some(desktop_version.as_str()) ); let surface_stable_id = parameters .get("source_surface_stable_id") diff --git a/backend/crates/providers/openai/tests/credential/quota/warmup.rs b/backend/crates/providers/openai/tests/credential/quota/warmup.rs index ae5c8a57e..8abc22cc0 100644 --- a/backend/crates/providers/openai/tests/credential/quota/warmup.rs +++ b/backend/crates/providers/openai/tests/credential/quota/warmup.rs @@ -192,7 +192,7 @@ async fn warmup_consumes_completed_sse_and_persists_rate_limit_event() { } #[tokio::test] -async fn warmup_sends_the_lowest_reasoning_effort_supported_by_the_catalog() { +async fn warmup_uses_global_profile_and_lowest_reasoning_effort() { let server = MockServer::start().await; let sse = concat!( "event: response.completed\n", @@ -201,19 +201,27 @@ async fn warmup_sends_the_lowest_reasoning_effort_supported_by_the_catalog() { ); Mock::given(method("POST")) .and(path("/codex/responses")) + .and(wiremock::matchers::header("version", "0.169.0")) + .and(wiremock::matchers::header("originator", "codex_cli_rs")) .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")) .expect(1) .mount(&server) .await; let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_warmup_effort").await; - let service = quota_service_with_base_url( - &store, + let policy = Arc::new(crate::support::RequestProfileRuntimePolicy(std::sync::Mutex::new( + OpaqueProviderData::new(json!({"client":"cli","platform":"macos","versionMode":"fixed","codexVersion":"0.169.0"}).as_object().unwrap().clone()), + ))); + let service = CodexCredentialQuotaService::new( + store.repository(), + wire_profile(), reqwest::Client::builder() .no_proxy() .build() .expect("client"), server.uri(), + Arc::new(crate::support::MemoryCooldownPort::new()), + policy.clone(), ); let summary = service.execute_warmup("test-model").await.expect("warmup"); @@ -226,6 +234,16 @@ async fn warmup_sends_the_lowest_reasoning_effort_supported_by_the_catalog() { serde_json::from_slice(&decoded).expect("warmup body should be valid JSON"); // 上游按目录拒绝目录外的档位,none 曾让全部定时预热只留下 error 终态 assert_eq!(body["reasoning"]["effort"], "low"); + *policy.0.lock().unwrap() = OpaqueProviderData::new( + json!({"mode":"custom","userAgent":"invalid"}) + .as_object() + .unwrap() + .clone(), + ); + assert!(matches!( + service.execute_warmup("test-model").await, + Err(CodexCredentialQuotaError::Profile(_)) + )); } #[tokio::test] diff --git a/backend/crates/providers/openai/tests/provider/contract/cookies.rs b/backend/crates/providers/openai/tests/provider/contract/cookies.rs index 0dc995bb5..a1c0857ea 100644 --- a/backend/crates/providers/openai/tests/provider/contract/cookies.rs +++ b/backend/crates/providers/openai/tests/provider/contract/cookies.rs @@ -13,6 +13,7 @@ fn cookie_provider(store: &Arc, base_url: String) -> Arc CodexAccountProfile { } } +pub(crate) struct RequestProfileRuntimePolicy( + pub(crate) std::sync::Mutex, +); + +impl gateway_core::provider_ports::ProviderRuntimePolicyPort for RequestProfileRuntimePolicy { + fn load_request_profile<'a>( + &'a self, + provider: &'a ProviderKind, + ) -> futures::future::BoxFuture< + 'a, + Result< + Option, + gateway_core::provider_ports::ProviderStoreError, + >, + > { + assert_eq!(provider.as_str(), "openai"); + let profile = self.0.lock().unwrap().clone(); + Box::pin(async move { Ok(Some(profile)) }) + } + + fn load_refresh_policy( + &self, + ) -> futures::future::BoxFuture< + '_, + Result< + gateway_core::provider_ports::ProviderRefreshPolicy, + gateway_core::provider_ports::ProviderStoreError, + >, + > { + Box::pin(async { crate::support::runtime_policy().load_refresh_policy().await }) + } +} + pub(crate) struct StaticRuntimePolicy; impl ProviderRuntimePolicyPort for StaticRuntimePolicy { 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 afeb9e30d..98329dd0d 100644 --- a/backend/crates/providers/openai/tests/transport/profile/cli_release.rs +++ b/backend/crates/providers/openai/tests/transport/profile/cli_release.rs @@ -64,6 +64,11 @@ async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_ ..Default::default() }; let frozen = automatic.resolve(&state).unwrap(); + // 从当前基线构造后继发布,升级离线版本无需重写缓存更新场景 + let mut next = semver::Version::parse(&frozen.codex_version).unwrap(); + next.patch += 1; + let next_sequence = 1 + next.major * 1_000_000 + next.minor * 1_000 + next.patch; + let next_version = next.to_string(); let fixed = ClientProfileSelection { version_mode: VersionMode::Fixed, codex_version: Some(frozen.codex_version.clone()), @@ -83,9 +88,11 @@ async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_ ProviderArtifactProfile::new( provider.clone(), "cli-linux-x64".into(), - 157_001, + next_sequence, chrono::Utc::now().into(), - OpaqueProviderData::new(json!({"version":"0.157.0"}).as_object().unwrap().clone()), + OpaqueProviderData::new( + json!({"version":next_version}).as_object().unwrap().clone(), + ), ), Duration::from_secs(86400), ) @@ -100,11 +107,11 @@ async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_ 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)" + "{originator}/{next_version} (Alpine Linux 3.24.1; x86_64) xterm-256color ({originator}; {next_version})" ); assert_eq!(headers["user-agent"], expected); assert_eq!(headers["originator"], originator); - assert_eq!(headers["version"], "0.157.0"); + assert_eq!(headers["version"], next_version.as_str()); } assert_eq!( fixed.resolve(&state).unwrap().user_agent(), @@ -114,7 +121,6 @@ async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_ custom.resolve(&state).unwrap().user_agent(), frozen.user_agent() ); - assert_eq!(frozen.codex_version, "0.155.0"); // 缓存恢复与启动种子构成观察历史,滞后档位据此钉住上一版。 let lagged = ClientProfileSelection { @@ -123,7 +129,7 @@ async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_ }; assert_eq!( lagged.resolve(&state).unwrap().user_agent(), - "codex-tui/0.155.0 (Alpine Linux 3.24.1; x86_64) xterm-256color (codex-tui; 0.155.0)" + frozen.user_agent() ); let restarted = CodexWireProfileState::new(super::wire_profile()); diff --git a/backend/crates/providers/openai/tests/transport/profile/platform_release/artifact.rs b/backend/crates/providers/openai/tests/transport/profile/platform_release/artifact.rs index 7736e572f..47773dc50 100644 --- a/backend/crates/providers/openai/tests/transport/profile/platform_release/artifact.rs +++ b/backend/crates/providers/openai/tests/transport/profile/platform_release/artifact.rs @@ -150,6 +150,39 @@ fn windows_reads_versions_within_budget_despite_large_unrelated_entries() { } } +#[test] +fn windows_reads_zip64_end_records_with_sentinel_directory_fields() { + let mut bytes = zip(false, &core(true, false)); + let expected = read_windows(Cursor::new(&bytes), "x86_64").unwrap(); + let end = bytes.len() - 22; + let mut eocd = bytes.split_off(end); + assert_eq!(&eocd[..4], b"PK\x05\x06"); + let entries = u64::from(u16::from_le_bytes(eocd[10..12].try_into().unwrap())); + let directory_size = u64::from(u32::from_le_bytes(eocd[12..16].try_into().unwrap())); + let directory_offset = u64::from(u32::from_le_bytes(eocd[16..20].try_into().unwrap())); + // MSIX 即使不足 4 GiB 也使用 ZIP64 尾记录,并把普通 EOCD 目录字段置为哨兵 + bytes.extend_from_slice(b"PK\x06\x06"); + bytes.extend_from_slice(&44_u64.to_le_bytes()); + bytes.extend_from_slice(&45_u16.to_le_bytes()); + bytes.extend_from_slice(&45_u16.to_le_bytes()); + bytes.extend_from_slice(&[0; 8]); + for value in [entries, entries, directory_size, directory_offset] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.extend_from_slice(b"PK\x06\x07"); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&(end as u64).to_le_bytes()); + bytes.extend_from_slice(&1_u32.to_le_bytes()); + eocd[8..20].fill(0xff); + bytes.extend(eocd); + assert_eq!( + read_windows(ReadBudget(Cursor::new(&bytes).take(64 * 1024)), "x86_64").unwrap(), + expected + ); + bytes[end..end + 4].fill(0); + assert!(read_windows(Cursor::new(bytes), "x86_64").is_err()); +} + #[test] fn truncated_or_corrupt_archives_do_not_publish_versions() { let zip = zip(false, &core(true, false)); diff --git a/backend/crates/providers/openai/tests/transport/profile/platform_release/mod.rs b/backend/crates/providers/openai/tests/transport/profile/platform_release/mod.rs index 866f22f18..188c611c7 100644 --- a/backend/crates/providers/openai/tests/transport/profile/platform_release/mod.rs +++ b/backend/crates/providers/openai/tests/transport/profile/platform_release/mod.rs @@ -63,14 +63,23 @@ impl DesktopArtifactTransport for Transport { previous: Option, ) -> BoxFuture<'_, io::Result> { Box::pin(async move { - if previous.is_some() { - *self.seen_previous.lock().unwrap() += 1; - } + let previous = previous.expect("target has an offline artifact"); + *self.seen_previous.lock().unwrap() += 1; let round = *self.round.lock().unwrap(); if round == 1 && target == TARGETS[0] { return Err(io::Error::other("fixture outage")); } - let build = if round == 2 { 1 } else { 20000 + round }; + let build = if round == 2 { + 1 + } else { + previous + .release + .desktop_build + .unwrap() + .parse::() + .unwrap() + + 1 + }; Ok(serde_json::from_value(json!({"identity":{"etag":format!("\"{build}\""),"size":1000}, "release":{"codexVersion":format!("0.200.{round}"), "desktopVersion":format!("30.1.{round}"), "desktopBuild":build.to_string(), "verifiedAt":null}})).unwrap()) }) } diff --git a/backend/crates/providers/openai/tests/transport/profile/selection.rs b/backend/crates/providers/openai/tests/transport/profile/selection.rs index 5a0f50960..28652d9ac 100644 --- a/backend/crates/providers/openai/tests/transport/profile/selection.rs +++ b/backend/crates/providers/openai/tests/transport/profile/selection.rs @@ -4,12 +4,19 @@ use gateway_core::account::OpaqueProviderData; use provider_openai::transport::profile::selection::{ CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, }; -use provider_openai::transport::profile::{CodexBundledReleaseProfile, CodexWireProfileState}; +use provider_openai::transport::profile::{ + CodexBundledReleaseProfile, CodexWireProfile, CodexWireProfileState, +}; use serde_json::json; #[test] fn six_presets_have_explicit_availability_and_cli_has_no_desktop_suffix() { - let state = CodexWireProfileState::new(super::wire_profile()); + let state = CodexWireProfileState::new(CodexWireProfile::default()); + assert_eq!( + ClientProfileSelection::default().resolve(&state).unwrap(), + state.snapshot(), + "default requests and the default preset must use the same identity" + ); let options = state.selection_options().unwrap(); let presets = options.expose_to_provider()["presets"].as_array().unwrap(); assert_eq!(presets.len(), 6); @@ -34,6 +41,7 @@ fn six_presets_have_explicit_availability_and_cli_has_no_desktop_suffix() { assert!(profile.desktop_version.is_empty()); assert!(!profile.user_agent().contains("Desktop")); assert!(profile.user_agent().ends_with("unknown")); + assert!(profile.verified_at > chrono::DateTime::UNIX_EPOCH); } for platform in [ClientPlatform::Linux, ClientPlatform::Windows] { for arch in ["arm64", "x86_64"] { @@ -46,6 +54,7 @@ fn six_presets_have_explicit_availability_and_cli_has_no_desktop_suffix() { assert_eq!(profile.os_type, platform.os_type()); assert_eq!(profile.arch, arch); assert!(profile.desktop_version.starts_with("26.")); + assert!(profile.verified_at > chrono::DateTime::UNIX_EPOCH); } } let unsupported = ClientProfileSelection { diff --git a/docs/api.md b/docs/api.md index a2d4d0c93..4ed314b1d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1503,7 +1503,7 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除或连接配 ### 账号连接测试 SSE -`GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,不参与普通账号轮换。成功流沿用 +`GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,采用当前全局客户端身份,不参与普通账号轮换。成功流沿用 `test_start`、`request`、`content`、`test_complete` 事件;失败事件为: ```json @@ -2191,6 +2191,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly 0% 或缺少活跃证据时允许一次轻量预热,不通过倒计时差值判断活跃,也不额外回查额度。 因此已激活但显示 0% 的账号也可能收到预热请求。 不存在的本地时刻跳过,重复时刻只执行较早一次;执行进度跨重启保留,时钟回拨不补跑已领取时刻之前的时段。 +预热每轮采用当前 OpenAI 全局客户端身份,不使用单个 Key 的覆盖。 只有收到响应成功终态才记为预热成功;预热不计入客户端业务用量 ### 请求位置 @@ -2430,7 +2431,8 @@ models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI `openaiClientProfile` 保存通用选择,首次默认 `MacOS · Desktop · 自动最新`。 兼容字段的更新语义见上节。初始化不读取 YAML 身份字段。 -该配置作用于 Client Key 的 OpenAI 模型请求,适用于 HTTP/SSE、WebSocket、Images 和 Search。 +该配置作用于 Client Key 的 OpenAI 模型请求,适用于 HTTP/SSE、WebSocket、Images 和 Search, +也用于账号模型目录的后台发现、手动刷新、导出及定时预热;下次执行时采用当前全局选择,不使用单个 Key 的覆盖。 不改变 xAI、入站客户端版本门禁、账号认证或后台 Desktop 专属操作 自定义配置使用 `{ "mode": "custom", "userAgent": "完整 UA" }`。 diff --git a/docs/architecture.md b/docs/architecture.md index 066df8375..14347b603 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -601,12 +601,16 @@ OpenAI 模型目录用于发现与原生 Responses 账号资格筛选。 各 Provider 唯一负责默认值、校验、版本来源及 UA 生成,Admin 提供管理和生效预览。 首次初始化只写入内置默认选择;YAML 不定义客户端身份,也不作为数据库初始化或请求解析的来源。 官方发布资料与用户选择分开:OpenAI 在 Redis 按 Provider、客户端、平台、架构隔离可重建版本缓存, + 离线版本、制品身份及预设环境默认值集中在 + [`transport/profile/baseline.rs`](../backend/crates/providers/openai/src/transport/profile/baseline.rs),由发布检查与预设解析共用。 Desktop 完整制品元组原子更新。CLI 的 TUI/Exec 入口共用官方 CLI 版本资料,Provider 在一次身份解析中 生成一致的 UA、入口后缀与配套请求头,后台更新不修改用户选择的运行环境。 完整自定义 UA 经 Provider 校验后原样传递,配套头统一解析;固定和自定义配置不受后台更新影响。 OpenAI Provider 按 Key 种子、Key ID 与账号 ID 派生出站安装身份;未配置种子时使用账号身份,不改写账号凭据。 普通连接按生效身份匹配,精确续写固定原连接与身份;配置合同见 [Key 客户端身份](api.md#key-客户端身份)。 - 后台账号和 Desktop 专属操作使用独立官方 Desktop 画像,不接受 Key 覆盖。 + OpenAI 账号目录发现和定时预热每轮读取并解析一次当前全局画像,查询版本和请求头使用同一快照,不接受 Key 覆盖。 + 其余后台账号和 Desktop 专属操作使用独立官方 Desktop 画像。 + 管理端连接测试从本次发布快照携带全局画像,经 Provider 解析后冻结为探测身份。 xAI 以内置画像为版本检查基线,在进程内更新 Grok CLI 发布资料;模型与压缩请求使用已保存的用户选择, OAuth、后台目录和额度查询使用内置官方画像。两者均不回写 `config.yaml`