mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
Generated
+258
-2
@@ -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"
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(¤t)), 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"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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` 必须覆盖二者之和。超时后只丢弃仍未落盘的可恢复观测,不执行隐式业务重放。
|
||||
|
||||
@@ -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,
|
||||
|
||||
+68
@@ -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>
|
||||
+77
@@ -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>
|
||||
+169
@@ -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>
|
||||
+41
@@ -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>
|
||||
+81
@@ -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>
|
||||
+91
@@ -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>
|
||||
+43
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user