feat(settings): enforce minimum client versions

Add configurable minimum versions for Codex Desktop and CLI, return 426 for unsupported recognized clients, and provide install and Windows offline download guidance.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-01 13:37:04 +08:00
co-authored by Codex
parent 2640a03219
commit bd2454a5b6
65 changed files with 2779 additions and 87 deletions
+258 -2
View File
@@ -954,6 +954,29 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1005,6 +1028,27 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1053,6 +1097,21 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1074,6 +1133,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ego-tree"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
[[package]]
name = "either"
version = "1.16.0"
@@ -1416,6 +1481,7 @@ dependencies = [
"bytes",
"futures",
"futures-timer",
"semver",
"serde",
"serde_json",
"thiserror",
@@ -1438,6 +1504,7 @@ dependencies = [
"gateway-core",
"hex",
"reqwest",
"scraper",
"semver",
"serde",
"serde_json",
@@ -1501,6 +1568,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1661,6 +1737,16 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -2064,6 +2150,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2132,6 +2229,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2283,7 +2386,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
@@ -2296,6 +2399,27 @@ dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
@@ -2306,19 +2430,42 @@ dependencies = [
"rand 0.8.7",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
@@ -2337,6 +2484,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2389,6 +2545,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "primefield"
version = "0.14.0"
@@ -2917,6 +3079,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "sec1"
version = "0.8.1"
@@ -2964,6 +3141,25 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -3047,6 +3243,15 @@ dependencies = [
"serde",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
@@ -3386,6 +3591,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -3475,6 +3704,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "thiserror"
version = "2.0.20"
@@ -3890,6 +4128,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -4073,6 +4317,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
dependencies = [
"phf 0.13.1",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
+1
View File
@@ -79,6 +79,7 @@ pub async fn run() -> Result<(), BootstrapError> {
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
host.client_distribution_resolver(),
host.system_operations(),
)
.await?;
+14 -3
View File
@@ -22,19 +22,22 @@ mod use_case;
pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_keys::ClientKeyService, observability::ObservabilityService,
openai::OpenAiService, settings::SettingsService, system::SystemService, xai::XaiService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
use ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
store::AdminStorePorts,
system::SystemOperations,
};
use use_case::{
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
auth::DefaultAuthService, backup::DefaultBackupService, client_keys::DefaultClientKeyService,
auth::DefaultAuthService, backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
};
@@ -149,6 +152,7 @@ pub struct AdminServices {
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
client_distribution: Arc<dyn ClientDistributionService>,
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
@@ -178,6 +182,11 @@ impl AdminServices {
self.client_keys.as_ref()
}
#[must_use]
pub fn client_distribution(&self) -> &dyn ClientDistributionService {
self.client_distribution.as_ref()
}
#[must_use]
pub fn observability(&self) -> &dyn ObservabilityService {
self.observability.as_ref()
@@ -238,6 +247,7 @@ pub async fn initialize(
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
config
@@ -290,6 +300,7 @@ pub async fn initialize(
store.client_keys(),
snapshot.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.settings(),
@@ -0,0 +1,58 @@
//! 管理设置页使用的 Codex 客户端下载信息。
use chrono::{DateTime, Utc};
/// Windows 安装包架构。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ClientArchitecture {
X64,
Arm64,
}
impl ClientArchitecture {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::X64 => "x64",
Self::Arm64 => "arm64",
}
}
}
/// 下载地址的可信来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientDownloadSource {
MicrosoftStore,
OfficialOpenAi,
}
impl ClientDownloadSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MicrosoftStore => "microsoft_store",
Self::OfficialOpenAi => "official_openai",
}
}
}
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientDownloadPackage {
pub architecture: ClientArchitecture,
pub source: ClientDownloadSource,
pub version: Option<String>,
pub file_name: String,
pub size_bytes: Option<u64>,
pub download_url: String,
pub expires_at: Option<DateTime<Utc>>,
}
/// Codex Desktop Windows 下载解析结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexDesktopWindowsDownloads {
pub resolved_at: DateTime<Utc>,
pub cached: bool,
pub warning: Option<String>,
pub packages: Vec<ClientDownloadPackage>,
}
@@ -6,6 +6,7 @@ pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
@@ -24,6 +24,8 @@ pub struct RuntimeSettings {
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -39,6 +41,8 @@ pub struct ReplaceRuntimeSettings {
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -0,0 +1,10 @@
//! 客户端安装包解析的外部能力端口。
use async_trait::async_trait;
use crate::model::client_distribution::CodexDesktopWindowsDownloads;
#[async_trait]
pub trait ClientDistributionResolver: Send + Sync {
async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
}
@@ -1,6 +1,7 @@
//! 管理用例依赖的外部能力端口。
pub mod backup;
pub mod client_distribution;
pub mod provider;
pub mod store;
pub mod system;
@@ -0,0 +1,33 @@
//! Codex 客户端下载信息用例。
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
model::client_distribution::CodexDesktopWindowsDownloads,
ports::client_distribution::ClientDistributionResolver,
};
#[async_trait]
pub trait ClientDistributionService: Send + Sync {
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
}
pub(crate) struct DefaultClientDistributionService {
resolver: Arc<dyn ClientDistributionResolver>,
}
impl DefaultClientDistributionService {
#[must_use]
pub(crate) const fn new(resolver: Arc<dyn ClientDistributionResolver>) -> Self {
Self { resolver }
}
}
#[async_trait]
impl ClientDistributionService for DefaultClientDistributionService {
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads {
self.resolver.resolve_codex_desktop_windows(refresh).await
}
}
@@ -4,6 +4,7 @@ pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod openai;
@@ -3,6 +3,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::policy::CodexClientVersion;
use gateway_core::routing::snapshot::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
@@ -119,6 +120,8 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok();
if valid {
Ok(())
@@ -126,3 +129,7 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
@@ -798,6 +798,8 @@ impl SettingsStore for StaticSettingsStore {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -31,6 +31,7 @@ use gateway_admin::{
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::CodexDesktopWindowsDownloads,
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -55,6 +56,7 @@ use gateway_admin::{
},
ports::{
backup::BackupStorePorts,
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
@@ -205,6 +207,7 @@ impl AdminHarness {
self.providers,
Arc::new(NoopSnapshot),
self.probe,
Arc::new(NoopClientDistribution),
self.system,
)
.await
@@ -213,6 +216,20 @@ impl AdminHarness {
}
}
struct NoopClientDistribution;
#[async_trait]
impl ClientDistributionResolver for NoopClientDistribution {
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
CodexDesktopWindowsDownloads {
resolved_at: Utc::now(),
cached: false,
warning: None,
packages: Vec::new(),
}
}
}
#[derive(Default)]
struct BootstrapAuthStore {
password_hash: Mutex<Option<String>>,
@@ -721,6 +721,8 @@ impl SettingsStore for FixtureSettingsStore {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -67,6 +67,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -10,14 +10,18 @@ use axum::{
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_distribution::{
ClientDownloadPackage, CodexDesktopWindowsDownloads,
};
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::policy::CodexClientVersion;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
@@ -34,6 +38,8 @@ pub struct RuntimeSettingsView {
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
@@ -50,6 +56,8 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
@@ -75,6 +83,14 @@ impl UpdateRuntimeSettingsRequest {
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
return Err(WireValidationError::new("rotationStrategy"));
}
validate_optional_client_version(
self.min_codex_desktop_version.as_deref(),
"minCodexDesktopVersion",
)?;
validate_optional_client_version(
self.min_codex_cli_version.as_deref(),
"minCodexCliVersion",
)?;
Ok(())
}
@@ -90,6 +106,8 @@ impl UpdateRuntimeSettingsRequest {
request_interval_ms: self.request_interval_ms,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
@@ -109,6 +127,8 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
@@ -145,6 +165,58 @@ pub struct DeletedAdminApiKey {
pub message: &'static str,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ClientDownloadsQuery {
refresh: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClientDownloadPackageView {
architecture: String,
source: String,
version: Option<String>,
file_name: String,
size_bytes: Option<u64>,
download_url: String,
expires_at: Option<DateTime<Utc>>,
}
impl From<ClientDownloadPackage> for ClientDownloadPackageView {
fn from(package: ClientDownloadPackage) -> Self {
Self {
architecture: package.architecture.as_str().to_owned(),
source: package.source.as_str().to_owned(),
version: package.version,
file_name: package.file_name,
size_bytes: package.size_bytes,
download_url: package.download_url,
expires_at: package.expires_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexDesktopWindowsDownloadsView {
resolved_at: DateTime<Utc>,
cached: bool,
warning: Option<String>,
packages: Vec<ClientDownloadPackageView>,
}
impl From<CodexDesktopWindowsDownloads> for CodexDesktopWindowsDownloadsView {
fn from(downloads: CodexDesktopWindowsDownloads) -> Self {
Self {
resolved_at: downloads.resolved_at,
cached: downloads.cached,
warning: downloads.warning,
packages: downloads.packages.into_iter().map(Into::into).collect(),
}
}
}
impl Default for DeletedAdminApiKey {
fn default() -> Self {
Self {
@@ -161,6 +233,10 @@ where
Router::new()
.route("/api/admin/settings", get(settings::<S>))
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
get(codex_desktop_windows_downloads::<S>),
)
.route(
"/api/admin/settings/admin-api-key",
get(admin_api_key_status::<S>),
@@ -175,6 +251,25 @@ where
)
}
async fn codex_desktop_windows_downloads<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
) -> impl IntoResponse
where
S: AdminSessionState + Send + Sync,
{
let downloads = state
.admin_services()
.client_distribution()
.codex_desktop_windows(query.refresh)
.await;
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)),
)
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -322,6 +417,16 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn validate_optional_client_version(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
@@ -329,6 +434,8 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
field => format!("{field} 字段不合法"),
};
AdminError::bad_request(message)
+153 -9
View File
@@ -5,9 +5,13 @@ use axum::{
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::AuthenticatedClient;
use gateway_core::policy::{ClientVersionRejection, CodexClientKind, CodexClientVersion};
use super::{
error::{missing_client_api_key_response, runtime_unavailable_response},
error::{
client_version_rejection_response, missing_client_api_key_response,
runtime_unavailable_response,
},
service::OpenAiService,
};
@@ -26,6 +30,44 @@ pub enum ClientApiKeyAuthError {
RuntimeUnavailable,
}
/// Client API Key 或最低版本准入失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientAccessError {
Authentication(ClientApiKeyAuthError),
Version(ClientVersionRejection),
}
impl From<ClientApiKeyAuthError> for ClientAccessError {
fn from(error: ClientApiKeyAuthError) -> Self {
Self::Authentication(error)
}
}
impl From<ClientVersionRejection> for ClientAccessError {
fn from(error: ClientVersionRejection) -> Self {
Self::Version(error)
}
}
/// 从有界请求头中识别出的客户端及其可选合法版本。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentifiedCodexClient {
kind: CodexClientKind,
version: Option<CodexClientVersion>,
}
impl IdentifiedCodexClient {
#[must_use]
pub const fn kind(&self) -> CodexClientKind {
self.kind
}
#[must_use]
pub const fn version(&self) -> Option<&CodexClientVersion> {
self.version.as_ref()
}
}
impl ClientApiKeyAuthError {
/// 返回可用于日志和指标的稳定失败原因。
#[must_use]
@@ -67,19 +109,121 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
pub(crate) fn authenticate_client(
service: &OpenAiService,
headers: &HeaderMap,
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
bearer_client_api_key(headers).and_then(|key| service.authenticate(key))
) -> Result<AuthenticatedClient, ClientAccessError> {
let key = bearer_client_api_key(headers)?;
let client = service.authenticate(key)?;
if let Some(identified) = identify_codex_client(headers) {
client
.snapshot()
.min_codex_client_versions()
.enforce(identified.kind(), identified.version())?;
}
Ok(client)
}
pub(crate) fn authentication_error_response(error: ClientApiKeyAuthError) -> Response {
log_client_api_key_auth_failure(error);
if error == ClientApiKeyAuthError::RuntimeUnavailable {
runtime_unavailable_response().into_response()
} else {
missing_client_api_key_response().into_response()
pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response {
match error {
ClientAccessError::Authentication(error) => {
log_client_api_key_auth_failure(error);
if error == ClientApiKeyAuthError::RuntimeUnavailable {
runtime_unavailable_response().into_response()
} else {
missing_client_api_key_response().into_response()
}
}
ClientAccessError::Version(error) => {
tracing::info!(
client = error.kind().as_str(),
current_version = error.current().map(ToString::to_string),
min_version = %error.min(),
"Codex client version requirement rejected request"
);
client_version_rejection_response(&error).into_response()
}
}
}
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH);
let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH);
let is_desktop = originator.is_some_and(|value| value.eq_ignore_ascii_case("Codex Desktop"))
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
if is_desktop {
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
let version = match explicit_version {
Some(value) => CodexClientVersion::parse(value).ok(),
None => user_agent
.and_then(desktop_version_from_user_agent)
.and_then(|value| CodexClientVersion::parse(value).ok()),
};
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Desktop,
version,
});
}
let user_agent = user_agent?;
for product in user_agent.split(|character: char| {
character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']')
}) {
for prefix in ["codex_cli_rs", "codex-cli"] {
if product.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: None,
});
}
let Some((name, version)) = product.split_once('/') else {
continue;
};
if name.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: CodexClientVersion::parse(version).ok(),
});
}
}
}
None
}
fn bounded_ascii_header<'a>(
headers: &'a HeaderMap,
name: &'static str,
maximum_length: usize,
) -> Option<&'a str> {
let value = headers.get(name)?.to_str().ok()?;
if !value.is_ascii() {
return None;
}
Some(&value[..value.len().min(maximum_length)])
}
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
let marker = "Codex Desktop;";
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
let version = user_agent[start..]
.trim_start()
.split([')', ' ', ';', ','])
.next()?;
(!version.is_empty()).then_some(version)
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
find_ascii_case_insensitive(haystack, needle).is_some()
}
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
haystack
.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) {
match error {
ClientApiKeyAuthError::RuntimeUnavailable => {
@@ -9,6 +9,7 @@ use axum::{
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -58,6 +59,47 @@ pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
)
}
/// 已识别 Codex 客户端不满足最低版本要求。
pub fn client_version_rejection_response(
rejection: &ClientVersionRejection,
) -> (StatusCode, Json<Value>) {
let client_label = match rejection.kind() {
CodexClientKind::Desktop => "Codex Desktop",
CodexClientKind::Cli => "Codex CLI",
};
let (message, code, current_version) = match rejection.current() {
Some(current) => (
format!(
"{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.",
rejection.min()
),
"client_version_too_old",
Some(current.to_string()),
),
None => (
format!(
"A valid {client_label} version is required. Upgrade to version {} or newer and retry.",
rejection.min()
),
"client_version_unavailable",
None,
),
};
(
StatusCode::UPGRADE_REQUIRED,
Json(json!({
"error": {
"message": message,
"type": "invalid_request_error",
"code": code,
"client": rejection.kind().as_str(),
"current_version": current_version,
"min_version": rejection.min().to_string()
}
})),
)
}
/// 对外模型不存在。
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
openai_error_response(
@@ -16,7 +16,7 @@ use serde_json::{Map, Value};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::{engine_error_response, gateway_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
};
@@ -71,7 +71,7 @@ async fn handle_image_request(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use crate::ApiState;
use super::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
};
@@ -34,7 +34,7 @@ pub(crate) async fn models(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
if query
@@ -81,7 +81,7 @@ pub(crate) async fn model_detail(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
@@ -105,7 +105,7 @@ pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: Header
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let models = service
.public_models(&client)
@@ -125,7 +125,7 @@ pub(crate) async fn model_info(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
@@ -23,7 +23,7 @@ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_wi
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::{
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, protocol_error_response, runtime_unavailable_response,
@@ -65,7 +65,7 @@ async fn handle_responses(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let decoded = match if review {
decode_review_request_with_headers(&body, &headers)
@@ -4,7 +4,7 @@ mod error;
mod http;
mod request;
mod response;
mod websocket;
pub mod websocket;
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(super) use http::{PendingExecution, request_client_context};
@@ -24,21 +24,23 @@ const INBOUND_EVENT_BUFFER: usize = 32;
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
/// WebSocket pump 的写入与生命周期预算。
#[derive(Clone, Copy)]
pub(super) struct ConnectionConfig {
pub(super) write_timeout: Duration,
pub(super) max_age: Duration,
pub struct ConnectionConfig {
write_timeout: Duration,
max_age: Duration,
}
impl ConnectionConfig {
pub(super) const PRODUCTION: Self = Self {
/// 生产环境固定预算。
pub const PRODUCTION: Self = Self {
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
max_age: CONNECTION_MAX_AGE,
};
}
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费。
pub(super) enum ConnectionEvent {
pub enum ConnectionEvent {
Text(String),
Binary,
Expired,
@@ -47,7 +49,7 @@ pub(super) enum ConnectionEvent {
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FramePhase {
pub enum FramePhase {
Metadata,
First,
Data,
@@ -85,21 +87,26 @@ impl FramePhase {
}
}
/// 一次下游写入的请求归属与协议阶段。
#[derive(Clone)]
pub(super) struct WriteContext {
pub struct WriteContext {
request_id: Option<Arc<str>>,
phase: FramePhase,
}
impl WriteContext {
pub(super) fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
/// 创建归属于某个请求的写入上下文。
#[must_use]
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
Self {
request_id: Some(Arc::clone(request_id)),
phase,
}
}
pub(super) const fn connection(phase: FramePhase) -> Self {
/// 创建连接级写入上下文。
#[must_use]
pub const fn connection(phase: FramePhase) -> Self {
Self {
request_id: None,
phase,
@@ -107,8 +114,9 @@ impl WriteContext {
}
}
/// WebSocket pump 停止的稳定原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PumpExitReason {
pub enum PumpExitReason {
ClientClose,
PeerEof,
ReadError,
@@ -140,8 +148,9 @@ impl PumpExitReason {
}
}
/// 下游 WebSocket 写入失败。
#[derive(Debug, Error)]
pub(super) enum ConnectionWriteError {
pub enum ConnectionWriteError {
#[error("downstream WebSocket pump is closed")]
Closed,
#[error("downstream WebSocket write timed out after {timeout:?}")]
@@ -179,7 +188,8 @@ impl ConnectionStats {
}
}
pub(super) struct ResponsesWebSocketConnection {
/// 协调层持有的单 owner WebSocket 连接句柄。
pub struct ResponsesWebSocketConnection {
connection_id: Arc<str>,
opened_at: Instant,
expired: Arc<AtomicBool>,
@@ -209,7 +219,9 @@ impl ResponsesWebSocketConnection {
&self.connection_id
}
pub(super) fn is_expired(&self) -> bool {
/// 返回连接是否已经达到生命周期上限。
#[must_use]
pub fn is_expired(&self) -> bool {
self.expired.load(Ordering::Acquire)
}
@@ -217,7 +229,8 @@ impl ResponsesWebSocketConnection {
self.opened_at.elapsed()
}
pub(super) async fn next_event(&mut self) -> Option<ConnectionEvent> {
/// 等待下一个需要业务层处理的客户端事件。
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
let event = self.incoming.recv().await;
if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() {
self.exit_reason.get_or_insert(*reason);
@@ -227,7 +240,12 @@ impl ResponsesWebSocketConnection {
event
}
pub(super) async fn send_text(
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果。
///
/// # Errors
///
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误。
pub async fn send_text(
&mut self,
payload: String,
context: WriteContext,
@@ -391,7 +409,8 @@ impl Drop for ResponsesWebSocketConnection {
}
}
pub(super) fn spawn_connection<S, E>(
/// 为一个 socket 启动单 owner pump,并返回协调层句柄。
pub fn spawn_connection<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
@@ -1,6 +1,6 @@
//! OpenAI Responses WebSocket 的升级、连接与串行 session 编排。
mod connection;
pub mod connection;
mod forward;
mod protocol;
@@ -26,7 +26,7 @@ use gateway_core::{
use crate::{
ApiState,
openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::runtime_unavailable_response,
service::OpenAiService,
},
@@ -54,7 +54,7 @@ pub(crate) async fn responses_websocket(
let service = state.openai().clone();
let client = match authenticate_client(&service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
@@ -25,6 +25,10 @@ use gateway_admin::{
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::{
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
CodexDesktopWindowsDownloads,
},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -50,6 +54,7 @@ use gateway_admin::{
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
},
ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
@@ -143,6 +148,7 @@ impl AdminTestFixture {
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
Arc::new(StaticClientDistribution),
system,
)
.await
@@ -165,6 +171,30 @@ impl AdminTestFixture {
}
}
struct StaticClientDistribution;
#[async_trait]
impl ClientDistributionResolver for StaticClientDistribution {
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
CodexDesktopWindowsDownloads {
resolved_at: Utc::now(),
cached: false,
warning: None,
packages: vec![ClientDownloadPackage {
architecture: ClientArchitecture::X64,
source: ClientDownloadSource::MicrosoftStore,
version: Some("26.825.6671.0".to_owned()),
file_name: "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix".to_owned(),
size_bytes: Some(744_250_000),
download_url:
"https://dl.delivery.mp.microsoft.com/filestreamingservice/files/test"
.to_owned(),
expires_at: Some(Utc::now() + Duration::hours(1)),
}],
}
}
}
#[derive(Clone)]
pub(super) struct AdminTestState(AdminServices);
@@ -319,6 +349,8 @@ impl SettingsStore for MemorySettingsStore {
max_concurrent_per_account: command.max_concurrent_per_account,
request_interval_ms: command.request_interval_ms,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
@@ -1073,6 +1105,8 @@ fn test_runtime_settings() -> RuntimeSettings {
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
@@ -46,6 +46,8 @@ fn update_body() -> Value {
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91
@@ -62,6 +64,19 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy");
}
#[test]
fn settings_request_should_reject_non_semver_client_min() {
let mut body = update_body();
body["minCodexCliVersion"] = json!("v0.40.0");
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(body).expect("decode settings");
assert_eq!(
request.validate().unwrap_err().field(),
"minCodexCliVersion"
);
}
#[test]
fn settings_response_should_cover_the_full_runtime_settings_contract() {
use std::collections::BTreeMap;
@@ -90,6 +105,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_concurrent_per_account: 5,
request_interval_ms: 25,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
usage_retention_days: 32,
ops_event_retention_days: 31,
audit_retention_days: 91,
@@ -112,6 +129,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91,
@@ -159,6 +178,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
request_interval_ms: request.request_interval_ms,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
min_codex_cli_version: request.min_codex_cli_version,
usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"),
audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"),
@@ -234,6 +255,31 @@ async fn settings_post_should_replace_global_model_mappings() {
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
}
#[tokio::test]
async fn client_downloads_should_return_validated_direct_links() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(
Method::GET,
"/api/admin/settings/client-downloads/codex-desktop/windows?refresh=true",
None,
))
.await
.expect("client downloads response");
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await["data"].clone();
assert_eq!(data["packages"][0]["architecture"], "x64");
assert_eq!(data["packages"][0]["source"], "microsoft_store");
assert_eq!(data["packages"][0]["version"], "26.825.6671.0");
assert!(
data["packages"][0]["downloadUrl"]
.as_str()
.is_some_and(|url| url.starts_with("https://dl.delivery.mp.microsoft.com/"))
);
}
#[test]
fn settings_request_should_reject_invalid_model_mapping_name() {
let mut body = update_body();
@@ -1,6 +1,9 @@
use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key};
use gateway_api::openai::auth::{
ClientApiKeyAuthError, bearer_client_api_key, identify_codex_client,
};
use gateway_core::policy::CodexClientKind;
#[test]
fn bearer_client_api_key_should_reject_missing_authorization() {
@@ -88,3 +91,48 @@ fn client_auth_failure_reasons_should_be_stable_and_secret_free() {
]
);
}
#[test]
fn desktop_headers_should_take_precedence_over_embedded_cli_marker() {
let mut headers = HeaderMap::new();
headers.insert("originator", HeaderValue::from_static("Codex Desktop"));
headers.insert("version", HeaderValue::from_static("26.825.6671"));
headers.insert(
"user-agent",
HeaderValue::from_static("codex_cli_rs/0.39.0 terminal (Codex Desktop; 26.1.0)"),
);
let identified = identify_codex_client(&headers).expect("recognized desktop");
assert_eq!(identified.kind(), CodexClientKind::Desktop);
assert_eq!(
identified.version().map(ToString::to_string).as_deref(),
Some("26.825.6671")
);
}
#[test]
fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static("codex_cli_rs/0.39.0 (Linux; x86_64)"),
);
let identified = identify_codex_client(&headers).expect("recognized CLI");
assert_eq!(identified.kind(), CodexClientKind::Cli);
assert_eq!(
identified.version().map(ToString::to_string).as_deref(),
Some("0.39.0")
);
headers.insert(
"user-agent",
HeaderValue::from_static("codex-cli/not-a-version"),
);
assert!(
identify_codex_client(&headers)
.expect("recognized invalid CLI")
.version()
.is_none()
);
}
+28 -1
View File
@@ -34,7 +34,10 @@ use gateway_core::engine::{
use gateway_core::error::StoreError;
use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::policy::{
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
PlaintextClientApiKey, RateLimits,
};
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
@@ -109,6 +112,30 @@ pub(super) fn authenticated_client_for_provider(
.expect("authenticated client")
}
pub(super) fn authenticated_client_with_min_versions(
plaintext: &str,
desktop: Option<&str>,
cli: Option<&str>,
) -> AuthenticatedClient {
let snapshot =
snapshot(plaintext, "openai").with_min_codex_client_versions(CodexClientMinVersions::new(
desktop.map(|version| CodexClientVersion::parse(version).expect("Desktop min version")),
cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")),
));
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
source
.authenticate(plaintext)
.expect("authenticated client")
}
struct IgnoredClientApiKeyUsage;
impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
@@ -15,7 +15,7 @@ use gateway_core::routing::{
};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
use super::{api_router, authenticated_client, authenticated_client_with_min_versions};
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
@@ -36,6 +36,24 @@ impl ModelsExecution {
profiles: true,
})
}
fn with_cli_min() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
profiles: false,
})
}
fn with_desktop_min() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client_with_min_versions(
"sk_models_test",
Some("26.825.51511"),
None,
),
profiles: false,
})
}
}
impl ExecutionService for ModelsExecution {
@@ -144,6 +162,111 @@ async fn models_should_encode_the_service_visible_catalog() {
);
}
#[tokio::test]
async fn models_should_reject_recognized_cli_below_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex_cli_rs/0.39.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_too_old");
assert_eq!(value["error"]["client"], "codex_cli");
assert_eq!(value["error"]["current_version"], "0.39.0");
assert_eq!(value["error"]["min_version"], "0.40.0");
}
#[tokio::test]
async fn models_should_reject_recognized_cli_with_invalid_version() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex-cli/not-a-version")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_unavailable");
assert!(value["error"]["current_version"].is_null());
assert_eq!(value["error"]["min_version"], "0.40.0");
}
#[tokio::test]
async fn models_should_reject_desktop_below_its_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("originator", "Codex Desktop")
.header("version", "26.825.50000")
.header("user-agent", "codex_cli_rs/99.0.0 (Codex Desktop)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_desktop_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_too_old");
assert_eq!(value["error"]["client"], "codex_desktop");
assert_eq!(value["error"]["current_version"], "26.825.50000");
assert_eq!(value["error"]["min_version"], "26.825.51511");
}
#[tokio::test]
async fn models_should_allow_cli_at_its_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex_cli_rs/0.40.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn models_should_leave_unknown_clients_unrestricted() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "curl/8.14.1")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
let response = api_router(ModelsExecution::with_profiles())
@@ -301,11 +424,12 @@ async fn model_info_should_hide_unknown_models() {
}
#[tokio::test]
async fn models_should_require_a_valid_bearer_client_key() {
let response = api_router(ModelsExecution::new())
async fn models_should_authenticate_before_applying_the_version_gate() {
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(
Request::get("/v1/models")
.header("user-agent", "codex_cli_rs/0.1.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build unauthenticated request"),
)
@@ -190,16 +190,7 @@ fn crossing_limit_terminal_batch() -> CoordinatedEvent {
.expect("terminal WebSocket batch")
}
// 连接 runtime 没有正式公共 API;镜像测试直接编译该私有模块,以测试 socket pump,
// 不把测试钩子暴露给 gateway-api 的消费者。
#[expect(
dead_code,
reason = "the mirrored test only exercises pump-facing seams"
)]
#[path = "../../../../src/openai/responses/websocket/connection.rs"]
mod connection_under_test;
use connection_under_test::{
use gateway_api::openai::responses::websocket::connection::{
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase,
ResponsesWebSocketConnection, WriteContext, spawn_connection,
};
+1
View File
@@ -18,6 +18,7 @@ futures = "0.3.32"
futures-timer = "3.0.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
semver = "1.0.27"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
@@ -0,0 +1,152 @@
//! Codex 客户端最低版本策略。
use std::fmt;
use semver::Version;
const MAXIMUM_VERSION_LENGTH: usize = 64;
/// 可被网关识别并限制最低版本的 Codex 客户端。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexClientKind {
Desktop,
Cli,
}
impl CodexClientKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Desktop => "codex_desktop",
Self::Cli => "codex_cli",
}
}
}
/// 经严格 SemVer 校验的 Codex 客户端版本。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct CodexClientVersion(Version);
impl CodexClientVersion {
/// 仅接受没有前后空白、长度受限的标准 SemVer。
pub fn parse(value: &str) -> Result<Self, CodexClientVersionError> {
if value.is_empty()
|| value.len() > MAXIMUM_VERSION_LENGTH
|| value.trim() != value
|| value.bytes().any(|byte| byte.is_ascii_control())
{
return Err(CodexClientVersionError);
}
Version::parse(value)
.map(Self)
.map_err(|_| CodexClientVersionError)
}
}
impl fmt::Display for CodexClientVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
/// 客户端版本 wire 值不合法。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("Codex client version must be a valid semantic version")]
pub struct CodexClientVersionError;
/// 运行快照中冻结的最低版本要求;`None` 表示该客户端不限制。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodexClientMinVersions {
desktop: Option<CodexClientVersion>,
cli: Option<CodexClientVersion>,
}
impl CodexClientMinVersions {
#[must_use]
pub const fn new(desktop: Option<CodexClientVersion>, cli: Option<CodexClientVersion>) -> Self {
Self { desktop, cli }
}
#[must_use]
pub const fn desktop(&self) -> Option<&CodexClientVersion> {
self.desktop.as_ref()
}
#[must_use]
pub const fn cli(&self) -> Option<&CodexClientVersion> {
self.cli.as_ref()
}
#[must_use]
pub const fn min_for(&self, kind: CodexClientKind) -> Option<&CodexClientVersion> {
match kind {
CodexClientKind::Desktop => self.desktop(),
CodexClientKind::Cli => self.cli(),
}
}
/// 校验一个已识别客户端;配置未启用时直接放行。
pub fn enforce(
&self,
kind: CodexClientKind,
current: Option<&CodexClientVersion>,
) -> Result<(), ClientVersionRejection> {
let Some(min) = self.min_for(kind) else {
return Ok(());
};
let Some(current) = current else {
return Err(ClientVersionRejection::Unavailable {
kind,
min: min.clone(),
});
};
if current < min {
return Err(ClientVersionRejection::TooOld {
kind,
current: current.clone(),
min: min.clone(),
});
}
Ok(())
}
}
/// 已识别客户端未满足冻结的最低版本要求。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ClientVersionRejection {
#[error("recognized Codex client did not provide a valid version")]
Unavailable {
kind: CodexClientKind,
min: CodexClientVersion,
},
#[error("recognized Codex client is below the required minimum version")]
TooOld {
kind: CodexClientKind,
current: CodexClientVersion,
min: CodexClientVersion,
},
}
impl ClientVersionRejection {
#[must_use]
pub const fn kind(&self) -> CodexClientKind {
match self {
Self::Unavailable { kind, .. } | Self::TooOld { kind, .. } => *kind,
}
}
#[must_use]
pub const fn current(&self) -> Option<&CodexClientVersion> {
match self {
Self::Unavailable { .. } => None,
Self::TooOld { current, .. } => Some(current),
}
}
#[must_use]
pub const fn min(&self) -> &CodexClientVersion {
match self {
Self::Unavailable { min, .. } | Self::TooOld { min, .. } => min,
}
}
}
@@ -2,6 +2,12 @@
//!
//! Client API Key 冻结账号分组权限;模型名称不参与权限判断。
mod client_version;
pub use client_version::{
ClientVersionRejection, CodexClientKind, CodexClientMinVersions, CodexClientVersion,
};
use std::fmt;
use std::sync::Arc;
@@ -16,7 +16,10 @@ use crate::engine::provider::{ProviderCatalogGeneration, ProviderRegistry};
use crate::error::RoutingError;
use crate::health::{HealthProbe, HealthState};
use crate::operation::Operation;
use crate::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use crate::policy::{
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
PlaintextClientApiKey, RateLimits,
};
use crate::task::{
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
WorkerDefinitionError, WorkerId, WorkerKind, WorkerRegistration, WorkerRunnable,
@@ -43,6 +46,8 @@ pub struct SnapshotSettingsFacts {
request_interval_ms: u64,
rotation_strategy: String,
model_mappings: BTreeMap<String, String>,
min_codex_desktop_version: Option<String>,
min_codex_cli_version: Option<String>,
}
impl SnapshotSettingsFacts {
@@ -52,12 +57,16 @@ impl SnapshotSettingsFacts {
request_interval_ms: u64,
rotation_strategy: impl Into<String>,
model_mappings: BTreeMap<String, String>,
min_codex_desktop_version: Option<String>,
min_codex_cli_version: Option<String>,
) -> Self {
Self {
max_concurrent_per_account,
request_interval_ms,
rotation_strategy: rotation_strategy.into(),
model_mappings,
min_codex_desktop_version,
min_codex_cli_version,
}
}
}
@@ -356,6 +365,22 @@ async fn compile_runtime_snapshot(
let account_directory = Arc::new(RuntimeAccountDirectory::new(accounts));
let model_mappings = facts.settings.model_mappings;
let min_client_versions = CodexClientMinVersions::new(
facts
.settings
.min_codex_desktop_version
.as_deref()
.map(CodexClientVersion::parse)
.transpose()
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?,
facts
.settings
.min_codex_cli_version
.as_deref()
.map(CodexClientVersion::parse)
.transpose()
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?,
);
let rotation_strategy = RotationStrategy::parse(facts.settings.rotation_strategy.as_str())
.ok_or(RuntimeSnapshotCompileError::InvalidData)?;
let selection_policy = AccountSelectionPolicy::new(
@@ -420,6 +445,7 @@ async fn compile_runtime_snapshot(
.with_model_mappings(model_mappings)
.with_account_directory(account_directory)
.with_known_provider_catalogs(known_provider_catalogs)
.with_min_codex_client_versions(min_client_versions)
})
}
@@ -437,6 +463,7 @@ pub struct RuntimeSnapshot {
known_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
account_directory: Arc<RuntimeAccountDirectory>,
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
min_codex_client_versions: CodexClientMinVersions,
}
impl RuntimeSnapshot {
@@ -518,6 +545,7 @@ impl RuntimeSnapshot {
known_provider_catalogs: Arc::new(known_provider_catalogs),
account_directory: Arc::new(RuntimeAccountDirectory::default()),
client_policies: Arc::new(client_policy_map),
min_codex_client_versions: CodexClientMinVersions::default(),
})
}
@@ -533,6 +561,12 @@ impl RuntimeSnapshot {
self
}
#[must_use]
pub fn with_min_codex_client_versions(mut self, versions: CodexClientMinVersions) -> Self {
self.min_codex_client_versions = versions;
self
}
#[must_use]
fn with_known_provider_catalogs(mut self, providers: BTreeSet<ProviderKind>) -> Self {
self.known_provider_catalogs = Arc::new(providers);
@@ -711,6 +745,11 @@ impl RuntimeSnapshot {
self.client_policies.values()
}
#[must_use]
pub const fn min_codex_client_versions(&self) -> &CodexClientMinVersions {
&self.min_codex_client_versions
}
pub fn plan(
&self,
public_model: &PublicModelId,
@@ -1,6 +1,9 @@
use std::sync::Arc;
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::policy::{
ClientApiKeyId, ClientPolicy, ClientVersionRejection, CodexClientKind, CodexClientMinVersions,
CodexClientVersion, PlaintextClientApiKey, RateLimits,
};
use gateway_core::routing::{ClientRoutingScope, FrozenAccountScope, RuntimeAccountDirectory};
fn plaintext(value: &str) -> PlaintextClientApiKey {
@@ -53,3 +56,55 @@ fn plaintext_client_key_debug_should_be_redacted() {
assert!(!debug.contains("must_not_appear"));
assert_eq!(key.expose_for_auth(), "sk_must_not_appear");
}
#[test]
fn client_version_should_require_strict_semver() {
assert!(CodexClientVersion::parse("0.152.0").is_ok());
assert!(CodexClientVersion::parse("v0.152.0").is_err());
assert!(CodexClientVersion::parse("0.152").is_err());
assert!(CodexClientVersion::parse(" 0.152.0").is_err());
assert!(CodexClientVersion::parse("18446744073709551616.0.0").is_err());
}
#[test]
fn min_client_versions_should_reject_missing_and_old_recognized_clients() {
let min = CodexClientVersion::parse("0.40.0").expect("min version");
let policy = CodexClientMinVersions::new(None, Some(min.clone()));
let old = CodexClientVersion::parse("0.39.0").expect("old version");
let current = CodexClientVersion::parse("0.40.0").expect("current version");
assert_eq!(
policy.enforce(CodexClientKind::Cli, None),
Err(ClientVersionRejection::Unavailable {
kind: CodexClientKind::Cli,
min: min.clone(),
})
);
assert_eq!(
policy.enforce(CodexClientKind::Cli, Some(&old)),
Err(ClientVersionRejection::TooOld {
kind: CodexClientKind::Cli,
current: old,
min,
})
);
assert_eq!(policy.enforce(CodexClientKind::Cli, Some(&current)), Ok(()));
assert_eq!(policy.enforce(CodexClientKind::Desktop, None), Ok(()));
}
#[test]
fn min_client_versions_should_follow_semver_precedence() {
let min = CodexClientVersion::parse("1.0.0").expect("min version");
let policy = CodexClientMinVersions::new(None, Some(min));
let prerelease = CodexClientVersion::parse("1.0.0-rc.1").expect("prerelease");
let release = CodexClientVersion::parse("1.0.0").expect("release");
let newer = CodexClientVersion::parse("1.0.1").expect("newer release");
assert!(
policy
.enforce(CodexClientKind::Cli, Some(&prerelease))
.is_err()
);
assert_eq!(policy.enforce(CodexClientKind::Cli, Some(&release)), Ok(()));
assert_eq!(policy.enforce(CodexClientKind::Cli, Some(&newer)), Ok(()));
}
@@ -336,7 +336,60 @@ fn publisher_should_contribute_reconciliation_and_subscription_workers() {
);
}
#[test]
fn compiler_should_freeze_valid_client_min_versions() {
let store = Arc::new(TestSnapshotStore::new(Ok(facts_with_min_versions(
1,
1,
Some("26.825.6671".to_owned()),
Some("0.40.0".to_owned()),
))));
let snapshot = block_on(compiler(store).compile()).expect("valid min versions");
assert_eq!(
snapshot
.min_codex_client_versions()
.desktop()
.map(ToString::to_string)
.as_deref(),
Some("26.825.6671")
);
assert_eq!(
snapshot
.min_codex_client_versions()
.cli()
.map(ToString::to_string)
.as_deref(),
Some("0.40.0")
);
}
#[test]
fn compiler_should_reject_invalid_persisted_client_min_version() {
let store = Arc::new(TestSnapshotStore::new(Ok(facts_with_min_versions(
1,
1,
None,
Some("v0.40.0".to_owned()),
))));
assert_eq!(
block_on(compiler(store).compile()).expect_err("invalid min version"),
RuntimeSnapshotCompileError::InvalidData
);
}
fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts {
facts_with_min_versions(config_revision, observed_current_revision, None, None)
}
fn facts_with_min_versions(
config_revision: u64,
observed_current_revision: u64,
desktop: Option<String>,
cli: Option<String>,
) -> SnapshotFacts {
SnapshotFacts::new(
revision(config_revision),
revision(observed_current_revision),
@@ -345,6 +398,8 @@ fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts
50,
"smart",
BTreeMap::from([("public-model".to_owned(), "upstream-model".to_owned())]),
desktop,
cli,
),
vec![SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key_one").expect("key ID"),
+1
View File
@@ -18,6 +18,7 @@ gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
hex = "0.4.3"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
scraper = "0.27.0"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
@@ -0,0 +1,399 @@
//! RG-Adguard Store 页面解析与官方 Codex Desktop 下载回退。
use std::{collections::BTreeMap, time::Duration};
use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use futures::StreamExt as _;
use gateway_admin::{
model::client_distribution::{
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
CodexDesktopWindowsDownloads,
},
ports::client_distribution::ClientDistributionResolver,
};
use reqwest::{Client, Url, redirect::Policy};
use scraper::{Html, Selector};
use tokio::sync::Mutex;
const RG_ADGUARD_ENDPOINT: &str = "https://store.rg-adguard.net/api/GetFiles";
const RG_ADGUARD_ORIGIN: &str = "https://store.rg-adguard.net";
const CODEX_STORE_PRODUCT_ID: &str = "9PLM9XGG6VKS";
const PACKAGE_PUBLISHER_ID: &str = "2p2nqsd0c76g0";
const MAXIMUM_RESPONSE_BYTES: usize = 512 * 1024;
const DYNAMIC_CACHE_SECONDS: i64 = 300;
const FALLBACK_CACHE_SECONDS: u64 = 60;
const MINIMUM_REMAINING_LINK_SECONDS: i64 = 600;
const OFFICIAL_X64_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-x64.msix";
const OFFICIAL_ARM64_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/ChatGPT-arm64.msix";
/// 下载页请求或解析失败的稳定分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientDistributionResolutionErrorKind {
/// 上游请求不可用。
Unavailable,
/// 上游响应不满足受信合同。
InvalidResponse,
}
pub(crate) struct RgAdguardClientDistribution {
client: Option<Client>,
cache: Mutex<Option<CachedDownloads>>,
}
struct CachedDownloads {
cached_at: std::time::Instant,
valid_until: std::time::Instant,
value: CodexDesktopWindowsDownloads,
}
impl RgAdguardClientDistribution {
pub(crate) fn new() -> Self {
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12))
.redirect(Policy::none())
.build()
.inspect_err(|_| {
tracing::warn!(
"download resolver HTTP client initialization failed; official downloads will be used"
);
})
.ok();
Self {
client,
cache: Mutex::new(None),
}
}
async fn resolve_uncached(&self) -> (CodexDesktopWindowsDownloads, Duration) {
let now = Utc::now();
match self.fetch_store_packages(now).await {
Ok(packages) => {
let (packages, warning) = complete_with_official_fallback(packages);
let valid_seconds = packages
.iter()
.filter_map(|package| package.expires_at)
.map(|expires_at| {
(expires_at - now).num_seconds() - MINIMUM_REMAINING_LINK_SECONDS
})
.min()
.unwrap_or(DYNAMIC_CACHE_SECONDS)
.clamp(1, DYNAMIC_CACHE_SECONDS);
(
CodexDesktopWindowsDownloads {
resolved_at: now,
cached: false,
warning,
packages,
},
Duration::from_secs(u64::try_from(valid_seconds).unwrap_or(1)),
)
}
Err(error_kind) => {
tracing::warn!(
error_kind = ?error_kind,
"RG-Adguard package resolution failed; using official downloads"
);
(
official_fallback(now),
Duration::from_secs(FALLBACK_CACHE_SECONDS),
)
}
}
}
async fn fetch_store_packages(
&self,
now: DateTime<Utc>,
) -> Result<Vec<ClientDownloadPackage>, ClientDistributionResolutionErrorKind> {
let client = self
.client
.as_ref()
.ok_or(ClientDistributionResolutionErrorKind::Unavailable)?;
let response = client
.post(RG_ADGUARD_ENDPOINT)
.header("origin", RG_ADGUARD_ORIGIN)
.header("referer", format!("{RG_ADGUARD_ORIGIN}/"))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36",
)
.form(&[
("type", "ProductId"),
("url", CODEX_STORE_PRODUCT_ID),
("ring", "Retail"),
("lang", ""),
])
.send()
.await
.map_err(|_| ClientDistributionResolutionErrorKind::Unavailable)?;
if !response.status().is_success() {
return Err(ClientDistributionResolutionErrorKind::Unavailable);
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| ClientDistributionResolutionErrorKind::Unavailable)?;
if bytes.len().saturating_add(chunk.len()) > MAXIMUM_RESPONSE_BYTES {
return Err(ClientDistributionResolutionErrorKind::InvalidResponse);
}
bytes.extend_from_slice(&chunk);
}
let html = std::str::from_utf8(&bytes)
.map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?;
parse_store_packages(html, now)
}
}
#[async_trait]
impl ClientDistributionResolver for RgAdguardClientDistribution {
async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads {
let requested_at = std::time::Instant::now();
let mut cache = self.cache.lock().await;
if let Some(cached) = cache.as_ref()
&& cached.valid_until > std::time::Instant::now()
&& (!refresh || cached.cached_at >= requested_at)
{
let mut value = cached.value.clone();
value.cached = true;
return value;
}
let (value, ttl) = self.resolve_uncached().await;
let cached_at = std::time::Instant::now();
*cache = Some(CachedDownloads {
cached_at,
valid_until: cached_at + ttl,
value: value.clone(),
});
value
}
}
/// 从 RG-Adguard HTML 中选择每个受支持架构的最新有效主包。
///
/// # Errors
///
/// 页面没有任何满足包身份、下载地址和过期时间约束的结果时返回无效响应。
pub fn parse_store_packages(
html: &str,
now: DateTime<Utc>,
) -> Result<Vec<ClientDownloadPackage>, ClientDistributionResolutionErrorKind> {
let document = Html::parse_document(html);
let row_selector = Selector::parse("table.tftable tr")
.map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?;
let link_selector =
Selector::parse("a").map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?;
let cell_selector = Selector::parse("td")
.map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?;
let mut selected = BTreeMap::<ClientArchitecture, ParsedStorePackage>::new();
for row in document.select(&row_selector) {
let Some(link) = row.select(&link_selector).next() else {
continue;
};
let file_name = link.text().collect::<String>().trim().to_owned();
let Some((architecture, version)) = parse_package_file_name(&file_name) else {
continue;
};
let Some(href) = link.value().attr("href") else {
continue;
};
let Some(download_url) = validate_download_url(href) else {
continue;
};
let cells = row
.select(&cell_selector)
.map(|cell| cell.text().collect::<String>().trim().to_owned())
.collect::<Vec<_>>();
let Some(expires_at) = cells.iter().find_map(|cell| parse_expiry(cell)) else {
continue;
};
if expires_at <= now + TimeDelta::seconds(MINIMUM_REMAINING_LINK_SECONDS) {
continue;
}
let size_bytes = cells.iter().find_map(|cell| parse_size_bytes(cell));
let candidate = ParsedStorePackage {
version,
package: ClientDownloadPackage {
architecture,
source: ClientDownloadSource::MicrosoftStore,
version: Some(version.to_string()),
file_name,
size_bytes,
download_url,
expires_at: Some(expires_at),
},
};
let replace = selected
.get(&architecture)
.is_none_or(|existing| candidate.version > existing.version);
if replace {
selected.insert(architecture, candidate);
}
}
if selected.is_empty() {
return Err(ClientDistributionResolutionErrorKind::InvalidResponse);
}
Ok(selected.into_values().map(|value| value.package).collect())
}
#[derive(Debug)]
struct ParsedStorePackage {
version: StorePackageVersion,
package: ClientDownloadPackage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct StorePackageVersion([u64; 4]);
impl std::fmt::Display for StorePackageVersion {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"{}.{}.{}.{}",
self.0[0], self.0[1], self.0[2], self.0[3]
)
}
}
fn parse_package_file_name(file_name: &str) -> Option<(ClientArchitecture, StorePackageVersion)> {
let suffix = format!("__{PACKAGE_PUBLISHER_ID}.msix");
let body = file_name
.strip_prefix("OpenAI.Codex_")?
.strip_suffix(&suffix)?;
let (version, architecture) = body.rsplit_once('_')?;
let architecture = match architecture {
"x64" => ClientArchitecture::X64,
"arm64" => ClientArchitecture::Arm64,
_ => return None,
};
let mut parts = version.split('.');
let values = [
parts.next()?.parse::<u64>().ok()?,
parts.next()?.parse::<u64>().ok()?,
parts.next()?.parse::<u64>().ok()?,
parts.next()?.parse::<u64>().ok()?,
];
if parts.next().is_some() {
return None;
}
Some((architecture, StorePackageVersion(values)))
}
fn validate_download_url(value: &str) -> Option<String> {
let url = Url::parse(value).ok()?;
if !matches!(url.scheme(), "http" | "https")
|| !url.username().is_empty()
|| url.password().is_some()
|| url.port().is_some()
|| !matches!(
url.host_str(),
Some("dl.delivery.mp.microsoft.com" | "tlu.dl.delivery.mp.microsoft.com")
)
|| !url.path().starts_with("/filestreamingservice/files/")
|| url.path() == "/filestreamingservice/files/"
{
return None;
}
Some(url.into())
}
fn parse_expiry(value: &str) -> Option<DateTime<Utc>> {
let value = value.strip_suffix(" GMT")?;
NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
.ok()
.map(|value| value.and_utc())
}
fn parse_size_bytes(value: &str) -> Option<u64> {
let mut parts = value.split_ascii_whitespace();
let amount = parts.next()?.replace(',', "");
let unit = parts.next()?;
if parts.next().is_some() {
return None;
}
let multiplier = match unit {
"B" => 1_u128,
"KB" => 1_000,
"MB" => 1_000_000,
"GB" => 1_000_000_000,
_ => return None,
};
let (whole, fraction) = amount.split_once('.').unwrap_or((amount.as_str(), ""));
if fraction.len() > 6 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let whole = whole.parse::<u128>().ok()?;
let scale = 10_u128.checked_pow(u32::try_from(fraction.len()).ok()?)?;
let fraction = if fraction.is_empty() {
0
} else {
fraction.parse::<u128>().ok()?
};
let bytes = whole
.checked_mul(multiplier)?
.checked_add(fraction.checked_mul(multiplier)?.checked_div(scale)?)?;
u64::try_from(bytes).ok()
}
fn official_fallback(now: DateTime<Utc>) -> CodexDesktopWindowsDownloads {
let packages = [ClientArchitecture::X64, ClientArchitecture::Arm64]
.into_iter()
.map(official_package)
.collect();
CodexDesktopWindowsDownloads {
resolved_at: now,
cached: false,
warning: Some(
"Microsoft Store 离线直链暂不可用,已切换为 OpenAI 官方稳定安装包。".to_owned(),
),
packages,
}
}
/// 为动态结果中缺失的架构补齐 OpenAI 官方稳定包。
#[must_use]
pub fn complete_with_official_fallback(
packages: Vec<ClientDownloadPackage>,
) -> (Vec<ClientDownloadPackage>, Option<String>) {
let mut packages = packages
.into_iter()
.map(|package| (package.architecture, package))
.collect::<BTreeMap<_, _>>();
let mut missing = Vec::new();
for architecture in [ClientArchitecture::X64, ClientArchitecture::Arm64] {
if let std::collections::btree_map::Entry::Vacant(entry) = packages.entry(architecture) {
missing.push(architecture.as_str());
entry.insert(official_package(architecture));
}
}
let warning = (!missing.is_empty()).then(|| {
format!(
"{} 的 Microsoft Store 临时直链不可用,已切换为 OpenAI 官方稳定安装包。",
missing.join("、")
)
});
(packages.into_values().collect(), warning)
}
fn official_package(architecture: ClientArchitecture) -> ClientDownloadPackage {
let (download_url, file_name) = match architecture {
ClientArchitecture::X64 => (OFFICIAL_X64_URL, "ChatGPT-x64.msix"),
ClientArchitecture::Arm64 => (OFFICIAL_ARM64_URL, "ChatGPT-arm64.msix"),
};
ClientDownloadPackage {
architecture,
source: ClientDownloadSource::OfficialOpenAi,
version: None,
file_name: file_name.to_owned(),
size_bytes: None,
download_url: download_url.to_owned(),
expires_at: None,
}
}
+14 -1
View File
@@ -1,5 +1,6 @@
//! 网关进程与操作系统能力:配置发现、日志、任务、自更新与 serve/drain。
pub mod client_distribution;
pub mod config;
mod logging;
pub mod serve;
@@ -9,7 +10,9 @@ pub mod workers;
use std::sync::Arc;
use axum::Router;
use gateway_admin::ports::system::SystemOperations;
use gateway_admin::ports::{
client_distribution::ClientDistributionResolver, system::SystemOperations,
};
use gateway_core::engine::CancellationToken;
use gateway_core::health::WorkerHealthSource;
use gateway_core::lifecycle::ConnectionLifecycle;
@@ -17,6 +20,7 @@ use gateway_core::task::{WorkerContribution, WorkerLeaderLeasePort};
pub use config::{ConfigError, HostConfig, LoadableConfig, load_config};
use self::client_distribution::RgAdguardClientDistribution;
use self::logging::{LogGuard, initialize_logging};
use self::serve::{ConnectionTracker, serve_router};
use self::system_update::ProcessSystemOperations;
@@ -30,6 +34,7 @@ pub struct HostBundle {
connections: Arc<ConnectionTracker>,
workers: WorkerSupervisor,
system: Arc<ProcessSystemOperations>,
client_distribution: Arc<RgAdguardClientDistribution>,
}
/// 在启动其他包之前初始化进程级能力。
@@ -42,6 +47,7 @@ pub async fn initialize(config: HostConfig) -> Result<HostBundle, HostError> {
cancellation.clone(),
config.system_update.clone(),
));
let client_distribution = Arc::new(RgAdguardClientDistribution::new());
Ok(HostBundle {
config,
_log_guard: log_guard,
@@ -49,6 +55,7 @@ pub async fn initialize(config: HostConfig) -> Result<HostBundle, HostError> {
connections,
workers,
system,
client_distribution,
})
}
@@ -68,6 +75,12 @@ impl HostBundle {
self.system.clone()
}
#[must_use]
/// 返回惰性下载解析能力;网络请求只会在管理 API 调用时发生。
pub fn client_distribution_resolver(&self) -> Arc<dyn ClientDistributionResolver> {
self.client_distribution.clone()
}
#[must_use]
pub fn connection_lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
self.connections.clone()
@@ -0,0 +1,72 @@
use chrono::{TimeZone as _, Utc};
use gateway_admin::model::client_distribution::{
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
};
use gateway_host::client_distribution::{
ClientDistributionResolutionErrorKind, complete_with_official_fallback, parse_store_packages,
};
#[test]
fn parser_selects_latest_valid_package_for_each_architecture() {
let now = Utc
.with_ymd_and_hms(2026, 9, 1, 0, 0, 0)
.single()
.expect("time");
let html = r#"
<table class="tftable">
<tr><td><a href="http://tlu.dl.delivery.mp.microsoft.com/filestreamingservice/files/old?x=1">OpenAI.Codex_26.800.1.0_x64__2p2nqsd0c76g0.msix</a></td><td>2026-09-01 01:00:00 GMT</td><td>700 MB</td></tr>
<tr><td><a href="http://tlu.dl.delivery.mp.microsoft.com/filestreamingservice/files/new?x=1">OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix</a></td><td>2026-09-01 02:00:00 GMT</td><td>744.25 MB</td></tr>
<tr><td><a href="https://dl.delivery.mp.microsoft.com/filestreamingservice/files/arm?x=1">OpenAI.Codex_26.825.6671.0_arm64__2p2nqsd0c76g0.msix</a></td><td>2026-09-01 02:00:00 GMT</td><td>710 MB</td></tr>
<tr><td><a href="https://evil.example/filestreamingservice/files/bad">OpenAI.Codex_99.0.0.0_x64__2p2nqsd0c76g0.msix</a></td><td>2026-09-01 02:00:00 GMT</td><td>1 GB</td></tr>
<tr><td><a href="https://dl.delivery.mp.microsoft.com/filestreamingservice/files/map">OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.BlockMap</a></td><td>2026-09-01 02:00:00 GMT</td><td>1 MB</td></tr>
</table>
"#;
let packages = parse_store_packages(html, now).expect("packages");
assert_eq!(packages.len(), 2);
assert_eq!(packages[0].architecture, ClientArchitecture::X64);
assert_eq!(packages[0].version.as_deref(), Some("26.825.6671.0"));
assert!(packages[0].download_url.starts_with("http://"));
assert_eq!(packages[0].size_bytes, Some(744_250_000));
assert_eq!(packages[1].architecture, ClientArchitecture::Arm64);
assert!(packages[1].download_url.starts_with("https://"));
}
#[test]
fn parser_rejects_expiring_results() {
let now = Utc
.with_ymd_and_hms(2026, 9, 1, 0, 0, 0)
.single()
.expect("time");
let html = r#"
<table class="tftable">
<tr><td><a href="https://dl.delivery.mp.microsoft.com/filestreamingservice/files/x64">OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix</a></td><td>2026-09-01 00:09:59 GMT</td><td>700 MB</td></tr>
</table>
"#;
assert_eq!(
parse_store_packages(html, now),
Err(ClientDistributionResolutionErrorKind::InvalidResponse)
);
}
#[test]
fn missing_architecture_uses_only_its_official_fallback() {
let dynamic = ClientDownloadPackage {
architecture: ClientArchitecture::X64,
source: ClientDownloadSource::MicrosoftStore,
version: Some("26.825.6671.0".to_owned()),
file_name: "store-x64.msix".to_owned(),
size_bytes: None,
download_url: "https://dl.delivery.mp.microsoft.com/filestreamingservice/files/x64"
.to_owned(),
expires_at: None,
};
let (packages, warning) = complete_with_official_fallback(vec![dynamic]);
assert_eq!(packages[0].source, ClientDownloadSource::MicrosoftStore);
assert_eq!(packages[1].source, ClientDownloadSource::OfficialOpenAi);
assert!(warning.is_some_and(|value| value.contains("arm64")));
}
@@ -1,3 +1,4 @@
mod client_distribution;
mod config;
mod logging;
mod serve;
@@ -45,6 +45,8 @@ impl SettingsStore for AdminSettingsStoreAdapter {
request_interval_ms: command.request_interval_ms,
rotation_strategy: command.rotation_strategy.as_str().to_owned(),
model_mappings: store_model_mappings(command.model_mappings),
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
@@ -61,6 +63,8 @@ impl SettingsStore for AdminSettingsStoreAdapter {
"max_concurrent_per_account".to_owned(),
"request_interval_ms".to_owned(),
"rotation_strategy".to_owned(),
"min_codex_desktop_version".to_owned(),
"min_codex_cli_version".to_owned(),
"retention".to_owned(),
],
),
@@ -162,6 +166,8 @@ pub(crate) fn admin_runtime_settings(
max_concurrent_per_account: settings.max_concurrent_per_account,
request_interval_ms: settings.request_interval_ms,
rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
@@ -9,6 +9,7 @@ use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, Transaction};
use gateway_core::engine::credential::RotationStrategy;
use gateway_core::policy::CodexClientVersion;
use gateway_core::provider_ports::{
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
};
@@ -25,6 +26,8 @@ pub struct RuntimeSettings {
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -49,6 +52,8 @@ impl fmt::Debug for RuntimeSettings {
.field("request_interval_ms", &self.request_interval_ms)
.field("rotation_strategy", &self.rotation_strategy)
.field("model_mappings", &self.model_mappings)
.field("min_codex_desktop_version", &self.min_codex_desktop_version)
.field("min_codex_cli_version", &self.min_codex_cli_version)
.field("usage_retention_days", &self.usage_retention_days)
.field("ops_event_retention_days", &self.ops_event_retention_days)
.field("audit_retention_days", &self.audit_retention_days)
@@ -66,6 +71,8 @@ pub struct RuntimeSettingsUpdate {
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -94,6 +101,8 @@ impl RuntimeSettingsUpdate {
|| self.ops_event_retention_days == 0
|| self.audit_retention_days == 0
|| !valid_model_mappings(&self.model_mappings)
|| !valid_client_version(self.min_codex_desktop_version.as_deref())
|| !valid_client_version(self.min_codex_cli_version.as_deref())
|| RotationStrategy::parse(&self.rotation_strategy).is_none()
{
return Err(StoreError::InvalidData {
@@ -154,7 +163,8 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul
"select config_revision, admin_api_key, refresh_margin_seconds,
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, updated_at
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at
from runtime_settings where id = 1",
)
.fetch_optional(pool)
@@ -192,7 +202,8 @@ pub(crate) async fn load_runtime_settings_in_transaction(
"select config_revision, admin_api_key, refresh_margin_seconds,
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, updated_at
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at
from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
@@ -225,7 +236,9 @@ pub(crate) async fn update_runtime_settings_in_transaction(
usage_retention_days = $8,
ops_event_retention_days = $9,
audit_retention_days = $10,
updated_at = now()
min_codex_desktop_version = $11,
min_codex_cli_version = $12,
updated_at = now()
where id = 1
returning config_revision",
)
@@ -239,6 +252,8 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.bind(i64::from(update.usage_retention_days))
.bind(i64::from(update.ops_event_retention_days))
.bind(i64::from(update.audit_retention_days))
.bind(update.min_codex_desktop_version.as_deref())
.bind(update.min_codex_cli_version.as_deref())
.fetch_optional(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
@@ -298,6 +313,8 @@ type RuntimeSettingsRow = (
i64,
i64,
i64,
Option<String>,
Option<String>,
DateTime<Utc>,
);
@@ -314,7 +331,9 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
usage_retention_days: to_u32(row.8)?,
ops_event_retention_days: to_u32(row.9)?,
audit_retention_days: to_u32(row.10)?,
updated_at: row.11,
min_codex_desktop_version: row.11,
min_codex_cli_version: row.12,
updated_at: row.13,
})
}
@@ -353,3 +372,7 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
&& value.len() <= max_len
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
@@ -26,6 +26,8 @@ pub struct SnapshotRuntimeSettings {
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -143,6 +145,8 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
data.settings.request_interval_ms,
data.settings.rotation_strategy,
data.settings.model_mappings,
data.settings.min_codex_desktop_version,
data.settings.min_codex_cli_version,
);
let client_policies = data
.client_api_keys
@@ -222,11 +226,14 @@ async fn load_settings(
i64,
String,
sqlx::types::Json<BTreeMap<String, String>>,
Option<String>,
Option<String>,
),
>(
"select config_revision, refresh_margin_seconds, refresh_concurrency,
max_concurrent_per_account, request_interval_ms, rotation_strategy,
model_mappings_json
model_mappings_json, min_codex_desktop_version,
min_codex_cli_version
from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
@@ -245,6 +252,8 @@ async fn load_settings(
request_interval_ms: to_u64(row.4)?,
rotation_strategy: row.5,
model_mappings: row.6.0,
min_codex_desktop_version: row.7,
min_codex_cli_version: row.8,
},
))
}
@@ -19,6 +19,8 @@ fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
("gpt-5.4".to_owned(), "gpt-5.5".to_owned()),
("grok-latest".to_owned(), "grok-4.5".to_owned()),
]),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
@@ -41,6 +43,16 @@ fn runtime_settings_reject_invalid_model_mapping() {
assert!(settings.validate().is_err());
}
#[test]
fn runtime_settings_reject_non_semver_client_min() {
let settings = RuntimeSettingsUpdate {
min_codex_cli_version: Some("v0.40.0".to_owned()),
..settings_with_margin(3_600)
};
assert!(settings.validate().is_err());
}
#[tokio::test]
async fn refresh_margin_change_should_preserve_existing_account_refresh_facts() {
let Some(database) = TestDatabase::create("refresh_margin_reschedule").await else {
@@ -69,6 +81,33 @@ async fn refresh_margin_change_should_preserve_existing_account_refresh_facts()
database.close().await;
}
#[tokio::test]
async fn client_min_versions_should_round_trip_as_nullable_settings() {
let Some(database) = TestDatabase::create("client_min_versions").await else {
return;
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let mut update = settings_with_margin(3_600);
update.min_codex_desktop_version = Some("26.825.6671".to_owned());
update.min_codex_cli_version = Some("0.40.0".to_owned());
repository
.update_runtime_settings(update)
.await
.expect("update client min versions");
let settings = repository
.load_runtime_settings()
.await
.expect("load client min versions");
assert_eq!(
settings.min_codex_desktop_version.as_deref(),
Some("26.825.6671")
);
assert_eq!(settings.min_codex_cli_version.as_deref(), Some("0.40.0"));
database.close().await;
}
#[tokio::test]
async fn unchanged_refresh_margin_should_preserve_existing_account_refresh_facts() {
let Some(database) = TestDatabase::create("refresh_margin_unchanged").await else {
@@ -58,14 +58,19 @@ struct CentralDirectory {
entries: u16,
}
/// ZIP central directory 中已经校验的 Codex Core 条目。
#[derive(Debug)]
pub(super) struct CoreEntry {
pub(super) name: Vec<u8>,
pub struct CoreEntry {
/// ZIP 内完整条目名。
pub name: Vec<u8>,
flags: u16,
compression: u16,
pub(super) compressed_size: u64,
pub(super) uncompressed_size: u64,
pub(super) local_header_offset: u64,
/// 压缩后的字节数。
pub compressed_size: u64,
/// 解压后的字节数。
pub uncompressed_size: u64,
/// local file header 在制品内的偏移。
pub local_header_offset: u64,
}
pub(super) async fn fetch_codex_core_version(
@@ -184,7 +189,9 @@ async fn read_range(
Ok(body)
}
pub(super) fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> {
/// 解析严格的 `bytes start-end/size` Content-Range。
#[must_use]
pub fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> {
let value = value.strip_prefix("bytes ")?;
let (range, size) = value.split_once('/')?;
let (start, end) = range.split_once('-')?;
@@ -240,7 +247,12 @@ fn parse_eocd(
})
}
pub(super) fn find_core_entry(
/// 在 central directory 中定位唯一的 bundled Codex Core。
///
/// # Errors
///
/// ZIP 元数据无效、条目不受支持,或 Core 不唯一时返回制品错误。
pub fn find_core_entry(
central: &[u8],
expected_entries: u16,
) -> Result<CoreEntry, CodexDesktopArtifactError> {
@@ -466,16 +478,19 @@ fn validate_macho_header(header: &[u8]) -> Result<(), CodexDesktopArtifactError>
Ok(())
}
/// 跨分块查找 bundled Core 版本标记的有界扫描器。
#[derive(Default)]
pub(super) struct CoreVersionScanner {
pub struct CoreVersionScanner {
carry: Vec<u8>,
}
impl CoreVersionScanner {
pub(super) fn push(
&mut self,
bytes: &[u8],
) -> Result<Option<String>, CodexDesktopArtifactError> {
/// 推入一个解压分块,并在发现完整合法版本时返回它。
///
/// # Errors
///
/// 标记后的版本超过限制或不满足受支持格式时返回制品错误。
pub fn push(&mut self, bytes: &[u8]) -> Result<Option<String>, CodexDesktopArtifactError> {
let mut window = Vec::with_capacity(self.carry.len() + bytes.len());
window.extend_from_slice(&self.carry);
window.extend_from_slice(bytes);
@@ -19,7 +19,7 @@ use url::Url;
use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version};
mod desktop_artifact;
pub mod desktop_artifact;
/// Codex Desktop 官方 appcast 地址。
pub const CODEX_DESKTOP_APPCAST_URL: &str =
@@ -1,8 +1,6 @@
#[allow(dead_code)]
#[path = "../../../src/transport/profile/desktop_artifact.rs"]
mod subject;
use subject::{CoreVersionScanner, find_core_entry, parse_content_range};
use provider_openai::transport::profile::desktop_artifact::{
CoreVersionScanner, find_core_entry, parse_content_range,
};
#[test]
fn scanner_accepts_stable_and_multi_part_alpha_versions_across_chunks() {
+1
View File
@@ -1,2 +1,3 @@
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
93219760c733616e7bba9a8933c207b26752b2ebf7e6edfe1ef42ba227c50a7d 0002_runtime_pressure_observability.sql
1b6ffab434c754814aebb0eee3b650bd9770010059472bccf5bc856b319880ca 0003_client_min_versions.sql
@@ -0,0 +1,21 @@
alter table runtime_settings
add column min_codex_desktop_version text,
add column min_codex_cli_version text,
add constraint runtime_settings_client_versions_ck check (
(
min_codex_desktop_version is null
or (
octet_length(min_codex_desktop_version) between 1 and 64
and min_codex_desktop_version = btrim(min_codex_desktop_version)
and min_codex_desktop_version !~ '[[:cntrl:]]'
)
)
and (
min_codex_cli_version is null
or (
octet_length(min_codex_cli_version) between 1 and 64
and min_codex_cli_version = btrim(min_codex_cli_version)
and min_codex_cli_version !~ '[[:cntrl:]]'
)
)
);
+55
View File
@@ -17,6 +17,28 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
已启用分组成员的并集。分组可以混合 `openai` 与 `xai` 账号;同一请求只会在模型能力明确匹配且满足
重放安全边界时跨 Provider fallback。
运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null`
表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;已识别客户端没有合法版本,或版本
低于对应门槛时,所有 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。
未知客户端保持兼容,不应用版本门禁。
低版本响应使用 OpenAI 风格错误合同:
```json
{
"error": {
"message": "Codex CLI 0.151.0 is below the minimum required version 0.152.0. Upgrade Codex CLI and retry.",
"type": "invalid_request_error",
"code": "client_version_too_old",
"client": "codex_cli",
"current_version": "0.151.0",
"min_version": "0.152.0"
}
}
```
已识别但缺失或携带非法版本时,`code` 为 `client_version_unavailable`,`current_version` 为 `null`。
### 管理接口
除登录、会话状态和登出外,所有 `/api/admin/*` 请求都需要以下任一鉴权方式:
@@ -375,6 +397,7 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
| --- | --- | --- |
| `GET` | `/api/admin/settings` | 读取运行设置 |
| `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 |
| `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 |
| `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 |
| `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key |
| `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key |
@@ -388,12 +411,44 @@ refreshConcurrency
maxConcurrentPerAccount
requestIntervalMs
rotationStrategy
minCodexDesktopVersion
minCodexCliVersion
usageRetentionDays
opsEventRetentionDays
auditRetentionDays
```
`rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。
两个 `minCodex*Version` 字段为 `string | null`,只设置最低版本,不存在最大版本字段。
Windows 离线包接口固定解析 Microsoft Store Product ID `9PLM9XGG6VKS` 的 Retail 包,不接受调用方提供
产品 ID、上游地址、ring 或文件名。后端只返回通过包名、架构、Microsoft CDN host/path、scheme 和失效
时间校验的 `x64` / `arm64` MSIX 直链,不代理安装包字节。Store 内容通道返回 HTTP/80 临时地址时保留
原始 scheme,不强制改写为该 host 不保证支持的 HTTPS。动态链接不足 10 分钟即失效时不会下发;某个架构
解析失败时只将该架构降级到 OpenAI 官方 HTTPS 稳定 MSIX,并通过 `warning` 说明。响应形状为:
```json
{
"resolvedAt": "2026-09-01T06:30:00Z",
"cached": false,
"warning": null,
"packages": [
{
"architecture": "x64",
"source": "microsoft_store",
"version": "26.825.6671.0",
"fileName": "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix",
"sizeBytes": 744250000,
"downloadUrl": "http://dl.delivery.mp.microsoft.com/filestreamingservice/files/...",
"expiresAt": "2026-09-01T07:30:00Z"
}
]
}
```
`source` 为 `microsoft_store` 或 `official_openai`。Store 的四段 package version 只用于下载展示,不参与
Desktop 三段 SemVer 门禁,也不会自动回写最低版本设置。完整安全与回退设计见
[客户端最低版本与下载方案](client-min-version-plan.md)。
## 9. 备份
+13 -1
View File
@@ -43,6 +43,8 @@ flowchart LR
Store --> Object[(S3 / R2)]
Host[gateway-host] -. lifecycle / workers / update .-> API
Host -. lazy client distribution resolver .-> Admin
Host --> StoreLinks[RG-Adguard / Microsoft CDN]
```
`backend/apps/gateway` 是唯一组合根,按 Host → Store → Provider → Core → Admin → API → Worker 的顺序
@@ -95,9 +97,14 @@ sequenceDiagram
E->>S: enqueue terminal observation and accounting
```
请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Provider 候选顺序和调度策略。
请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Codex 客户端最低版本、Provider
候选顺序和调度策略。
运行中的请求不再拼接新旧配置,也不在热路径查询分组关系。
Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/CLI,Core 使用同一请求冻结的
`RuntimeSnapshot` 比较对应最低版本。Desktop 优先于其 User-Agent 内嵌的 CLI/Core 标记;未知客户端不
应用门禁。低版本或已识别但版本不可用时,在进入 Provider 前返回稳定的 `426` 合同。
核心不变量:
- 一个客户端请求对应一条 `model_requests`;attempt 是请求内事实,不建立第二张权威表。
@@ -190,6 +197,7 @@ PostgreSQL 周期对账才是正确性基础。
| 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 |
| 重置卡库存与消费结果 | OpenAI upstream | 后端不建立本地卡库存;前端只保留当前浏览器会话的最近查询 |
| Provider 公开模型与请求画像 | Provider/runtime cache | 由官方目录或发布源刷新,不写成第二份业务配置 |
| Windows 安装包临时直链 | Host 进程内短缓存 | 按需解析、严格校验、到期前丢弃;不写 PostgreSQL/Redis,也不代理包字节 |
账号对外状态不是独立列,而是 PostgreSQL credential/quota 事实与 Redis cooldown 的统一投影:
`normal`、`quota_exhausted`、`rate_limited`、`disabled`、`error`。只有明确上游证据才能恢复或终态化账号,
@@ -237,6 +245,10 @@ Worker 由各 Bundle 贡献、由 Host 统一监督:
启动只有在配置、PostgreSQL、Redis、Provider、Core、Admin、API 和 Worker 全部初始化成功后才进入服务。
健康检查综合 Core、Store 与 Worker 状态,但不会把单个 Provider 的业务降级等同于整个进程失活。
组合根在启动时只把 Host 的 `ClientDistributionResolver` 能力注入 Admin;此时不访问 RG-Adguard,下载
HTTP Client 构造失败也不会阻断网关启动。外部解析在已认证管理员首次打开客户端下载弹窗时惰性执行,
失败时按架构使用 OpenAI 官方稳定地址。Microsoft Store 内容通道返回的 HTTP/80 临时链接保留原始 scheme,
不会被错误改写到该 host 不保证支持的 HTTPS 虚拟主机。
关闭分为两段:先停止接收新连接并 drain HTTP/WS,再取消并等待 Worker;两段各有独立预算,Compose 的
`stop_grace_period` 必须覆盖二者之和。超时后只丢弃仍未落盘的可恢复观测,不执行隐式业务重放。
+30
View File
@@ -9,12 +9,34 @@ export interface RuntimeSettings {
maxConcurrentPerAccount: number
requestIntervalMs: number
rotationStrategy: RotationStrategy
minCodexDesktopVersion: string | null
minCodexCliVersion: string | null
usageRetentionDays: number
opsEventRetentionDays: number
auditRetentionDays: number
updatedAt: string
}
export type ClientArchitecture = 'x64' | 'arm64'
export type ClientDownloadSource = 'microsoft_store' | 'official_openai'
export interface ClientDownloadPackage {
architecture: ClientArchitecture
source: ClientDownloadSource
version: string | null
fileName: string
sizeBytes: number | null
downloadUrl: string
expiresAt: string | null
}
export interface CodexDesktopWindowsDownloads {
resolvedAt: string
cached: boolean
warning: string | null
packages: ClientDownloadPackage[]
}
export interface AdminApiKeyStatus {
exists: boolean
}
@@ -64,3 +86,11 @@ export function deleteAdminApiKey() {
method: 'POST',
})
}
export function getCodexDesktopWindowsDownloads(refresh = false) {
return request<CodexDesktopWindowsDownloads>({
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
method: 'GET',
params: refresh ? { refresh: true } : undefined,
})
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { useResizeObserver } from '@vueuse/core'
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
import BaseIconButton from '../BaseIconButton.vue'
@@ -34,13 +35,19 @@ const open = defineModel<boolean>({ default: false })
const panel = useTemplateRef<HTMLElement>('panel')
const titleId = useId()
const descriptionId = useId()
const { cancelDrag, handlePointerDown, isDragging, resetPosition } = useModalDrag(
const { cancelDrag, constrainPosition, handlePointerDown, isDragging, resetPosition } = useModalDrag(
panel,
() => props.draggable,
)
let previouslyFocused: HTMLElement | null = null
let ownsScrollLock = false
// 内容分段可能在弹窗打开期间改变面板高度;拖拽偏移必须随尺寸重新约束到视口内。
useResizeObserver(panel, () => {
if (open.value)
constrainPosition()
})
const focusableSelector = [
'a[href]',
'button:not([disabled])',
@@ -159,6 +159,7 @@ export function useModalDrag(
return {
cancelDrag: endDrag,
constrainPosition: updatePosition,
handlePointerDown,
isDragging,
resetPosition,
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ExternalLink, TerminalSquare } from '@lucide/vue'
import ClientInstallCommandList from './ClientInstallCommandList.vue'
defineOptions({ name: 'ClientCliInstallPanel' })
const cliCommands = [
{
label: 'macOS / Linux · 官方安装器',
command: 'curl -fsSL https://chatgpt.com/codex/install.sh | sh',
},
{
label: 'Windows PowerShell · 官方安装器',
command: 'irm https://chatgpt.com/codex/install.ps1 | iex',
},
{
label: 'npm',
command: 'npm install -g @openai/codex',
},
{
label: 'Homebrew · 首次安装',
command: 'brew install --cask codex',
},
{
label: 'Homebrew · 升级',
command: 'brew upgrade --cask codex',
},
]
</script>
<template>
<section class="grid gap-4" aria-labelledby="codex-cli-guide-title">
<div class="flex items-start gap-3 px-1">
<span class="inline-grid size-10 shrink-0 place-items-center rounded-cp bg-cp-fill-quaternary text-cp-text-secondary">
<TerminalSquare class="size-4.5" />
</span>
<div class="min-w-0">
<h3 id="codex-cli-guide-title" class="m-0 text-cp-lg leading-none font-heavy text-cp-text">
安装或升级 Codex CLI
</h3>
<p class="mt-1.5 mb-0 text-cp-sm leading-[1.45] font-semibold text-cp-text-secondary">
按当前安装来源执行一种命令;官方安装器会自动选择当前平台架构。
</p>
</div>
</div>
<div class="grid gap-3 rounded-cp-card bg-cp-fill-quaternary p-4">
<p class="m-0 text-cp-sm font-heavy text-cp-text">
选择安装来源
</p>
<ClientInstallCommandList :commands="cliCommands" />
</div>
<div class="flex flex-wrap items-center justify-between gap-3 px-1 text-cp-sm font-semibold text-cp-text-secondary">
<span>重新执行对应命令即可升级到当前发布版本。</span>
<a
href="https://learn.chatgpt.com/docs/codex/cli"
target="_blank"
rel="noopener noreferrer"
class="inline-flex shrink-0 items-center gap-1.5 font-bold text-cp-link transition-colors hover:text-cp-link-hover"
>
查看官方文档
<ExternalLink class="size-3.5" />
</a>
</div>
</section>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import type { CodexDesktopWindowsDownloads } from '@/api'
import { ExternalLink } from '@lucide/vue'
import ClientDesktopOfflineDownloads from './ClientDesktopOfflineDownloads.vue'
import ClientInstallCommandList from './ClientInstallCommandList.vue'
defineOptions({ name: 'ClientDesktopInstallPanel' })
withDefaults(
defineProps<{
downloads?: CodexDesktopWindowsDownloads | null
loading?: boolean
error?: string
}>(),
{
downloads: null,
loading: false,
error: '',
},
)
const emit = defineEmits<{
refresh: []
retry: []
}>()
const desktopCommands = [
{
label: 'Windows · Microsoft Store',
command: 'winget install --id 9PLM9XGG6VKS -s msstore',
},
{
label: 'Ubuntu / Debian · 升级',
command: 'sudo apt update && sudo apt install --only-upgrade chatgpt',
},
{
label: 'Fedora / RHEL · 升级',
command: 'sudo dnf upgrade --refresh chatgpt',
},
]
</script>
<template>
<section class="grid gap-4" aria-labelledby="codex-desktop-online-title">
<div class="grid gap-3 rounded-cp-card bg-cp-fill-quaternary p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h3 id="codex-desktop-online-title" class="m-0 text-cp-sm font-heavy text-cp-text">
在线安装与升级
</h3>
<p class="mt-1.5 mb-0 text-cp-sm leading-[1.45] font-semibold text-cp-text-secondary">
桌面应用通常会自动更新,也可以从官方渠道重新安装。
</p>
</div>
<a
href="https://chatgpt.com/download/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-cp-control shrink-0 items-center gap-2 rounded-cp-sm bg-cp-fill-tertiary px-3.5 text-cp font-bold text-cp-text shadow-cp-tertiary transition-[background-color,transform] hover:bg-cp-bg-text-active motion-safe:active:translate-y-px"
>
官方下载页
<ExternalLink class="size-3.5" />
</a>
</div>
<ClientInstallCommandList :commands="desktopCommands" />
</div>
<ClientDesktopOfflineDownloads
:downloads="downloads"
:loading="loading"
:error="error"
@retry="emit('retry')"
@refresh="emit('refresh')"
/>
</section>
</template>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { ClientDownloadPackage, CodexDesktopWindowsDownloads } from '@/api'
import { ArrowDownToLine, PackageOpen, RefreshCw } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
import { formatDateTime } from '@/utils/date'
import ClientInstallCommandList from './ClientInstallCommandList.vue'
defineOptions({ name: 'ClientDesktopOfflineDownloads' })
withDefaults(
defineProps<{
downloads?: CodexDesktopWindowsDownloads | null
loading?: boolean
error?: string
}>(),
{
downloads: null,
loading: false,
error: '',
},
)
const emit = defineEmits<{
refresh: []
retry: []
}>()
const localInstallCommand = [
{
label: '下载后使用 PowerShell 安装 / 升级',
command: 'Add-AppxPackage -Path .\\<下载的安装包>.msix',
},
]
function packageTitle(packageItem: ClientDownloadPackage): string {
return packageItem.architecture === 'arm64' ? 'Windows ARM64' : 'Windows x64'
}
function formatFileSize(value: number | null): string {
if (!value)
return '大小未知'
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unit = 0
while (size >= 1000 && unit < units.length - 1) {
size /= 1000
unit += 1
}
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
}
</script>
<template>
<section class="grid gap-3.5 rounded-cp-card bg-cp-fill-quaternary p-4" aria-labelledby="windows-offline-download-title">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
<span class="inline-grid size-9 shrink-0 place-items-center rounded-cp bg-cp-info-bg text-cp-info">
<ArrowDownToLine class="size-4.5" />
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 id="windows-offline-download-title" class="m-0 text-cp-lg leading-none font-heavy text-cp-text">
Windows 离线安装
</h4>
<span
v-if="downloads?.cached"
class="rounded-full bg-cp-bg-container px-2 py-1 text-cp-xs leading-none font-heavy text-cp-text-quaternary"
>
已缓存
</span>
</div>
<p class="mt-1.5 mb-0 text-cp-sm leading-[1.45] font-semibold text-cp-text-secondary">
后端实时校验 Microsoft CDN 直链;请选择与设备一致的架构。
</p>
</div>
</div>
<BaseIconButton
:label="loading ? '正在重新提取离线下载链接' : '重新提取离线下载链接'"
variant="secondary"
:disabled="loading"
:aria-busy="loading || undefined"
@click="emit('refresh')"
>
<RefreshCw class="size-4" :class="loading && 'animate-spin motion-reduce:animate-none'" />
</BaseIconButton>
</div>
<p
v-if="downloads?.warning"
class="m-0 rounded-cp bg-cp-warning-bg px-3 py-2.5 text-cp-sm leading-[1.45] font-bold text-cp-warning-text"
>
{{ downloads.warning }}
</p>
<p
v-if="error && downloads"
class="m-0 rounded-cp bg-cp-error-bg px-3 py-2.5 text-cp-sm leading-[1.45] font-bold text-cp-error-text"
role="alert"
>
{{ error }},当前仍显示上次成功结果。
</p>
<div v-if="loading && !downloads" class="grid gap-3 sm:grid-cols-2" aria-label="正在提取离线安装包">
<div v-for="index in 2" :key="index" class="grid gap-3 rounded-cp bg-cp-bg-container p-4 shadow-cp-tertiary">
<BaseSkeleton class="h-4 w-28" />
<BaseSkeleton class="h-3 w-44" shape="text" />
<BaseSkeleton class="h-9 w-full" />
</div>
</div>
<BaseEmpty
v-else-if="error && !downloads"
title="离线安装包加载失败"
:description="error"
:icon="PackageOpen"
size="sm"
>
<template #action>
<BaseButton size="sm" :loading="loading" @click="emit('retry')">
重试
</BaseButton>
</template>
</BaseEmpty>
<div v-else-if="downloads" class="grid gap-3 sm:grid-cols-2">
<article
v-for="packageItem in downloads.packages"
:key="packageItem.architecture"
class="grid content-between gap-4 rounded-cp bg-cp-bg-container p-4 shadow-cp-tertiary"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="m-0 text-cp leading-none font-heavy text-cp-text">
{{ packageTitle(packageItem) }}
</p>
<span class="rounded-full bg-cp-fill-tertiary px-2 py-1 font-mono text-cp-xs leading-none font-bold text-cp-text-secondary">
{{ packageItem.architecture }}
</span>
</div>
<p class="mt-2 mb-0 truncate font-mono text-cp-sm font-semibold text-cp-text" :title="packageItem.fileName">
{{ packageItem.version ? `v${packageItem.version}` : '稳定通道' }}
· {{ formatFileSize(packageItem.sizeBytes) }}
</p>
<p v-if="packageItem.expiresAt" class="mt-1.5 mb-0 text-cp-xs font-semibold text-cp-text-quaternary">
链接失效:{{ formatDateTime(packageItem.expiresAt) }}
</p>
</div>
<a
:href="packageItem.downloadUrl"
target="_blank"
rel="noopener noreferrer"
:aria-label="`下载 ${packageTitle(packageItem)} MSIX`"
class="inline-flex h-cp-control items-center justify-center gap-2 rounded-cp-sm bg-(--cp-button-primary-bg) px-4 text-cp font-bold text-(--cp-button-primary-color) shadow-cp-tertiary transition-[background-color,transform] hover:bg-(--cp-button-primary-hover-bg) motion-safe:active:translate-y-px"
>
下载 MSIX
<ArrowDownToLine class="size-4" />
</a>
</article>
</div>
<ClientInstallCommandList :commands="localInstallCommand" />
</section>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Copy } from '@lucide/vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { useCopyText } from '@/composables/useCopyText'
defineOptions({ name: 'ClientInstallCommandList' })
defineProps<{
commands: Array<{
label: string
command: string
}>
}>()
const copyText = useCopyText()
function copyCommand(command: string) {
void copyText(command, { successText: '命令已复制' })
}
</script>
<template>
<div class="grid gap-2.5">
<article
v-for="item in commands"
:key="item.label"
class="grid grid-cols-[minmax(0,1fr)_28px] items-start gap-2.5 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-tertiary"
>
<div class="min-w-0">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<code class="mt-1.5 block overflow-x-auto whitespace-nowrap font-mono text-cp-sm leading-[1.5] font-semibold text-cp-text">{{ item.command }}</code>
</div>
<BaseIconButton size="sm" label="复制命令" @click="copyCommand(item.command)">
<Copy class="size-3.5" />
</BaseIconButton>
</article>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { CodexDesktopWindowsDownloads } from '@/api'
import { MonitorUp, PackageOpen, TerminalSquare } from '@lucide/vue'
import { shallowRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import ClientCliInstallPanel from './ClientCliInstallPanel.vue'
import ClientDesktopInstallPanel from './ClientDesktopInstallPanel.vue'
defineOptions({ name: 'ClientVersionGuideModal' })
withDefaults(
defineProps<{
downloads?: CodexDesktopWindowsDownloads | null
loading?: boolean
error?: string
}>(),
{
downloads: null,
loading: false,
error: '',
},
)
const emit = defineEmits<{
refresh: []
retry: []
}>()
type ClientSection = 'desktop' | 'cli'
const open = defineModel<boolean>({ default: false })
const activeSection = shallowRef<ClientSection>('desktop')
const sectionOptions = [
{ label: 'Codex Desktop', value: 'desktop', icon: MonitorUp },
{ label: 'Codex CLI', value: 'cli', icon: TerminalSquare },
]
</script>
<template>
<BaseModal
v-model="open"
title="Codex 客户端升级"
description="先选择客户端,再查看对应的安装与升级方式"
tone="info"
size="lg"
>
<template #icon>
<PackageOpen class="size-4.5 text-cp-info" />
</template>
<div class="grid gap-4">
<BaseSegmented
v-model="activeSection"
label="客户端类型"
:options="sectionOptions"
size="lg"
class="w-full"
/>
<ClientDesktopInstallPanel
v-if="activeSection === 'desktop'"
:downloads="downloads"
:loading="loading"
:error="error"
@retry="emit('retry')"
@refresh="emit('refresh')"
/>
<ClientCliInstallPanel v-else />
</div>
<template #footer>
<BaseButton variant="primary" @click="open = false">
关闭
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { CircleHelp, MonitorUp, TerminalSquare } from '@lucide/vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
defineOptions({ name: 'ClientVersionPolicyCard' })
withDefaults(
defineProps<{
loading?: boolean
desktopError?: string
cliError?: string
}>(),
{
loading: false,
desktopError: '',
cliError: '',
},
)
const emit = defineEmits<{
help: []
}>()
const minCodexDesktopVersion = defineModel<string>('minCodexDesktopVersion', { required: true })
const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required: true })
</script>
<template>
<BaseCard
description="低于最低版本的 Codex 客户端将无法发起请求,留空表示不限制"
>
<template #title>
<span class="inline-flex items-center gap-1.5">
<span>客户端版本限制</span>
<BaseIconButton
label="查看安装与升级说明"
size="sm"
class="-my-1"
@click="emit('help')"
>
<CircleHelp class="size-3.5" />
</BaseIconButton>
</span>
</template>
<BaseForm class="max-w-6xl sm:grid-cols-2">
<BaseFormItem
label="Codex Desktop 最低版本"
description="只检查 Desktop 应用版本,例如 26.825.51511"
:error="desktopError"
>
<BaseInput
v-model="minCodexDesktopVersion"
aria-label="Codex Desktop 最低版本"
autocomplete="off"
spellcheck="false"
placeholder="不限制"
:disabled="loading"
>
<template #prefix>
<MonitorUp class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem
label="Codex CLI 最低版本"
description="只检查独立 CLI 版本,例如 0.152.0"
:error="cliError"
>
<BaseInput
v-model="minCodexCliVersion"
aria-label="Codex CLI 最低版本"
autocomplete="off"
spellcheck="false"
placeholder="不限制"
:disabled="loading"
>
<template #prefix>
<TerminalSquare class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
</BaseForm>
</BaseCard>
</template>
@@ -0,0 +1,43 @@
import type { CodexDesktopWindowsDownloads } from '@/api'
import { shallowRef } from 'vue'
import { getCodexDesktopWindowsDownloads } from '@/api'
import { errorMessage } from '@/utils/async'
export function useClientDownloads() {
const open = shallowRef(false)
const loading = shallowRef(false)
const error = shallowRef('')
const downloads = shallowRef<CodexDesktopWindowsDownloads | null>(null)
async function load(refresh = false): Promise<void> {
if (loading.value)
return
loading.value = true
error.value = ''
try {
downloads.value = await getCodexDesktopWindowsDownloads(refresh)
}
catch (cause: unknown) {
error.value = errorMessage(cause, 'Windows 离线安装包加载失败')
}
finally {
loading.value = false
}
}
function showGuide() {
open.value = true
if (!downloads.value)
void load()
}
return {
open,
loading,
error,
downloads,
load,
showGuide,
}
}
@@ -0,0 +1,52 @@
<script setup lang="ts">
import ClientVersionGuideModal from './components/ClientVersionGuideModal.vue'
import ClientVersionPolicyCard from './components/ClientVersionPolicyCard.vue'
import { useClientDownloads } from './composables/useClientDownloads'
defineOptions({ name: 'ClientVersionSettings' })
withDefaults(
defineProps<{
loading?: boolean
desktopError?: string
cliError?: string
}>(),
{
loading: false,
desktopError: '',
cliError: '',
},
)
const minCodexDesktopVersion = defineModel<string>('minCodexDesktopVersion', { required: true })
const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required: true })
const {
open,
loading: downloadsLoading,
error,
downloads,
load,
showGuide,
} = useClientDownloads()
</script>
<template>
<ClientVersionPolicyCard
v-model:min-codex-desktop-version="minCodexDesktopVersion"
v-model:min-codex-cli-version="minCodexCliVersion"
:loading="loading"
:desktop-error="desktopError"
:cli-error="cliError"
@help="showGuide"
/>
<ClientVersionGuideModal
v-model="open"
:downloads="downloads"
:loading="downloadsLoading"
:error="error"
@retry="load(false)"
@refresh="load(true)"
/>
</template>
@@ -18,6 +18,8 @@ export function useSettingsForm() {
maxConcurrentPerAccount: null as number | null,
requestIntervalMs: null as number | null,
rotationStrategy: '' as RotationStrategy | '',
minCodexDesktopVersion: '',
minCodexCliVersion: '',
usageRetentionDays: 31,
opsEventRetentionDays: 30,
auditRetentionDays: 90,
@@ -41,6 +43,13 @@ export function useSettingsForm() {
const refreshConcurrencyValue = numericModel('refreshConcurrency')
const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount')
const requestIntervalMsValue = numericModel('requestIntervalMs')
const minCodexDesktopVersionError = computed(() => versionError(form.minCodexDesktopVersion))
const minCodexCliVersionError = computed(() => versionError(form.minCodexCliVersion))
function versionError(value: string): string {
const normalized = value.trim()
return normalized && !isSemver(normalized) ? '请输入标准 SemVer,例如 0.152.0' : ''
}
function applySettings(data: Awaited<ReturnType<typeof getSettings>>) {
form.refreshMarginSeconds = data.refreshMarginSeconds
@@ -48,6 +57,8 @@ export function useSettingsForm() {
form.maxConcurrentPerAccount = data.maxConcurrentPerAccount
form.requestIntervalMs = data.requestIntervalMs
form.rotationStrategy = data.rotationStrategy
form.minCodexDesktopVersion = data.minCodexDesktopVersion ?? ''
form.minCodexCliVersion = data.minCodexCliVersion ?? ''
form.usageRetentionDays = data.usageRetentionDays
form.opsEventRetentionDays = data.opsEventRetentionDays
form.auditRetentionDays = data.auditRetentionDays
@@ -112,6 +123,10 @@ export function useSettingsForm() {
toast.warning('请完整填写运行参数和调度策略')
return
}
if (minCodexDesktopVersionError.value || minCodexCliVersionError.value) {
toast.warning('请修正客户端最低版本格式')
return
}
try {
saving.value = true
const result = await updateSettings({
@@ -121,6 +136,8 @@ export function useSettingsForm() {
maxConcurrentPerAccount,
requestIntervalMs,
rotationStrategy,
minCodexDesktopVersion: form.minCodexDesktopVersion.trim() || null,
minCodexCliVersion: form.minCodexCliVersion.trim() || null,
usageRetentionDays: form.usageRetentionDays,
opsEventRetentionDays: form.opsEventRetentionDays,
auditRetentionDays: form.auditRetentionDays,
@@ -151,7 +168,50 @@ export function useSettingsForm() {
refreshConcurrencyValue,
maxConcurrentPerAccountValue,
requestIntervalMsValue,
minCodexDesktopVersionError,
minCodexCliVersionError,
saveSettings,
loadSettings,
}
}
function isSemver(value: string): boolean {
if (value.length > 64 || value.startsWith('v'))
return false
const buildParts = value.split('+')
if (buildParts.length > 2)
return false
const [versionAndPrerelease = '', build] = buildParts
if (build !== undefined && !validIdentifiers(build, false))
return false
const prereleaseSeparator = versionAndPrerelease.indexOf('-')
const core = prereleaseSeparator < 0
? versionAndPrerelease
: versionAndPrerelease.slice(0, prereleaseSeparator)
const prerelease = prereleaseSeparator < 0
? undefined
: versionAndPrerelease.slice(prereleaseSeparator + 1)
if (prerelease !== undefined && !validIdentifiers(prerelease, true))
return false
const coreParts = core.split('.')
return coreParts.length === 3 && coreParts.every(validCoreNumericIdentifier)
}
function validIdentifiers(value: string, rejectNumericLeadingZeros: boolean): boolean {
return Boolean(value) && value.split('.').every((identifier) => {
if (!identifier || !/^[\da-z-]+$/i.test(identifier))
return false
return !rejectNumericLeadingZeros || !/^\d+$/.test(identifier) || validNumericIdentifier(identifier)
})
}
function validNumericIdentifier(value: string): boolean {
return /^(?:0|[1-9]\d*)$/.test(value)
}
function validCoreNumericIdentifier(value: string): boolean {
return validNumericIdentifier(value) && BigInt(value) <= 18_446_744_073_709_551_615n
}
+11
View File
@@ -10,6 +10,7 @@ import BaseSegmented from '@/components/base/BaseSegmented.vue'
import AdminApiKeyCard from './components/AdminApiKeyCard.vue'
import SettingsBackupSection from './components/backup/SettingsBackupSection.vue'
import ClientVersionSettings from './components/client-version/index.vue'
import ModelAliasesCard from './components/ModelAliasesCard.vue'
import RotationStrategyCard from './components/RotationStrategyCard.vue'
import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue'
@@ -48,6 +49,8 @@ const {
refreshConcurrencyValue,
maxConcurrentPerAccountValue,
requestIntervalMsValue,
minCodexDesktopVersionError,
minCodexCliVersionError,
saveSettings,
loadSettings,
} = useSettingsForm()
@@ -126,6 +129,14 @@ watch(
v-model:request-interval-ms="requestIntervalMsValue"
/>
<ClientVersionSettings
v-model:min-codex-desktop-version="form.minCodexDesktopVersion"
v-model:min-codex-cli-version="form.minCodexCliVersion"
:loading="loading"
:desktop-error="minCodexDesktopVersionError"
:cli-error="minCodexCliVersionError"
/>
<ModelAliasesCard
:mappings="mappings"
:loading="loading"