feat(codex): add dynamic CLI client profile

This commit is contained in:
AAEE86
2026-09-23 15:57:37 +08:00
parent 2a9d8d3b25
commit 7e033d0571
20 changed files with 707 additions and 61 deletions
@@ -0,0 +1,108 @@
use std::sync::{OnceLock, RwLock};
/// 当前支持的 Codex 客户端类型。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CodexClientKind {
Cli,
Desktop,
}
/// Codex 上游请求使用的客户端画像。
///
/// 画像由网关后台任务更新,格式转换层只读取不可变快照,避免在请求路径执行网络操作。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodexClientProfile {
pub client_kind: CodexClientKind,
pub codex_version: String,
pub originator: String,
pub user_agent: String,
}
impl CodexClientProfile {
/// 从稳定版本号创建 CLI 画像;版本校验由发布检查器负责,构造器只拒绝明显非法值。
pub fn cli(version: &str) -> Result<Self, &'static str> {
let version = version.trim();
if version.is_empty()
|| version.len() > 64
|| !version.bytes().all(|byte| (32..=126).contains(&byte))
{
return Err("invalid Codex CLI version");
}
let originator = "codex_cli_rs".to_owned();
Ok(Self {
client_kind: CodexClientKind::Cli,
codex_version: version.to_owned(),
user_agent: format!("{}/{}", originator, version),
originator,
})
}
}
impl Default for CodexClientProfile {
fn default() -> Self {
// 远程发布检查不可用时仍保持现有线上行为,避免启动或请求被版本服务拖住。
Self::cli("0.153.4").expect("built-in Codex CLI profile must be valid")
}
}
static ACTIVE_PROFILE: OnceLock<RwLock<CodexClientProfile>> = OnceLock::new();
fn active_profile() -> &'static RwLock<CodexClientProfile> {
ACTIVE_PROFILE.get_or_init(|| RwLock::new(CodexClientProfile::default()))
}
/// 返回当前画像的独立快照,调用方不会持有全局锁。
pub fn codex_client_profile() -> CodexClientProfile {
active_profile()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
/// 原子替换当前画像,并返回替换前的画像。
pub fn set_codex_client_profile(profile: CodexClientProfile) -> CodexClientProfile {
let mut current = active_profile()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::replace(&mut *current, profile)
}
/// 发布一份新的 CLI 画像。
pub fn set_codex_cli_version(version: &str) -> Result<CodexClientProfile, &'static str> {
let profile = CodexClientProfile::cli(version)?;
Ok(set_codex_client_profile(profile))
}
/// 返回当前画像的 Codex Core 版本。
pub fn codex_client_version() -> String {
codex_client_profile().codex_version
}
/// 返回当前画像的 User-Agent。
pub fn codex_client_user_agent() -> String {
codex_client_profile().user_agent
}
/// 返回当前画像的 originator。
pub fn codex_client_originator() -> String {
codex_client_profile().originator
}
#[cfg(test)]
mod tests {
use super::{CodexClientKind, CodexClientProfile};
#[test]
fn cli_profile_derives_wire_identity_from_version() {
let profile = CodexClientProfile::cli("0.200.1").expect("valid version");
assert_eq!(profile.client_kind, CodexClientKind::Cli);
assert_eq!(profile.originator, "codex_cli_rs");
assert_eq!(profile.user_agent, "codex_cli_rs/0.200.1");
}
#[test]
fn cli_profile_rejects_empty_or_control_values() {
assert!(CodexClientProfile::cli("").is_err());
assert!(CodexClientProfile::cli("0.1.0\nspoof").is_err());
}
}
@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::sync::OnceLock;
use crate::codex_profile::codex_client_profile;
use aether_ai_formats::provider_compat::proxy::rules::body_rules_handle_path;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -36,9 +37,6 @@ const CODEX_OPENAI_RESPONSES_COMPACT_BODY_FIELDS: &[&str] = &[
"prompt_cache_key",
"text",
];
pub const CODEX_CLIENT_VERSION: &str = "0.153.4";
pub const CODEX_CLIENT_USER_AGENT: &str = "codex_cli_rs/0.153.4";
pub const CODEX_CLIENT_ORIGINATOR: &str = "codex_cli_rs";
pub const CODEX_OPENAI_IMAGE_INTERNAL_MODEL: &str = "gpt-5.4-mini";
pub const CODEX_OPENAI_IMAGE_DEFAULT_MODEL: &str = "gpt-image-2";
pub const CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL: &str = "dall-e-2";
@@ -2098,6 +2096,7 @@ pub fn apply_codex_openai_special_headers(
};
let auth_identity = parse_codex_auth_identity(decrypted_auth_config_raw);
let client_profile = codex_client_profile();
remove_btree_header(provider_request_headers, "chatgpt-account-id");
remove_btree_header(provider_request_headers, "x-openai-fedramp");
@@ -2112,12 +2111,12 @@ pub fn apply_codex_openai_special_headers(
set_codex_client_header(
provider_request_headers,
"user-agent",
CODEX_CLIENT_USER_AGENT,
&client_profile.user_agent,
);
set_codex_client_header(
provider_request_headers,
"originator",
CODEX_CLIENT_ORIGINATOR,
&client_profile.originator,
);
if endpoint_kind == CodexOpenAiEndpointKind::Search {
remove_btree_header(provider_request_headers, CODEX_RESPONSES_LITE_HEADER);
@@ -2175,17 +2174,18 @@ mod tests {
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
parse_codex_auth_identity, project_codex_catalog_model_card,
resolve_codex_responses_model_capabilities,
validate_codex_openai_responses_compact_request_contract, CODEX_CLIENT_ORIGINATOR,
CODEX_CLIENT_USER_AGENT, CODEX_CLIENT_VERSION, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS, CODEX_RESPONSES_LITE_HEADER,
validate_codex_openai_responses_compact_request_contract,
CODEX_OPENAI_IMAGE_INTERNAL_MODEL, CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS,
CODEX_RESPONSES_LITE_HEADER,
};
use serde_json::{json, Value};
#[test]
fn codex_client_user_agent_matches_originator_and_version() {
let profile = crate::codex_client_profile();
assert_eq!(
CODEX_CLIENT_USER_AGENT,
format!("{CODEX_CLIENT_ORIGINATOR}/{CODEX_CLIENT_VERSION}")
profile.user_agent,
format!("{}/{}", profile.originator, profile.codex_version)
);
}
@@ -2963,11 +2963,11 @@ mod tests {
);
assert_eq!(
headers.get("user-agent").map(String::as_str),
Some(CODEX_CLIENT_USER_AGENT)
Some(crate::codex_client_user_agent().as_str())
);
assert_eq!(
headers.get("originator").map(String::as_str),
Some(CODEX_CLIENT_ORIGINATOR)
Some(crate::codex_client_originator().as_str())
);
assert!(!headers.contains_key(CODEX_RESPONSES_LITE_HEADER));
assert!(!headers.contains_key("openai-beta"));
@@ -3001,11 +3001,11 @@ mod tests {
);
assert_eq!(
headers.get("user-agent").map(String::as_str),
Some(CODEX_CLIENT_USER_AGENT)
Some(crate::codex_client_user_agent().as_str())
);
assert_eq!(
headers.get("originator").map(String::as_str),
Some(CODEX_CLIENT_ORIGINATOR)
Some(crate::codex_client_originator().as_str())
);
assert!(!headers.contains_key(CODEX_RESPONSES_LITE_HEADER));
}
+5 -1
View File
@@ -1,11 +1,16 @@
extern crate self as aether_ai_formats;
pub mod api;
pub mod codex_profile;
pub mod contracts;
pub mod formats;
pub mod protocol;
pub mod provider_compat;
pub use codex_profile::{
codex_client_originator, codex_client_profile, codex_client_user_agent, codex_client_version,
set_codex_cli_version, set_codex_client_profile, CodexClientKind, CodexClientProfile,
};
pub use contracts::{ApiOperation, ClientSurface};
pub use formats::context::{
@@ -50,7 +55,6 @@ pub use formats::openai::responses::codex::{
codex_responses_lite_tool_is_client_executed, effective_codex_model_cards,
parse_codex_auth_identity, project_codex_catalog_model_card,
resolve_codex_responses_model_capabilities, CodexAuthIdentity, CodexResponsesModelCapabilities,
CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT, CODEX_CLIENT_VERSION,
CODEX_MODEL_CATALOG_METADATA_FIELD, CODEX_RESPONSES_LITE_HEADER,
};
pub use formats::openai::responses::request::{
+3 -2
View File
@@ -993,7 +993,7 @@ fn build_codex_models_url(base_url: &str, client_version: Option<&str>) -> Optio
} else if !has_client_version {
query_parts.push(format!(
"client_version={}",
aether_ai_formats::CODEX_CLIENT_VERSION
aether_ai_formats::codex_client_version()
));
}
if !query_parts.is_empty() {
@@ -1354,6 +1354,7 @@ mod tests {
#[test]
fn build_models_fetch_url_uses_codex_backend_models_endpoint() {
let client_version = aether_ai_formats::codex_client_version();
assert_eq!(
build_models_fetch_url(
"codex",
@@ -1363,7 +1364,7 @@ mod tests {
Some((
format!(
"https://chatgpt.com/backend-api/codex/models?client_version={}",
aether_ai_formats::CODEX_CLIENT_VERSION
client_version
),
"openai:responses".to_string()
))
+9 -7
View File
@@ -627,28 +627,30 @@ fn standard_models_fetch_headers(
let api_format = aether_ai_formats::normalize_api_format_alias(api_format);
let provider_type = provider_type.trim().to_ascii_lowercase();
if provider_type == "codex" && api_format.starts_with("openai:") {
// 模型目录请求也必须使用当前动态画像,不能回退到编译时固定版本。
let dynamic_client_version = aether_ai_formats::codex_client_version();
let client_version = codex_client_version
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(aether_ai_formats::CODEX_CLIENT_VERSION);
.unwrap_or(dynamic_client_version.as_str());
return BTreeMap::from([
(
"user-agent".to_string(),
format!(
"{}/{client_version}",
aether_ai_formats::CODEX_CLIENT_ORIGINATOR
aether_ai_formats::codex_client_originator()
),
),
(
"originator".to_string(),
aether_ai_formats::CODEX_CLIENT_ORIGINATOR.to_string(),
aether_ai_formats::codex_client_originator(),
),
]);
}
match api_format.as_str() {
"openai:responses" | "openai:responses:compact" => BTreeMap::from([(
"user-agent".to_string(),
aether_ai_formats::CODEX_CLIENT_USER_AGENT.to_string(),
aether_ai_formats::codex_client_user_agent(),
)]),
"claude:messages" => {
let mut headers = BTreeMap::from([(
@@ -894,7 +896,7 @@ mod tests {
assert_eq!(plan.url, "https://example.com/models");
assert_eq!(
plan.headers.get("user-agent").map(String::as_str),
Some(aether_ai_formats::CODEX_CLIENT_USER_AGENT)
Some(aether_ai_formats::codex_client_user_agent().as_str())
);
assert_eq!(
plan.headers.get("authorization").map(String::as_str),
@@ -993,7 +995,7 @@ mod tests {
plan.url,
format!(
"https://chatgpt.com/backend-api/codex/models?client_version={}",
aether_ai_formats::CODEX_CLIENT_VERSION
aether_ai_formats::codex_client_version()
)
);
assert_eq!(
@@ -1018,7 +1020,7 @@ mod tests {
);
assert_eq!(
plan.headers.get("user-agent").map(String::as_str),
Some(aether_ai_formats::CODEX_CLIENT_USER_AGENT)
Some(aether_ai_formats::codex_client_user_agent().as_str())
);
assert!(!plan.headers.contains_key("version"));
}
@@ -780,11 +780,11 @@ async fn register_codex_agent_identity_from_access_token_with_auth_api_base_url(
),
(
"user-agent".to_string(),
aether_ai_formats::CODEX_CLIENT_USER_AGENT.to_string(),
aether_ai_formats::codex_client_user_agent(),
),
(
"originator".to_string(),
aether_ai_formats::CODEX_CLIENT_ORIGINATOR.to_string(),
aether_ai_formats::codex_client_originator(),
),
]);
if options.is_fedramp_account {
@@ -799,7 +799,7 @@ async fn register_codex_agent_identity_from_access_token_with_auth_api_base_url(
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"abom": {
"agent_version": aether_ai_formats::CODEX_CLIENT_VERSION,
"agent_version": aether_ai_formats::codex_client_version(),
"agent_harness_id": CODEX_AGENT_IDENTITY_AGENT_HARNESS_ID,
"running_location": format!("cli-{}", std::env::consts::OS),
},