fix(openai): align model operations with global client profiles (#708)

Apply the global request profile to account catalog discovery, warmup,
and diagnostic routing. Freeze each operation's identity through the
existing provider profile resolver.

Refresh verified offline releases and centralize their version, artifact,
and environment defaults. Cover ZIP64 MSIX metadata and keep release
update tests independent of the current bundled versions.

Fixes #703

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-14 10:40:12 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 8f40bfda7c
commit d99ecf9aaf
31 changed files with 558 additions and 111 deletions
@@ -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<Option<OpaqueProviderData>, ProviderStoreError>> {
Box::pin(async { Ok(None) })
}
/// 原子推进预热执行游标,重启或时钟回拨后不重复领取已消费的时刻
fn claim_warmup_slot<'a>(
&'a self,
@@ -1247,13 +1247,13 @@ impl RuntimeSnapshot {
operation: &Operation,
context: &RoutingContext,
) -> Result<RoutingPlan, RoutingError> {
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(
@@ -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<dyn Provider>])
ProviderRegistry::new([Arc::new(ProfileCheckingProvider(profile)) as Arc<dyn Provider>])
.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<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn execute(
self: Arc<Self>,
request: ProviderRequest,
context: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
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 {
@@ -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<Option<gateway_core::account::OpaqueProviderData>, ProviderStoreError>,
> {
Box::pin(async move {
let document = sqlx::query_scalar::<
_,
Option<sqlx::types::Json<serde_json::Map<String, serde_json::Value>>>,
>(
"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,
@@ -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))
@@ -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)
}
@@ -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<dyn ProviderCatalogCachePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
cache: Arc<RwLock<CatalogCacheState>>,
refresh_notification: Arc<Notify>,
refresh_lock: Arc<tokio::sync::Mutex<()>>,
@@ -286,6 +290,7 @@ impl CodexCredentialCatalogService {
http: reqwest::Client,
base_url: String,
account_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
) -> 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<CodexBackendClient, CodexCredentialCatalogError> {
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<CodexCatalogModel>, 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);
@@ -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 {
@@ -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")]
@@ -140,11 +140,17 @@ impl CodexCredentialQuotaService {
})
.collect::<BTreeMap<_, _>>();
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() {
@@ -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");
@@ -15,7 +15,7 @@ use super::{
};
impl CodexBackendClient {
/// 客户端目录按调用方版本协商;后台目录仍使用经过核验的服务端画像版本
/// 目录优先使用显式协商版本,否则沿用调用方冻结的画像版本
pub async fn fetch_models_with_context(
&self,
context: CodexRequestContext<'_>,
@@ -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<Utc> {
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\"",
},
];
@@ -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()),
},
);
}
@@ -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<CodexWireProfile, ErrorSource> {
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 {
@@ -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(),
}
}
}
@@ -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<u64> {
}
fn baseline_artifacts() -> BTreeMap<DesktopTarget, VerifiedArtifact> {
// 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(
@@ -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 },
}));
}
}
@@ -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<AtomicUsize>,
}
@@ -95,6 +171,7 @@ fn service_with_catalog_cache(
.expect("client"),
base_url,
catalog_cache,
crate::support::runtime_policy(),
)
}
@@ -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(
@@ -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")
@@ -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]
@@ -13,6 +13,7 @@ fn cookie_provider(store: &Arc<MemoryAccountStore>, base_url: String) -> Arc<Cod
http.clone(),
base_url.clone(),
catalog_cache(),
crate::support::runtime_policy(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
store.repository(),
@@ -1284,6 +1284,7 @@ fn provider_and_quota_with_runtime_ports(
http.clone(),
base_url.clone(),
catalog_cache(),
crate::support::runtime_policy(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
store.repository(),
@@ -1365,6 +1365,39 @@ pub(crate) fn profile(account_id: &str) -> CodexAccountProfile {
}
}
pub(crate) struct RequestProfileRuntimePolicy(
pub(crate) std::sync::Mutex<gateway_core::account::OpaqueProviderData>,
);
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::account::OpaqueProviderData>,
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 {
@@ -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(&current, "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());
@@ -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));
@@ -63,14 +63,23 @@ impl DesktopArtifactTransport for Transport {
previous: Option<VerifiedArtifact>,
) -> BoxFuture<'_, io::Result<VerifiedArtifact>> {
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::<u64>()
.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())
})
}
@@ -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 {
+4 -2
View File
@@ -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" }`。
+5 -1
View File
@@ -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`