From d13f52bdf88bf56ff67cf64f21efcf6238fc7ddc Mon Sep 17 00:00:00 2001 From: dalamudx Date: Thu, 1 Oct 2026 15:56:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(claude-code):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=8A=A8=E6=80=81=20CLI=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=94=BB?= =?UTF-8?q?=E5=83=8F=E5=B9=B6=E6=8C=89=E5=AE=98=E6=96=B9=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Claude Code 传输身份拆分为带版本的静态模板(Stainless/运行时/beta 策略)与动态 CLI 画像(cli_version / User-Agent),请求按快照读取保证 UA 与 billing cc_version 一致 - 将 codex_profile 泛化为 cli_client_profile,Codex 与 Claude Code 共享发布校验、缓存恢复、防回退与每日刷新逻辑 - Claude Code 跟随 npm latest,要求 8 个平台包版本与主包一致;支持 AETHER_CLAUDE_CODE_CLIENT_PROFILE_REFRESH 与 AETHER_CLAUDE_CODE_CLIENT_VERSION - 新增定时任务 maintenance.claude_code.client.profile,启动时与 Codex 画像并发预热 - 额度查询 User-Agent 改用动态画像,移除硬编码 CLAUDE_CODE_USAGE_USER_AGENT --- apps/aether-gateway/src/cli_client_profile.rs | 690 ++++++++++++++++++ apps/aether-gateway/src/codex_profile.rs | 468 ------------ apps/aether-gateway/src/lib.rs | 2 +- apps/aether-gateway/src/main.rs | 21 +- apps/aether-gateway/src/state/core.rs | 22 +- apps/aether-gateway/src/task_runtime/mod.rs | 10 + .../pool/src/providers/claude_code.rs | 3 +- .../aether-provider/pool/src/providers/mod.rs | 1 - .../transport/src/claude_code/fingerprint.rs | 4 +- .../transport/src/claude_code/mimicry.rs | 2 +- .../transport/src/claude_code/mod.rs | 5 +- .../transport/src/claude_code/profile.rs | 285 ++++++-- .../transport/src/claude_code/request.rs | 14 +- .../aether-provider/transport/src/network.rs | 2 +- .../transport/src/same_format_provider/mod.rs | 2 +- 15 files changed, 975 insertions(+), 556 deletions(-) create mode 100644 apps/aether-gateway/src/cli_client_profile.rs delete mode 100644 apps/aether-gateway/src/codex_profile.rs diff --git a/apps/aether-gateway/src/cli_client_profile.rs b/apps/aether-gateway/src/cli_client_profile.rs new file mode 100644 index 000000000..60c2f9872 --- /dev/null +++ b/apps/aether-gateway/src/cli_client_profile.rs @@ -0,0 +1,690 @@ +//! CLI 客户端画像(Codex / Claude Code)的运行时发布与官方版本刷新。 +//! +//! 每个客户端由一份 [`CliClientProfileSpec`] 描述:官方 npm 发布源、平台包校验规则、 +//! 运行时缓存键、环境变量开关与画像发布函数。刷新逻辑本身与客户端无关。 + +use std::collections::BTreeMap; +use std::future::Future; +use std::time::Duration; + +use aether_runtime_state::RuntimeState; +use futures_util::StreamExt as _; +use reqwest::{redirect::Policy, Client}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use crate::ai_serving::api::{codex_client_version, set_codex_cli_version}; +use crate::ai_serving::transport::claude_code::{ + claude_code_client_version, set_claude_code_cli_version, +}; +use crate::task_runtime::{TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE, TASK_KEY_CODEX_CLIENT_PROFILE}; +use crate::AppState; + +const PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const PROFILE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +const RELEASE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const RELEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RELEASE_BYTES: usize = 256 * 1024; + +/// 单个 CLI 客户端的发布源、校验规则、缓存与运行时画像发布方式。 +pub(crate) struct CliClientProfileSpec { + /// 日志中的客户端标识。 + client: &'static str, + /// 官方 npm stable 标签的发布元数据地址。 + release_endpoint: &'static str, + package_name: &'static str, + /// 同一发布必须同时携带的平台二进制包。 + platform_targets: &'static [&'static str], + /// 根据包名、平台与版本给出期望的 optionalDependencies 条目。 + platform_dependency: fn(&str, &str, &str) -> (String, String), + cache_key: &'static str, + refresh_env: &'static str, + fixed_version_env: &'static str, + task_key: &'static str, + active_version: fn() -> String, + publish_version: fn(&str) -> Result<(), &'static str>, +} + +fn codex_platform_dependency(package: &str, target: &str, version: &str) -> (String, String) { + ( + format!("{package}-{target}"), + format!("npm:{package}@{version}-{target}"), + ) +} + +fn claude_code_platform_dependency(package: &str, target: &str, version: &str) -> (String, String) { + (format!("{package}-{target}"), version.to_owned()) +} + +fn publish_codex_version(version: &str) -> Result<(), &'static str> { + set_codex_cli_version(version).map(|_| ()) +} + +fn publish_claude_code_version(version: &str) -> Result<(), &'static str> { + set_claude_code_cli_version(version).map(|_| ()) +} + +pub(crate) static CODEX_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec { + client: "codex", + release_endpoint: "https://registry.npmjs.org/@openai%2Fcodex/latest", + package_name: "@openai/codex", + platform_targets: &[ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", + ], + platform_dependency: codex_platform_dependency, + cache_key: "aether:codex:client-profile:v1", + refresh_env: "AETHER_CODEX_CLIENT_PROFILE_REFRESH", + fixed_version_env: "AETHER_CODEX_CLIENT_VERSION", + task_key: TASK_KEY_CODEX_CLIENT_PROFILE, + active_version: codex_client_version, + publish_version: publish_codex_version, +}; + +/// Claude Code 跟随 npm `latest` 标签,与官方 CLI 默认的自动更新通道一致。 +/// 仅 CLI 版本(User-Agent 与 billing cc_version)随发布刷新;Stainless SDK 与运行时 +/// 指纹仍由 transport crate 中带版本号的身份模板统一维护。 +pub(crate) static CLAUDE_CODE_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec { + client: "claude_code", + release_endpoint: "https://registry.npmjs.org/@anthropic-ai%2Fclaude-code/latest", + package_name: "@anthropic-ai/claude-code", + platform_targets: &[ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "linux-arm64-musl", + "linux-x64-musl", + "win32-arm64", + "win32-x64", + ], + platform_dependency: claude_code_platform_dependency, + cache_key: "aether:claude_code:client-profile:v1", + refresh_env: "AETHER_CLAUDE_CODE_CLIENT_PROFILE_REFRESH", + fixed_version_env: "AETHER_CLAUDE_CODE_CLIENT_VERSION", + task_key: TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE, + active_version: claude_code_client_version, + publish_version: publish_claude_code_version, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NpmRelease { + name: String, + version: String, + optional_dependencies: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize)] +struct CachedProfile { + version: String, + verified_at_unix_secs: u64, +} + +#[derive(Debug, thiserror::Error)] +enum ProfileRefreshError { + #[error("CLI release client initialization failed: {0}")] + Client(#[from] reqwest::Error), + #[error("CLI release request returned HTTP {0}")] + HttpStatus(u16), + #[error("CLI release response exceeded {MAX_RELEASE_BYTES} bytes")] + ResponseTooLarge, + #[error("CLI release metadata is invalid")] + InvalidMetadata, + #[error("CLI release version is older than the active profile")] + Rollback, + #[error("CLI profile cache operation failed: {0}")] + Cache(String), +} + +fn version_sequence(version: &str) -> Result { + let parsed = Version::parse(version).map_err(|_| ProfileRefreshError::InvalidMetadata)?; + if !parsed.pre.is_empty() + || !parsed.build.is_empty() + || parsed.major > 999 + || parsed.minor > 999 + || parsed.patch > 999 + { + return Err(ProfileRefreshError::InvalidMetadata); + } + Ok(1 + parsed.major * 1_000_000 + parsed.minor * 1_000 + parsed.patch) +} + +/// 校验官方 npm 标签及全部平台依赖来自同一版本发布。 +fn parse_cli_release( + spec: &CliClientProfileSpec, + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_RELEASE_BYTES { + return Err(ProfileRefreshError::ResponseTooLarge); + } + let release = serde_json::from_slice::(bytes) + .map_err(|_| ProfileRefreshError::InvalidMetadata)?; + let sequence = version_sequence(&release.version)?; + if sequence == 0 + || release.name != spec.package_name + || spec.platform_targets.iter().any(|target| { + let (name, expected) = + (spec.platform_dependency)(spec.package_name, target, &release.version); + release.optional_dependencies.get(&name) != Some(&expected) + }) + { + return Err(ProfileRefreshError::InvalidMetadata); + } + Ok(release.version) +} + +fn refresh_enabled_from(value: Option<&str>) -> bool { + !value.is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" + ) + }) +} + +fn refresh_enabled(spec: &CliClientProfileSpec) -> bool { + refresh_enabled_from(std::env::var(spec.refresh_env).ok().as_deref()) +} + +fn fixed_version_from(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() || version_sequence(value).is_err() { + None + } else { + Some(value.to_owned()) + } +} + +fn fixed_version_override(spec: &CliClientProfileSpec) -> Option { + let value = std::env::var(spec.fixed_version_env).ok()?; + let version = fixed_version_from(Some(&value)); + if version.is_none() { + warn!( + event_name = "cli_client_profile_fixed_version_invalid", + client = spec.client, + env = spec.fixed_version_env, + "fixed CLI client version is invalid; using cached or built-in profile" + ); + } + version +} + +fn build_release_client() -> Result { + Client::builder() + .https_only(true) + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(RELEASE_CONNECT_TIMEOUT) + .timeout(RELEASE_REQUEST_TIMEOUT) + .build() + .map_err(ProfileRefreshError::Client) +} + +async fn fetch_latest_cli_version( + spec: &CliClientProfileSpec, + client: &Client, +) -> Result { + let response = client + .get(spec.release_endpoint) + .send() + .await + .map_err(ProfileRefreshError::Client)?; + if !response.status().is_success() { + return Err(ProfileRefreshError::HttpStatus(response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|length| length > MAX_RELEASE_BYTES as u64) + { + return Err(ProfileRefreshError::ResponseTooLarge); + } + + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(ProfileRefreshError::Client)?; + if bytes.len().saturating_add(chunk.len()) > MAX_RELEASE_BYTES { + return Err(ProfileRefreshError::ResponseTooLarge); + } + bytes.extend_from_slice(&chunk); + } + parse_cli_release(spec, &bytes) +} + +fn publish(spec: &CliClientProfileSpec, version: &str) -> Result<(), ProfileRefreshError> { + (spec.publish_version)(version).map_err(|_| ProfileRefreshError::InvalidMetadata) +} + +async fn restore_cached_profile( + spec: &CliClientProfileSpec, + runtime: &RuntimeState, +) -> Result<(), ProfileRefreshError> { + let Some(raw) = runtime + .kv_get(spec.cache_key) + .await + .map_err(|err| ProfileRefreshError::Cache(err.to_string()))? + else { + return Ok(()); + }; + let cached = serde_json::from_str::(&raw) + .map_err(|_| ProfileRefreshError::InvalidMetadata)?; + if let Some(version) = cached_version_to_restore(&cached, &(spec.active_version)())? { + publish(spec, &version)?; + info!( + event_name = "cli_client_profile_restored", + client = spec.client, + version = %version, + verified_at_unix_secs = cached.verified_at_unix_secs, + "restored cached CLI client profile" + ); + } + Ok(()) +} + +fn cached_version_to_restore( + cached: &CachedProfile, + active_version: &str, +) -> Result, ProfileRefreshError> { + let cached_sequence = version_sequence(&cached.version)?; + let active_sequence = version_sequence(active_version)?; + Ok((cached_sequence >= active_sequence).then(|| cached.version.clone())) +} + +async fn refresh_once_with_fetch( + spec: &CliClientProfileSpec, + runtime: &RuntimeState, + fixed_version: Option<&str>, + refresh_is_enabled: bool, + fetch_latest: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if let Some(version) = fixed_version { + publish(spec, version)?; + return Ok(version.to_owned()); + } + + if let Err(error) = restore_cached_profile(spec, runtime).await { + // 缓存损坏或暂时不可用不应阻断官方版本检查;当前进程继续使用旧画像。 + warn!( + event_name = "cli_client_profile_cache_restore_failed", + client = spec.client, + error = %error, + "could not restore cached CLI client profile" + ); + } + if !refresh_is_enabled { + return Ok((spec.active_version)()); + } + + let version = fetch_latest().await?; + let current = (spec.active_version)(); + if version_sequence(&version)? < version_sequence(¤t)? { + return Err(ProfileRefreshError::Rollback); + } + + let cached = CachedProfile { + version: version.clone(), + verified_at_unix_secs: chrono::Utc::now().timestamp().max(0) as u64, + }; + let serialized = + serde_json::to_string(&cached).map_err(|_| ProfileRefreshError::InvalidMetadata)?; + publish(spec, &version)?; + if let Err(error) = runtime + .kv_set(spec.cache_key, serialized, Some(PROFILE_CACHE_TTL)) + .await + { + // 本地画像已经完成原子替换;缓存写失败只影响下次进程启动的恢复。 + warn!( + event_name = "cli_client_profile_cache_write_failed", + client = spec.client, + error = %error, + "published CLI client profile locally but could not persist the cache" + ); + } + Ok(version) +} + +async fn refresh_once( + spec: &CliClientProfileSpec, + runtime: &RuntimeState, +) -> Result { + let fixed_version = fixed_version_override(spec); + refresh_once_with_fetch( + spec, + runtime, + fixed_version.as_deref(), + refresh_enabled(spec), + || async { + let client = build_release_client()?; + fetch_latest_cli_version(spec, &client).await + }, + ) + .await +} + +pub(crate) async fn prewarm( + spec: &CliClientProfileSpec, + runtime: &RuntimeState, +) -> Result { + refresh_once(spec, runtime) + .await + .map_err(|err| err.to_string()) +} + +pub(crate) fn spawn_worker( + spec: &'static CliClientProfileSpec, + app: AppState, +) -> tokio::task::JoinHandle<()> { + crate::task_runtime::spawn_singleton_worker(app, spec.task_key, move |app| async move { + let mut interval = tokio::time::interval(PROFILE_REFRESH_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // 启动阶段由 prewarm 完成一次检查;后台任务只负责后续每日刷新,避免重复建连。 + interval.tick().await; + loop { + interval.tick().await; + match refresh_once(spec, app.runtime_state()).await { + Ok(version) => info!( + event_name = "cli_client_profile_refreshed", + client = spec.client, + version = %version, + "refreshed CLI client profile" + ), + Err(error) => warn!( + event_name = "cli_client_profile_refresh_failed", + client = spec.client, + error = %error, + "keeping the previous CLI client profile after refresh failure" + ), + } + } + }) +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }; + use std::time::Duration; + + use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState}; + + use super::{ + cached_version_to_restore, fixed_version_from, parse_cli_release, refresh_enabled_from, + refresh_once_with_fetch, CachedProfile, CliClientProfileSpec, ProfileRefreshError, + CLAUDE_CODE_CLI_PROFILE, CODEX_CLI_PROFILE, + }; + + const TEST_BUILTIN_VERSION: &str = "1.0.0"; + + /// 测试画像使用独立存储,避免改写进程级 Codex / Claude Code 画像而干扰并行测试。 + static TEST_ACTIVE_VERSION: Mutex = Mutex::new(String::new()); + /// 异步测试会跨 await 持有该锁,因此使用 tokio 的异步锁串行化共享测试画像。 + static TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + fn test_active_version() -> String { + TEST_ACTIVE_VERSION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn test_publish_version(version: &str) -> Result<(), &'static str> { + *TEST_ACTIVE_VERSION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = version.to_owned(); + Ok(()) + } + + static TEST_PROFILE: CliClientProfileSpec = CliClientProfileSpec { + client: "test", + release_endpoint: "https://registry.invalid/test/latest", + package_name: "@test/cli", + platform_targets: &["linux-x64"], + platform_dependency: super::claude_code_platform_dependency, + cache_key: "aether:test:client-profile:v1", + refresh_env: "AETHER_TEST_CLIENT_PROFILE_REFRESH", + fixed_version_env: "AETHER_TEST_CLIENT_VERSION", + task_key: "maintenance.test.client.profile", + active_version: test_active_version, + publish_version: test_publish_version, + }; + + async fn test_profile_guard() -> tokio::sync::MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().await; + test_publish_version(TEST_BUILTIN_VERSION).unwrap(); + guard + } + + #[test] + fn accepts_only_one_verified_codex_release_for_all_targets() { + let body = serde_json::json!({ + "name": "@openai/codex", + "version": "0.200.1", + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.200.1-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.200.1-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.200.1-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.200.1-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.200.1-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.200.1-win32-x64" + } + }); + assert_eq!( + parse_cli_release(&CODEX_CLI_PROFILE, &serde_json::to_vec(&body).unwrap()).unwrap(), + "0.200.1" + ); + } + + #[test] + fn accepts_only_one_verified_claude_code_release_for_all_targets() { + let mut body = serde_json::json!({ + "name": "@anthropic-ai/claude-code", + "version": "2.1.286", + "optionalDependencies": { + "@anthropic-ai/claude-code-darwin-arm64": "2.1.286", + "@anthropic-ai/claude-code-darwin-x64": "2.1.286", + "@anthropic-ai/claude-code-linux-arm64": "2.1.286", + "@anthropic-ai/claude-code-linux-x64": "2.1.286", + "@anthropic-ai/claude-code-linux-arm64-musl": "2.1.286", + "@anthropic-ai/claude-code-linux-x64-musl": "2.1.286", + "@anthropic-ai/claude-code-win32-arm64": "2.1.286", + "@anthropic-ai/claude-code-win32-x64": "2.1.286" + } + }); + assert_eq!( + parse_cli_release( + &CLAUDE_CODE_CLI_PROFILE, + &serde_json::to_vec(&body).unwrap() + ) + .unwrap(), + "2.1.286" + ); + + // 任一平台包与主包版本不一致都视为未完成的发布。 + body["optionalDependencies"]["@anthropic-ai/claude-code-linux-x64-musl"] = + serde_json::json!("2.1.285"); + assert!(parse_cli_release( + &CLAUDE_CODE_CLI_PROFILE, + &serde_json::to_vec(&body).unwrap() + ) + .is_err()); + } + + #[test] + fn rejects_incomplete_or_foreign_release() { + let incomplete = serde_json::json!({ + "name": "@openai/codex", + "version": "0.200.1", + "optionalDependencies": {} + }); + assert!(parse_cli_release( + &CODEX_CLI_PROFILE, + &serde_json::to_vec(&incomplete).unwrap() + ) + .is_err()); + + let foreign = serde_json::json!({ + "name": "@openai/codex", + "version": "2.1.286", + "optionalDependencies": { + "@openai/codex-linux-x64": "2.1.286" + } + }); + assert!(parse_cli_release( + &CLAUDE_CODE_CLI_PROFILE, + &serde_json::to_vec(&foreign).unwrap() + ) + .is_err()); + } + + #[test] + fn client_specs_do_not_share_cache_keys_or_env_switches() { + assert_ne!( + CODEX_CLI_PROFILE.cache_key, + CLAUDE_CODE_CLI_PROFILE.cache_key + ); + assert_ne!(CODEX_CLI_PROFILE.task_key, CLAUDE_CODE_CLI_PROFILE.task_key); + assert_ne!( + CODEX_CLI_PROFILE.refresh_env, + CLAUDE_CODE_CLI_PROFILE.refresh_env + ); + assert_ne!( + CODEX_CLI_PROFILE.fixed_version_env, + CLAUDE_CODE_CLI_PROFILE.fixed_version_env + ); + assert_eq!( + CLAUDE_CODE_CLI_PROFILE.fixed_version_env, + "AETHER_CLAUDE_CODE_CLIENT_VERSION" + ); + } + + #[test] + fn refresh_and_fixed_version_environment_policies_are_strict() { + assert!(!refresh_enabled_from(Some("off"))); + assert!(!refresh_enabled_from(Some(" FALSE "))); + assert!(refresh_enabled_from(None)); + assert_eq!( + fixed_version_from(Some(" 0.200.1 ")).as_deref(), + Some("0.200.1") + ); + assert!(fixed_version_from(Some("0.200.1-beta.1")).is_none()); + assert!(fixed_version_from(Some("1.2")).is_none()); + } + + #[test] + fn cached_profile_never_rewinds_active_profile() { + let cached = CachedProfile { + version: "0.200.1".to_string(), + verified_at_unix_secs: 1, + }; + assert_eq!( + cached_version_to_restore(&cached, "0.200.0").unwrap(), + Some("0.200.1".to_string()) + ); + assert_eq!(cached_version_to_restore(&cached, "0.201.0").unwrap(), None); + } + + #[tokio::test] + async fn cache_hit_is_restored_without_network_when_refresh_is_disabled() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + runtime + .kv_set( + TEST_PROFILE.cache_key, + serde_json::to_string(&CachedProfile { + version: "1.2.0".to_string(), + verified_at_unix_secs: 1, + }) + .unwrap(), + Some(Duration::from_secs(60)), + ) + .await + .unwrap(); + + let result = refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, false, || async { + Err(ProfileRefreshError::HttpStatus(599)) + }) + .await + .unwrap(); + + assert_eq!(result, "1.2.0"); + assert_eq!(test_active_version(), "1.2.0"); + } + + #[tokio::test] + async fn successful_refresh_publishes_and_caches_profile() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + let result = refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, true, || async { + Ok("1.3.0".to_string()) + }) + .await + .unwrap(); + + assert_eq!(result, "1.3.0"); + assert_eq!(test_active_version(), "1.3.0"); + let cached = runtime + .kv_get(TEST_PROFILE.cache_key) + .await + .unwrap() + .expect("cached profile"); + let cached = serde_json::from_str::(&cached).unwrap(); + assert_eq!(cached.version, "1.3.0"); + } + + #[tokio::test] + async fn refresh_failure_keeps_previous_profile() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + let result = refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, true, || async { + Err(ProfileRefreshError::HttpStatus(503)) + }) + .await; + + assert!(matches!(result, Err(ProfileRefreshError::HttpStatus(503)))); + assert_eq!(test_active_version(), TEST_BUILTIN_VERSION); + } + + #[tokio::test] + async fn fixed_version_override_skips_network_and_publishes_profile() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + let fetch_called = AtomicBool::new(false); + let result = + refresh_once_with_fetch(&TEST_PROFILE, &runtime, Some("1.5.0"), true, || async { + fetch_called.store(true, Ordering::SeqCst); + Ok("1.6.0".to_string()) + }) + .await + .unwrap(); + + assert_eq!(result, "1.5.0"); + assert!(!fetch_called.load(Ordering::SeqCst)); + assert_eq!(test_active_version(), "1.5.0"); + } + + #[tokio::test] + async fn rollback_is_rejected_without_replacing_profile() { + let _guard = test_profile_guard().await; + (TEST_PROFILE.publish_version)("1.5.0").unwrap(); + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + let result = refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, true, || async { + Ok("1.4.9".to_string()) + }) + .await; + + assert!(matches!(result, Err(ProfileRefreshError::Rollback))); + assert_eq!(test_active_version(), "1.5.0"); + } +} diff --git a/apps/aether-gateway/src/codex_profile.rs b/apps/aether-gateway/src/codex_profile.rs deleted file mode 100644 index aa27cc50c..000000000 --- a/apps/aether-gateway/src/codex_profile.rs +++ /dev/null @@ -1,468 +0,0 @@ -//! Codex 客户端画像的运行时发布与官方 CLI 版本刷新。 - -use std::collections::BTreeMap; -use std::future::Future; -use std::time::Duration; - -use aether_runtime_state::RuntimeState; -use futures_util::StreamExt as _; -use reqwest::{redirect::Policy, Client}; -use semver::Version; -use serde::{Deserialize, Serialize}; -use tracing::{info, warn}; - -use crate::ai_serving::api::{codex_client_version, set_codex_cli_version}; -use crate::AppState; - -const CLI_RELEASE_ENDPOINT: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest"; -const PROFILE_CACHE_KEY: &str = "aether:codex:client-profile:v1"; -const PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); -const PROFILE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); -const RELEASE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const RELEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -const MAX_RELEASE_BYTES: usize = 256 * 1024; -const CLI_TARGETS: [&str; 6] = [ - "darwin-arm64", - "darwin-x64", - "linux-arm64", - "linux-x64", - "win32-arm64", - "win32-x64", -]; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct NpmRelease { - name: String, - version: String, - optional_dependencies: BTreeMap, -} - -#[derive(Debug, Deserialize, Serialize)] -struct CachedProfile { - version: String, - verified_at_unix_secs: u64, -} - -#[derive(Debug, thiserror::Error)] -enum ProfileRefreshError { - #[error("Codex CLI release client initialization failed: {0}")] - Client(#[from] reqwest::Error), - #[error("Codex CLI release request returned HTTP {0}")] - HttpStatus(u16), - #[error("Codex CLI release response exceeded {MAX_RELEASE_BYTES} bytes")] - ResponseTooLarge, - #[error("Codex CLI release metadata is invalid")] - InvalidMetadata, - #[error("Codex CLI release version is older than the active profile")] - Rollback, - #[error("Codex CLI profile cache operation failed: {0}")] - Cache(String), -} - -fn version_sequence(version: &str) -> Result { - let parsed = Version::parse(version).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - if !parsed.pre.is_empty() - || !parsed.build.is_empty() - || parsed.major > 999 - || parsed.minor > 999 - || parsed.patch > 999 - { - return Err(ProfileRefreshError::InvalidMetadata); - } - Ok(1 + parsed.major * 1_000_000 + parsed.minor * 1_000 + parsed.patch) -} - -/// 校验官方 npm stable 标签及六个平台依赖来自同一版本发布。 -fn parse_cli_release(bytes: &[u8]) -> Result { - if bytes.len() > MAX_RELEASE_BYTES { - return Err(ProfileRefreshError::ResponseTooLarge); - } - let release = serde_json::from_slice::(bytes) - .map_err(|_| ProfileRefreshError::InvalidMetadata)?; - let sequence = version_sequence(&release.version)?; - if sequence == 0 - || release.name != "@openai/codex" - || CLI_TARGETS.iter().any(|target| { - release - .optional_dependencies - .get(&format!("@openai/codex-{target}")) - != Some(&format!("npm:@openai/codex@{}-{target}", release.version)) - }) - { - return Err(ProfileRefreshError::InvalidMetadata); - } - Ok(release.version) -} - -fn refresh_enabled_from(value: Option<&str>) -> bool { - !value.is_some_and(|value| { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "off" - ) - }) -} - -fn refresh_enabled() -> bool { - refresh_enabled_from( - std::env::var("AETHER_CODEX_CLIENT_PROFILE_REFRESH") - .ok() - .as_deref(), - ) -} - -fn fixed_version_from(value: Option<&str>) -> Option { - let value = value?.trim(); - if value.is_empty() || version_sequence(value).is_err() { - None - } else { - Some(value.to_owned()) - } -} - -fn fixed_version_override() -> Option { - let value = std::env::var("AETHER_CODEX_CLIENT_VERSION").ok()?; - let version = fixed_version_from(Some(&value)); - if version.is_none() { - warn!( - event_name = "codex_client_profile_fixed_version_invalid", - "AETHER_CODEX_CLIENT_VERSION is invalid; using cached or built-in profile" - ); - } - version -} - -fn build_release_client() -> Result { - Client::builder() - .https_only(true) - .no_proxy() - .redirect(Policy::none()) - .connect_timeout(RELEASE_CONNECT_TIMEOUT) - .timeout(RELEASE_REQUEST_TIMEOUT) - .build() - .map_err(ProfileRefreshError::Client) -} - -async fn fetch_latest_cli_version(client: &Client) -> Result { - let response = client - .get(CLI_RELEASE_ENDPOINT) - .send() - .await - .map_err(ProfileRefreshError::Client)?; - if !response.status().is_success() { - return Err(ProfileRefreshError::HttpStatus(response.status().as_u16())); - } - if response - .content_length() - .is_some_and(|length| length > MAX_RELEASE_BYTES as u64) - { - return Err(ProfileRefreshError::ResponseTooLarge); - } - - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(ProfileRefreshError::Client)?; - if bytes.len().saturating_add(chunk.len()) > MAX_RELEASE_BYTES { - return Err(ProfileRefreshError::ResponseTooLarge); - } - bytes.extend_from_slice(&chunk); - } - parse_cli_release(&bytes) -} - -async fn restore_cached_profile(runtime: &RuntimeState) -> Result<(), ProfileRefreshError> { - let Some(raw) = runtime - .kv_get(PROFILE_CACHE_KEY) - .await - .map_err(|err| ProfileRefreshError::Cache(err.to_string()))? - else { - return Ok(()); - }; - let cached = serde_json::from_str::(&raw) - .map_err(|_| ProfileRefreshError::InvalidMetadata)?; - if let Some(version) = cached_version_to_restore(&cached, &codex_client_version())? { - set_codex_cli_version(&version).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - info!( - event_name = "codex_client_profile_restored", - version = %version, - verified_at_unix_secs = cached.verified_at_unix_secs, - "restored cached Codex CLI profile" - ); - } - Ok(()) -} - -fn cached_version_to_restore( - cached: &CachedProfile, - active_version: &str, -) -> Result, ProfileRefreshError> { - let cached_sequence = version_sequence(&cached.version)?; - let active_sequence = version_sequence(active_version)?; - Ok((cached_sequence >= active_sequence).then(|| cached.version.clone())) -} - -async fn refresh_once_with_fetch( - runtime: &RuntimeState, - fixed_version: Option<&str>, - refresh_is_enabled: bool, - fetch_latest: F, -) -> Result -where - F: FnOnce() -> Fut, - Fut: Future>, -{ - if let Some(version) = fixed_version { - set_codex_cli_version(version).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - return Ok(version.to_owned()); - } - - if let Err(error) = restore_cached_profile(runtime).await { - // 缓存损坏或暂时不可用不应阻断官方版本检查;当前进程继续使用旧画像。 - warn!( - event_name = "codex_client_profile_cache_restore_failed", - error = %error, - "could not restore cached Codex CLI profile" - ); - } - if !refresh_is_enabled { - return Ok(codex_client_version()); - } - - let version = fetch_latest().await?; - let current = codex_client_version(); - if version_sequence(&version)? < version_sequence(¤t)? { - return Err(ProfileRefreshError::Rollback); - } - - let cached = CachedProfile { - version: version.clone(), - verified_at_unix_secs: chrono::Utc::now().timestamp().max(0) as u64, - }; - let serialized = - serde_json::to_string(&cached).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - set_codex_cli_version(&version).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - if let Err(error) = runtime - .kv_set(PROFILE_CACHE_KEY, serialized, Some(PROFILE_CACHE_TTL)) - .await - { - // 本地画像已经完成原子替换;缓存写失败只影响下次进程启动的恢复。 - warn!( - event_name = "codex_client_profile_cache_write_failed", - error = %error, - "published Codex CLI profile locally but could not persist the cache" - ); - } - Ok(version) -} - -async fn refresh_once(runtime: &RuntimeState) -> Result { - let fixed_version = fixed_version_override(); - refresh_once_with_fetch( - runtime, - fixed_version.as_deref(), - refresh_enabled(), - || async { - let client = build_release_client()?; - fetch_latest_cli_version(&client).await - }, - ) - .await -} - -pub(crate) async fn prewarm(runtime: &RuntimeState) -> Result { - refresh_once(runtime).await.map_err(|err| err.to_string()) -} - -pub(crate) fn spawn_worker(app: AppState) -> tokio::task::JoinHandle<()> { - crate::task_runtime::spawn_singleton_worker( - app, - crate::task_runtime::TASK_KEY_CODEX_CLIENT_PROFILE, - |app| async move { - let mut interval = tokio::time::interval(PROFILE_REFRESH_INTERVAL); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - // 启动阶段由 prewarm 完成一次检查;后台任务只负责后续每日刷新,避免重复建连。 - interval.tick().await; - loop { - interval.tick().await; - match refresh_once(app.runtime_state()).await { - Ok(version) => info!( - event_name = "codex_client_profile_refreshed", - version = %version, - "refreshed Codex CLI profile" - ), - Err(error) => warn!( - event_name = "codex_client_profile_refresh_failed", - error = %error, - "keeping the previous Codex CLI profile after refresh failure" - ), - } - } - }, - ) -} - -#[cfg(test)] -mod tests { - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Mutex, OnceLock, - }; - use std::time::Duration; - - use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState}; - - use super::{ - cached_version_to_restore, fixed_version_from, parse_cli_release, refresh_enabled_from, - refresh_once_with_fetch, CachedProfile, ProfileRefreshError, PROFILE_CACHE_KEY, - }; - use crate::ai_serving::api::{ - codex_client_profile, codex_client_version, set_codex_cli_version, - set_codex_client_profile, CodexClientProfile, - }; - - static PROFILE_TEST_LOCK: OnceLock> = OnceLock::new(); - - struct ProfileRestore(CodexClientProfile); - - impl Drop for ProfileRestore { - fn drop(&mut self) { - set_codex_client_profile(self.0.clone()); - } - } - - fn profile_restore_guard() -> (std::sync::MutexGuard<'static, ()>, ProfileRestore) { - let lock = PROFILE_TEST_LOCK.get_or_init(|| Mutex::new(())); - let guard = lock.lock().expect("profile test lock"); - let restore = ProfileRestore(codex_client_profile()); - (guard, restore) - } - - #[test] - fn accepts_only_one_verified_cli_release_for_all_targets() { - let body = serde_json::json!({ - "name": "@openai/codex", - "version": "0.200.1", - "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.200.1-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.200.1-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.200.1-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.200.1-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.200.1-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.200.1-win32-x64" - } - }); - assert_eq!( - parse_cli_release(&serde_json::to_vec(&body).unwrap()).unwrap(), - "0.200.1" - ); - } - - #[test] - fn rejects_incomplete_platform_release() { - let body = serde_json::json!({ - "name": "@openai/codex", - "version": "0.200.1", - "optionalDependencies": {} - }); - assert!(parse_cli_release(&serde_json::to_vec(&body).unwrap()).is_err()); - } - - #[test] - fn refresh_and_fixed_version_environment_policies_are_strict() { - assert!(!refresh_enabled_from(Some("off"))); - assert!(!refresh_enabled_from(Some(" FALSE "))); - assert!(refresh_enabled_from(None)); - assert_eq!( - fixed_version_from(Some(" 0.200.1 ")).as_deref(), - Some("0.200.1") - ); - assert!(fixed_version_from(Some("0.200.1-beta.1")).is_none()); - assert!(fixed_version_from(Some("1.2")).is_none()); - } - - #[test] - fn cached_profile_never_rewinds_active_profile() { - let cached = CachedProfile { - version: "0.200.1".to_string(), - verified_at_unix_secs: 1, - }; - assert_eq!( - cached_version_to_restore(&cached, "0.200.0").unwrap(), - Some("0.200.1".to_string()) - ); - assert_eq!(cached_version_to_restore(&cached, "0.201.0").unwrap(), None); - } - - #[tokio::test] - async fn cache_hit_is_restored_without_network_when_refresh_is_disabled() { - let (_lock, _restore) = profile_restore_guard(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - runtime - .kv_set( - PROFILE_CACHE_KEY, - serde_json::to_string(&CachedProfile { - version: "0.200.1".to_string(), - verified_at_unix_secs: 1, - }) - .unwrap(), - Some(Duration::from_secs(60)), - ) - .await - .unwrap(); - - let result = refresh_once_with_fetch(&runtime, None, false, || async { - Err(ProfileRefreshError::HttpStatus(599)) - }) - .await - .unwrap(); - - assert_eq!(result, "0.200.1"); - assert_eq!(codex_client_version(), "0.200.1"); - } - - #[tokio::test] - async fn refresh_failure_keeps_previous_profile() { - let (_lock, _restore) = profile_restore_guard(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let before = codex_client_profile(); - let result = refresh_once_with_fetch(&runtime, None, true, || async { - Err(ProfileRefreshError::HttpStatus(503)) - }) - .await; - - assert!(matches!(result, Err(ProfileRefreshError::HttpStatus(503)))); - assert_eq!(codex_client_profile(), before); - } - - #[tokio::test] - async fn fixed_version_override_skips_network_and_publishes_profile() { - let (_lock, _restore) = profile_restore_guard(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let fetch_called = AtomicBool::new(false); - let result = refresh_once_with_fetch(&runtime, Some("0.220.0"), true, || async { - fetch_called.store(true, Ordering::SeqCst); - Ok("0.221.0".to_string()) - }) - .await - .unwrap(); - - assert_eq!(result, "0.220.0"); - assert!(!fetch_called.load(Ordering::SeqCst)); - assert_eq!(codex_client_version(), "0.220.0"); - } - - #[tokio::test] - async fn rollback_is_rejected_without_replacing_profile() { - let (_lock, _restore) = profile_restore_guard(); - set_codex_cli_version("0.220.0").unwrap(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let result = - refresh_once_with_fetch(&runtime, None, true, || async { Ok("0.219.9".to_string()) }) - .await; - - assert!(matches!(result, Err(ProfileRefreshError::Rollback))); - assert_eq!(codex_client_version(), "0.220.0"); - } -} diff --git a/apps/aether-gateway/src/lib.rs b/apps/aether-gateway/src/lib.rs index 20c2ba715..5cbea4f1b 100644 --- a/apps/aether-gateway/src/lib.rs +++ b/apps/aether-gateway/src/lib.rs @@ -35,9 +35,9 @@ mod auth; mod backup; mod bark_push; mod cache; +mod cli_client_profile; mod client_session_affinity; mod clock; -mod codex_profile; mod constants; mod control; mod data; diff --git a/apps/aether-gateway/src/main.rs b/apps/aether-gateway/src/main.rs index c130fb53b..da04731e6 100644 --- a/apps/aether-gateway/src/main.rs +++ b/apps/aether-gateway/src/main.rs @@ -2513,7 +2513,12 @@ async fn run() -> Result<(), Box> { ); } } - match state.prewarm_codex_client_profile().await { + // 两个 CLI 画像的发布检查互不依赖,并发执行以免叠加启动阶段的网络超时。 + let (codex_profile, claude_code_profile) = tokio::join!( + state.prewarm_codex_client_profile(), + state.prewarm_claude_code_client_profile(), + ); + match codex_profile { Ok(version) => { info!( codex_client_version = %version, @@ -2527,6 +2532,20 @@ async fn run() -> Result<(), Box> { ); } } + match claude_code_profile { + Ok(version) => { + info!( + claude_code_client_version = %version, + "prewarmed Claude Code client profile" + ); + } + Err(err) => { + warn!( + error = %err, + "failed to refresh Claude Code client profile; built-in or cached profile remains active" + ); + } + } match prewarm_direct_h2c_sender_cache_from_env_for_startup().await { Ok(Some(report)) => { if report.failed_targets > 0 { diff --git a/apps/aether-gateway/src/state/core.rs b/apps/aether-gateway/src/state/core.rs index 52420dfaf..3478ab4ae 100644 --- a/apps/aether-gateway/src/state/core.rs +++ b/apps/aether-gateway/src/state/core.rs @@ -53,7 +53,9 @@ use super::super::router::RequestAdmissionError; use super::super::{control::GatewayControlDecision, error::GatewayError}; use super::super::{provider_transport, usage}; -use crate::codex_profile::spawn_worker as spawn_codex_client_profile_worker; +use crate::cli_client_profile::{ + spawn_worker as spawn_cli_client_profile_worker, CLAUDE_CODE_CLI_PROFILE, CODEX_CLI_PROFILE, +}; use crate::maintenance::spawn_account_self_check_worker; use crate::maintenance::spawn_audit_cleanup_worker; use crate::maintenance::spawn_db_maintenance_worker; @@ -150,7 +152,11 @@ fn system_config_key_affects_provider_transport_snapshot(key: &str) -> bool { impl AppState { pub async fn prewarm_codex_client_profile(&self) -> Result { - crate::codex_profile::prewarm(self.runtime_state()).await + crate::cli_client_profile::prewarm(&CODEX_CLI_PROFILE, self.runtime_state()).await + } + + pub async fn prewarm_claude_code_client_profile(&self) -> Result { + crate::cli_client_profile::prewarm(&CLAUDE_CODE_CLI_PROFILE, self.runtime_state()).await } pub async fn prewarm_chat_pii_redaction_runtime_config(&self) -> Result { @@ -2349,7 +2355,17 @@ impl AppState { ); supervise_worker( crate::task_runtime::TASK_KEY_CODEX_CLIENT_PROFILE, - Some(spawn_codex_client_profile_worker(background_state.clone())), + Some(spawn_cli_client_profile_worker( + &CODEX_CLI_PROFILE, + background_state.clone(), + )), + ); + supervise_worker( + crate::task_runtime::TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE, + Some(spawn_cli_client_profile_worker( + &CLAUDE_CODE_CLI_PROFILE, + background_state.clone(), + )), ); supervise_worker( crate::task_runtime::TASK_KEY_VIDEO_TASK_POLLER, diff --git a/apps/aether-gateway/src/task_runtime/mod.rs b/apps/aether-gateway/src/task_runtime/mod.rs index 226579c97..aa140544f 100644 --- a/apps/aether-gateway/src/task_runtime/mod.rs +++ b/apps/aether-gateway/src/task_runtime/mod.rs @@ -25,6 +25,8 @@ pub(crate) const TASK_KEY_USAGE_COUNTER_FLUSH: &str = "usage.counter.flush.worke pub(crate) const TASK_KEY_VIDEO_TASK_POLLER: &str = "video.task.poller"; pub(crate) const TASK_KEY_MODEL_FETCH_WORKER: &str = "model.fetch.worker"; pub(crate) const TASK_KEY_CODEX_CLIENT_PROFILE: &str = "maintenance.codex.client.profile"; +pub(crate) const TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE: &str = + "maintenance.claude_code.client.profile"; pub(crate) const TASK_KEY_PROVIDER_QUOTA_RESET: &str = "provider.quota.reset.worker"; pub(crate) const TASK_KEY_ACCOUNT_SELF_CHECK: &str = "account.self_check.worker"; pub(crate) const TASK_KEY_POOL_SCORE_REBUILD: &str = "pool.score.rebuild.worker"; @@ -211,6 +213,14 @@ const TASK_DEFINITIONS: &[TaskDefinition] = &[ true, RETRY_ONCE, ), + TaskDefinition::new( + TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE, + TaskKind::Scheduled, + "daily", + true, + true, + RETRY_ONCE, + ), TaskDefinition::new( TASK_KEY_PROVIDER_QUOTA_RESET, TaskKind::Scheduled, diff --git a/crates/aether-provider/pool/src/providers/claude_code.rs b/crates/aether-provider/pool/src/providers/claude_code.rs index 195dd40cb..73f2aa721 100644 --- a/crates/aether-provider/pool/src/providers/claude_code.rs +++ b/crates/aether-provider/pool/src/providers/claude_code.rs @@ -11,7 +11,6 @@ use crate::quota_refresh::ProviderPoolQuotaRequestSpec; pub const CLAUDE_CODE_OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1"; pub const CLAUDE_CODE_OAUTH_BETA: &str = "oauth-2025-04-20"; -pub const CLAUDE_CODE_USAGE_USER_AGENT: &str = "claude-cli/2.1.284 (external, cli)"; #[derive(Debug, Clone, Default)] pub struct ClaudeCodeProviderPoolAdapter; @@ -62,7 +61,7 @@ pub fn build_claude_code_pool_quota_request( ("x-app".to_string(), "cli".to_string()), ( "user-agent".to_string(), - CLAUDE_CODE_USAGE_USER_AGENT.to_string(), + aether_provider_transport::claude_code::claude_code_client_user_agent(), ), ]); diff --git a/crates/aether-provider/pool/src/providers/mod.rs b/crates/aether-provider/pool/src/providers/mod.rs index fe166a912..a7ada4e5f 100644 --- a/crates/aether-provider/pool/src/providers/mod.rs +++ b/crates/aether-provider/pool/src/providers/mod.rs @@ -24,7 +24,6 @@ pub use chatgpt_web::{ pub use claude_code::ClaudeCodeProviderPoolAdapter; pub use claude_code::{ build_claude_code_pool_quota_request, CLAUDE_CODE_OAUTH_BETA, CLAUDE_CODE_OAUTH_USAGE_URL, - CLAUDE_CODE_USAGE_USER_AGENT, }; pub use codex::CodexProviderPoolAdapter; pub use codex::{ diff --git a/crates/aether-provider/transport/src/claude_code/fingerprint.rs b/crates/aether-provider/transport/src/claude_code/fingerprint.rs index 6796cdb9d..dd4ffcb14 100644 --- a/crates/aether-provider/transport/src/claude_code/fingerprint.rs +++ b/crates/aether-provider/transport/src/claude_code/fingerprint.rs @@ -14,7 +14,7 @@ pub fn generate_fingerprint(seed: &str) -> Value { } fn generate_header_fingerprint(seed: &str) -> Value { - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); let vscode_session_id = Uuid::new_v5( &Uuid::NAMESPACE_URL, format!("aether:fingerprint:{seed}").as_bytes(), @@ -40,7 +40,7 @@ fn generate_header_fingerprint(seed: &str) -> Value { } fn wrap_header_fingerprint(header_fingerprint: Value) -> Value { - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); serde_json::json!({ "transport_profile": { "profile_id": profile.transport_profile_id(), diff --git a/crates/aether-provider/transport/src/claude_code/mimicry.rs b/crates/aether-provider/transport/src/claude_code/mimicry.rs index 4ead9b97a..4840b5d1a 100644 --- a/crates/aether-provider/transport/src/claude_code/mimicry.rs +++ b/crates/aether-provider/transport/src/claude_code/mimicry.rs @@ -103,7 +103,7 @@ pub fn apply_claude_code_body_mimicry( return false; } - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); let model = object .get("model") .and_then(Value::as_str) diff --git a/crates/aether-provider/transport/src/claude_code/mod.rs b/crates/aether-provider/transport/src/claude_code/mod.rs index 28df0f09e..003f2a6bf 100644 --- a/crates/aether-provider/transport/src/claude_code/mod.rs +++ b/crates/aether-provider/transport/src/claude_code/mod.rs @@ -20,8 +20,11 @@ pub use policy::{ supports_local_claude_code_transport_with_network, }; pub use profile::{ - current_claude_code_transport_identity_profile, ClaudeCodeBodyCapabilityGate, + claude_code_client_profile, claude_code_client_user_agent, claude_code_client_version, + current_claude_code_transport_identity_profile, set_claude_code_cli_version, + set_claude_code_client_profile, ClaudeCodeBodyCapabilityGate, ClaudeCodeClientProfile, ClaudeCodeTransportIdentityProfile, ClaudeCodeTransportIdentityProfileVersion, + ClaudeCodeTransportIdentityTemplate, CLAUDE_CODE_BUILTIN_CLI_VERSION, CLAUDE_CODE_CONTEXT_MANAGEMENT_BETA, CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04, }; pub use request::{ diff --git a/crates/aether-provider/transport/src/claude_code/profile.rs b/crates/aether-provider/transport/src/claude_code/profile.rs index 8778ea8a2..01cc6de0c 100644 --- a/crates/aether-provider/transport/src/claude_code/profile.rs +++ b/crates/aether-provider/transport/src/claude_code/profile.rs @@ -1,9 +1,14 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, OnceLock, RwLock}; use aether_ai_formats::ApiOperation; pub const CLAUDE_CODE_CONTEXT_MANAGEMENT_BETA: &str = "context-management-2025-06-27"; +/// Built-in Claude Code CLI version used until the gateway publishes a newer +/// verified release (or when release checks are disabled / unavailable). +pub const CLAUDE_CODE_BUILTIN_CLI_VERSION: &str = "2.1.284"; + const MESSAGE_BETAS_2026_04: &[&str] = &[ "claude-code-20250219", "oauth-2025-04-20", @@ -53,15 +58,93 @@ pub struct ClaudeCodeBodyCapabilityGate { pub default_edit_type: Option<&'static str>, } -/// Versioned upstream identity used when Aether is intentionally acting as a -/// Claude Code transport. Native Anthropic transports never resolve this -/// profile and therefore keep their original headers and body untouched. +/// Dynamic Claude Code CLI identity (version-derived wire values). +/// +/// The gateway refreshes this from verified official releases in a background +/// task; request paths only read an immutable snapshot and never touch the +/// network. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeCodeClientProfile { + pub cli_version: String, + pub user_agent: String, +} + +impl ClaudeCodeClientProfile { + /// Builds a CLI profile from a release version. Release verification is owned + /// by the release checker; the constructor only rejects clearly invalid values. + pub fn cli(version: &str) -> Result { + let version = version.trim(); + if version.is_empty() + || version.len() > 64 + || !version.bytes().all(|byte| (33..=126).contains(&byte)) + { + return Err("invalid Claude Code CLI version"); + } + Ok(Self { + cli_version: version.to_owned(), + user_agent: format!("claude-cli/{version} (external, cli)"), + }) + } +} + +impl Default for ClaudeCodeClientProfile { + fn default() -> Self { + Self::cli(CLAUDE_CODE_BUILTIN_CLI_VERSION) + .expect("built-in Claude Code CLI profile must be valid") + } +} + +static ACTIVE_CLIENT_PROFILE: OnceLock>> = OnceLock::new(); + +fn active_client_profile() -> &'static RwLock> { + ACTIVE_CLIENT_PROFILE.get_or_init(|| RwLock::new(Arc::new(ClaudeCodeClientProfile::default()))) +} + +/// Returns a snapshot of the active CLI profile without holding the global lock. +pub fn claude_code_client_profile() -> Arc { + active_client_profile() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +/// Atomically replaces the active CLI profile and returns the previous one. +pub fn set_claude_code_client_profile( + profile: ClaudeCodeClientProfile, +) -> Arc { + let mut current = active_client_profile() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::replace(&mut *current, Arc::new(profile)) +} + +/// Publishes a CLI profile for the given release version. +pub fn set_claude_code_cli_version( + version: &str, +) -> Result, &'static str> { + let profile = ClaudeCodeClientProfile::cli(version)?; + Ok(set_claude_code_client_profile(profile)) +} + +/// Returns the active Claude Code CLI version. +pub fn claude_code_client_version() -> String { + claude_code_client_profile().cli_version.clone() +} + +/// Returns the active Claude Code CLI User-Agent. +pub fn claude_code_client_user_agent() -> String { + claude_code_client_profile().user_agent.clone() +} + +/// Versioned, static part of the Claude Code transport identity: Stainless SDK +/// and runtime values, beta policy and body capability gates. These values are +/// calibrated together and change only with a new template version; the CLI +/// version itself is supplied by the dynamic [`ClaudeCodeClientProfile`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ClaudeCodeTransportIdentityProfile { +pub struct ClaudeCodeTransportIdentityTemplate { version: ClaudeCodeTransportIdentityProfileVersion, transport_profile_id: &'static str, anthropic_version: &'static str, - cli_version: &'static str, stainless_lang: &'static str, stainless_package_version: &'static str, stainless_os: &'static str, @@ -77,12 +160,11 @@ pub struct ClaudeCodeTransportIdentityProfile { body_capability_gates: &'static [ClaudeCodeBodyCapabilityGate], } -pub const CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04: ClaudeCodeTransportIdentityProfile = - ClaudeCodeTransportIdentityProfile { +pub const CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04: ClaudeCodeTransportIdentityTemplate = + ClaudeCodeTransportIdentityTemplate { version: ClaudeCodeTransportIdentityProfileVersion::V2026_04, transport_profile_id: "claude_code_nodejs", anthropic_version: "2023-06-01", - cli_version: "2.1.284", stainless_lang: "js", stainless_package_version: "0.112.1", stainless_os: "Linux", @@ -98,115 +180,139 @@ pub const CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04: ClaudeCodeTransportIdentityPro body_capability_gates: BODY_CAPABILITY_GATES_2026_04, }; -pub const fn current_claude_code_transport_identity_profile( -) -> &'static ClaudeCodeTransportIdentityProfile { - &CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04 +/// Upstream identity used when Aether is intentionally acting as a Claude Code +/// transport. Native Anthropic transports never resolve this profile and +/// therefore keep their original headers and body untouched. +/// +/// Each value is one coherent snapshot of the static template plus the active +/// CLI profile, so headers and billing attribution derived from the same +/// snapshot always agree on the CLI version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeCodeTransportIdentityProfile { + template: &'static ClaudeCodeTransportIdentityTemplate, + client: Arc, +} + +pub fn current_claude_code_transport_identity_profile() -> ClaudeCodeTransportIdentityProfile { + ClaudeCodeTransportIdentityProfile::new( + &CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04, + claude_code_client_profile(), + ) } impl ClaudeCodeTransportIdentityProfile { - pub const fn version(self) -> ClaudeCodeTransportIdentityProfileVersion { - self.version + pub fn new( + template: &'static ClaudeCodeTransportIdentityTemplate, + client: Arc, + ) -> Self { + Self { template, client } } - pub const fn transport_profile_id(self) -> &'static str { - self.transport_profile_id + pub fn version(&self) -> ClaudeCodeTransportIdentityProfileVersion { + self.template.version } - pub const fn cli_version(self) -> &'static str { - self.cli_version + pub fn transport_profile_id(&self) -> &'static str { + self.template.transport_profile_id } - pub const fn billing_cli_version(self) -> &'static str { - self.cli_version + pub fn cli_version(&self) -> &str { + &self.client.cli_version } - pub fn user_agent(self) -> String { - format!("claude-cli/{} (external, cli)", self.cli_version) + pub fn billing_cli_version(&self) -> &str { + &self.client.cli_version } - pub const fn stainless_package_version(self) -> &'static str { - self.stainless_package_version + pub fn user_agent(&self) -> &str { + &self.client.user_agent } - pub const fn stainless_lang(self) -> &'static str { - self.stainless_lang + pub fn stainless_package_version(&self) -> &'static str { + self.template.stainless_package_version } - pub const fn stainless_os(self) -> &'static str { - self.stainless_os + pub fn stainless_lang(&self) -> &'static str { + self.template.stainless_lang } - pub const fn stainless_arch(self) -> &'static str { - self.stainless_arch + pub fn stainless_os(&self) -> &'static str { + self.template.stainless_os } - pub const fn stainless_runtime(self) -> &'static str { - self.stainless_runtime + pub fn stainless_arch(&self) -> &'static str { + self.template.stainless_arch } - pub const fn stainless_runtime_version(self) -> &'static str { - self.stainless_runtime_version + pub fn stainless_runtime(&self) -> &'static str { + self.template.stainless_runtime } - pub const fn stainless_retry_count(self) -> &'static str { - self.stainless_retry_count + pub fn stainless_runtime_version(&self) -> &'static str { + self.template.stainless_runtime_version } - pub const fn stainless_timeout(self) -> &'static str { - self.stainless_timeout + pub fn stainless_retry_count(&self) -> &'static str { + self.template.stainless_retry_count } - pub fn required_beta_tokens(self, operation: Option) -> &'static [&'static str] { + pub fn stainless_timeout(&self) -> &'static str { + self.template.stainless_timeout + } + + pub fn required_beta_tokens(&self, operation: Option) -> &'static [&'static str] { if operation == Some(ApiOperation::ClaudeCountTokens) { - self.count_tokens_required_betas + self.template.count_tokens_required_betas } else { - self.message_required_betas + self.template.message_required_betas } } - pub const fn preserves_incoming_betas(self) -> bool { - self.preserve_incoming_betas + pub fn preserves_incoming_betas(&self) -> bool { + self.template.preserve_incoming_betas } - pub const fn dropped_beta_tokens(self) -> &'static [&'static str] { - self.dropped_betas + pub fn dropped_beta_tokens(&self) -> &'static [&'static str] { + self.template.dropped_betas } - pub const fn body_capability_gates(self) -> &'static [ClaudeCodeBodyCapabilityGate] { - self.body_capability_gates + pub fn body_capability_gates(&self) -> &'static [ClaudeCodeBodyCapabilityGate] { + self.template.body_capability_gates } - pub fn body_capability_gate(self, field: &str) -> Option { - self.body_capability_gates + pub fn body_capability_gate(&self, field: &str) -> Option { + self.template + .body_capability_gates .iter() .copied() .find(|gate| gate.body_field == field) } - pub fn apply_fixed_headers(self, headers: &mut BTreeMap, stream: bool) { + pub fn apply_fixed_headers(&self, headers: &mut BTreeMap, stream: bool) { + let template = self.template; for (name, value) in [ ("accept", "application/json"), - ("anthropic-version", self.anthropic_version), + ("anthropic-version", template.anthropic_version), ("anthropic-dangerous-direct-browser-access", "true"), ("x-app", "cli"), - ("x-stainless-lang", self.stainless_lang), + ("x-stainless-lang", template.stainless_lang), ( "x-stainless-package-version", - self.stainless_package_version, + template.stainless_package_version, ), - ("x-stainless-os", self.stainless_os), - ("x-stainless-arch", self.stainless_arch), - ("x-stainless-runtime", self.stainless_runtime), + ("x-stainless-os", template.stainless_os), + ("x-stainless-arch", template.stainless_arch), + ("x-stainless-runtime", template.stainless_runtime), ( "x-stainless-runtime-version", - self.stainless_runtime_version, + template.stainless_runtime_version, ), - ("x-stainless-retry-count", self.stainless_retry_count), - ("x-stainless-timeout", self.stainless_timeout), + ("x-stainless-retry-count", template.stainless_retry_count), + ("x-stainless-timeout", template.stainless_timeout), ] { headers.insert(name.to_string(), value.to_string()); } - headers.insert("user-agent".to_string(), self.user_agent()); + headers.insert("user-agent".to_string(), self.user_agent().to_string()); if stream { headers.insert( "x-stainless-helper-method".to_string(), @@ -218,7 +324,7 @@ impl ClaudeCodeTransportIdentityProfile { } pub fn apply_beta_policy( - self, + &self, headers: &mut BTreeMap, operation: Option, ) { @@ -232,7 +338,7 @@ impl ClaudeCodeTransportIdentityProfile { } pub fn merge_beta_tokens( - self, + &self, incoming: Option<&str>, operation: Option, ) -> String { @@ -242,7 +348,7 @@ impl ClaudeCodeTransportIdentityProfile { for token in self.required_beta_tokens(operation) { self.append_beta_token(&mut seen, &mut merged, token); } - if self.preserve_incoming_betas { + if self.template.preserve_incoming_betas { for token in incoming.unwrap_or_default().split(',') { self.append_beta_token(&mut seen, &mut merged, token); } @@ -250,7 +356,7 @@ impl ClaudeCodeTransportIdentityProfile { merged.join(",") } - pub fn beta_header_enables_body_field(self, beta_header: &str, field: &str) -> bool { + pub fn beta_header_enables_body_field(&self, beta_header: &str, field: &str) -> bool { let Some(gate) = self.body_capability_gate(field) else { return true; }; @@ -260,10 +366,16 @@ impl ClaudeCodeTransportIdentityProfile { .any(|token| token.eq_ignore_ascii_case(gate.beta_token)) } - fn append_beta_token(self, seen: &mut BTreeSet, merged: &mut Vec, token: &str) { + fn append_beta_token( + &self, + seen: &mut BTreeSet, + merged: &mut Vec, + token: &str, + ) { let token = token.trim(); if token.is_empty() || self + .template .dropped_betas .iter() .any(|dropped| token.eq_ignore_ascii_case(dropped)) @@ -278,12 +390,17 @@ impl ClaudeCodeTransportIdentityProfile { #[cfg(test)] mod tests { - use super::current_claude_code_transport_identity_profile; + use std::sync::Arc; + + use super::{ + current_claude_code_transport_identity_profile, ClaudeCodeClientProfile, + ClaudeCodeTransportIdentityProfile, CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04, + }; use aether_ai_formats::ApiOperation; #[test] fn profile_versions_cli_user_agent_stainless_and_billing_together() { - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); assert_eq!(profile.version().as_str(), "2026-04"); assert_eq!(profile.cli_version(), "2.1.284"); @@ -296,9 +413,43 @@ mod tests { assert_eq!(profile.stainless_runtime_version(), "v26.3.0"); } + #[test] + fn cli_profile_derives_wire_identity_from_version() { + let client = ClaudeCodeClientProfile::cli(" 2.1.300 ").expect("valid version"); + assert_eq!(client.cli_version, "2.1.300"); + assert_eq!(client.user_agent, "claude-cli/2.1.300 (external, cli)"); + + // A snapshot keeps headers and billing attribution on the same CLI version + // without mutating the process-wide active profile. + let profile = ClaudeCodeTransportIdentityProfile::new( + &CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04, + Arc::new(client), + ); + let mut headers = std::collections::BTreeMap::new(); + profile.apply_fixed_headers(&mut headers, false); + assert_eq!( + headers.get("user-agent").map(String::as_str), + Some("claude-cli/2.1.300 (external, cli)") + ); + assert_eq!(profile.billing_cli_version(), "2.1.300"); + assert_eq!( + headers + .get("x-stainless-package-version") + .map(String::as_str), + Some("0.112.1") + ); + } + + #[test] + fn cli_profile_rejects_empty_or_control_values() { + assert!(ClaudeCodeClientProfile::cli("").is_err()); + assert!(ClaudeCodeClientProfile::cli("2.1.0\nspoof").is_err()); + assert!(ClaudeCodeClientProfile::cli("2.1.0 spoof").is_err()); + } + #[test] fn profile_preserves_context_1m_and_adds_operation_specific_betas() { - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); let messages = profile.merge_beta_tokens(Some("context-1m-2025-08-07,custom"), None); assert!(messages diff --git a/crates/aether-provider/transport/src/claude_code/request.rs b/crates/aether-provider/transport/src/claude_code/request.rs index 8e2e15c6d..c63b68f91 100644 --- a/crates/aether-provider/transport/src/claude_code/request.rs +++ b/crates/aether-provider/transport/src/claude_code/request.rs @@ -49,7 +49,7 @@ pub fn build_claude_code_passthrough_headers( out.insert("anthropic-beta".to_string(), incoming_beta_values.join(",")); } - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); profile.apply_fixed_headers(&mut out, stream); profile.apply_beta_policy(&mut out, None); @@ -57,15 +57,15 @@ pub fn build_claude_code_passthrough_headers( } pub fn sanitize_claude_code_request_body(body: &mut Value) { - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); let beta_header = profile.merge_beta_tokens(None, None); - sanitize_claude_code_request_body_for_beta_header(body, &beta_header, profile); + sanitize_claude_code_request_body_for_beta_header(body, &beta_header, &profile); } pub fn sanitize_claude_code_request_body_for_beta_header( body: &mut Value, beta_header: &str, - profile: ClaudeCodeTransportIdentityProfile, + profile: &ClaudeCodeTransportIdentityProfile, ) { let Some(body_object) = body.as_object_mut() else { return; @@ -303,7 +303,7 @@ mod tests { #[test] fn context_management_body_is_gated_by_the_matching_beta_token() { - let profile = *current_claude_code_transport_identity_profile(); + let profile = current_claude_code_transport_identity_profile(); let original = json!({ "context_management": { "edits": [{"type":"clear_thinking_20251015", "keep":"all"}] @@ -315,7 +315,7 @@ mod tests { sanitize_claude_code_request_body_for_beta_header( &mut without_beta, "oauth-2025-04-20", - profile, + &profile, ); assert!(without_beta.get("context_management").is_none()); @@ -323,7 +323,7 @@ mod tests { sanitize_claude_code_request_body_for_beta_header( &mut with_beta, "oauth-2025-04-20, context-management-2025-06-27", - profile, + &profile, ); assert_eq!(with_beta, original); } diff --git a/crates/aether-provider/transport/src/network.rs b/crates/aether-provider/transport/src/network.rs index ece64e4ab..cd786dfa1 100644 --- a/crates/aether-provider/transport/src/network.rs +++ b/crates/aether-provider/transport/src/network.rs @@ -177,7 +177,7 @@ fn resolve_claude_code_transport_profile( return None; } - let identity_profile = *current_claude_code_transport_identity_profile(); + let identity_profile = current_claude_code_transport_identity_profile(); Some(ResolvedTransportProfile { profile_id: identity_profile.transport_profile_id().to_string(), backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.to_string(), diff --git a/crates/aether-provider/transport/src/same_format_provider/mod.rs b/crates/aether-provider/transport/src/same_format_provider/mod.rs index 3f3fa6358..c20bfd5be 100644 --- a/crates/aether-provider/transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider/transport/src/same_format_provider/mod.rs @@ -812,7 +812,7 @@ pub fn build_same_format_provider_headers( } else { replace_upstream_auth_headers(&mut provider_request_headers, "", ""); } - let claude_code_profile = *current_claude_code_transport_identity_profile(); + let claude_code_profile = current_claude_code_transport_identity_profile(); if input.behavior.is_claude_code_transport { claude_code_profile.apply_fixed_headers( &mut provider_request_headers, From 7acbaab82bd313c0d40b17cfff574ce6c3172837 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Mon, 5 Oct 2026 15:18:36 +0800 Subject: [PATCH 2/3] refactor: unify provider client identity profiles and node synchronization --- .../src/ai_serving/planner/gemini_cli.rs | 8 +- .../passthrough/provider/family/request.rs | 8 +- apps/aether-gateway/src/cli_client_profile.rs | 480 +++++++++++++- .../execution_runtime/chatgpt_web_image.rs | 11 +- .../admin/provider/query/models/model_test.rs | 2 +- apps/aether-gateway/src/lib.rs | 2 +- apps/aether-gateway/src/main.rs | 53 +- apps/aether-gateway/src/state/core.rs | 47 +- apps/aether-gateway/src/task_runtime/mod.rs | 10 + apps/aether-gateway/src/xai_profile.rs | 592 ------------------ .../aether-ai/formats/src/client_profile.rs | 67 ++ crates/aether-ai/formats/src/codex_profile.rs | 20 +- crates/aether-ai/formats/src/lib.rs | 1 + crates/aether-model-fetch/src/transport.rs | 29 +- .../pool/src/providers/chatgpt_web.rs | 9 +- .../pool/src/providers/gemini_cli.rs | 7 +- .../pool/src/providers/kiro.rs | 2 +- .../pool/src/providers/windsurf.rs | 11 +- .../transport/src/agent_identity.rs | 13 +- .../transport/src/antigravity/auth.rs | 4 +- .../transport/src/claude_code/mod.rs | 4 +- .../transport/src/claude_code/profile.rs | 37 +- .../transport/src/claude_code/request.rs | 79 +++ .../transport/src/client_identity.rs | 65 ++ .../transport/src/gemini_cli/mod.rs | 4 + .../transport/src/gemini_cli/profile.rs | 41 ++ .../transport/src/gemini_cli/url.rs | 2 +- .../aether-provider/transport/src/headers.rs | 4 +- crates/aether-provider/transport/src/lib.rs | 1 + .../transport/src/outbound_request_policy.rs | 17 + .../aether-provider/transport/src/windsurf.rs | 2 +- crates/aether-provider/transport/src/xai.rs | 41 +- docs/operations/codex-cli-alignment.md | 1 + docs/operations/provider-client-profiles.md | 102 +++ docs/operations/xai-provider.md | 3 + 35 files changed, 1006 insertions(+), 773 deletions(-) delete mode 100644 apps/aether-gateway/src/xai_profile.rs create mode 100644 crates/aether-ai/formats/src/client_profile.rs create mode 100644 crates/aether-provider/transport/src/client_identity.rs create mode 100644 crates/aether-provider/transport/src/gemini_cli/profile.rs create mode 100644 docs/operations/provider-client-profiles.md diff --git a/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs b/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs index 2085cb7f6..73cfbda8c 100644 --- a/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs +++ b/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs @@ -7,7 +7,7 @@ use crate::ai_serving::transport::{ build_gemini_cli_v1internal_request, build_standard_provider_request_headers, GatewayProviderTransportSnapshot, GeminiCliRequestAuth, GeminiCliRequestAuthSupport, GeminiCliRequestEnvelopeSupport, StandardProviderRequestHeaders, - StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT, + StandardProviderRequestHeadersInput, }; use crate::AppState; @@ -64,8 +64,10 @@ pub(crate) async fn build_gemini_cli_v1internal_provider_request( ) .ok_or(GeminiCliV1InternalRequestError::UpstreamUrlUnavailable)?; - let extra_headers = - BTreeMap::from([("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string())]); + let extra_headers = BTreeMap::from([( + "user-agent".to_string(), + aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + )]); let headers = build_standard_provider_request_headers(StandardProviderRequestHeadersInput { transport: &payload.transport, provider_api_format: input.provider_api_format, diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs index 10002cfc0..f27c599f2 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs @@ -21,8 +21,7 @@ use crate::ai_serving::transport::{ build_same_format_provider_headers, resolve_local_gemini_cli_request_auth, GeminiCliRequestAuth, GeminiCliRequestAuthSupport, GeminiCliRequestEnvelopeSupport, GrokHeaderInput, SameFormatProviderCompatibilityEdit, - SameFormatProviderCompatibilityEditAction, SameFormatProviderHeadersInput, - GEMINI_CLI_USER_AGENT, GROK_CHAT_PATH, + SameFormatProviderCompatibilityEditAction, SameFormatProviderHeadersInput, GROK_CHAT_PATH, }; use crate::ai_serving::{ CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, CODEX_RESPONSES_LITE_HEADER, @@ -533,7 +532,10 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( .map(build_antigravity_static_identity_headers) .unwrap_or_default(); if prepared.behavior.is_gemini_cli { - extra_headers.insert("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string()); + extra_headers.insert( + "user-agent".to_string(), + aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + ); } let Some(mut provider_request_headers) = (if is_grok { build_grok_browser_headers(GrokHeaderInput { diff --git a/apps/aether-gateway/src/cli_client_profile.rs b/apps/aether-gateway/src/cli_client_profile.rs index 60c2f9872..7207885f6 100644 --- a/apps/aether-gateway/src/cli_client_profile.rs +++ b/apps/aether-gateway/src/cli_client_profile.rs @@ -1,4 +1,4 @@ -//! CLI 客户端画像(Codex / Claude Code)的运行时发布与官方版本刷新。 +//! CLI 客户端画像的统一发布、官方版本刷新与每节点缓存同步。 //! //! 每个客户端由一份 [`CliClientProfileSpec`] 描述:官方 npm 发布源、平台包校验规则、 //! 运行时缓存键、环境变量开关与画像发布函数。刷新逻辑本身与客户端无关。 @@ -23,16 +23,20 @@ use crate::AppState; const PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); const PROFILE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +const PROFILE_SYNC_INTERVAL: Duration = Duration::from_secs(60); const RELEASE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const RELEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const MAX_RELEASE_BYTES: usize = 256 * 1024; /// 单个 CLI 客户端的发布源、校验规则、缓存与运行时画像发布方式。 +#[derive(Clone, Copy)] pub(crate) struct CliClientProfileSpec { /// 日志中的客户端标识。 client: &'static str, /// 官方 npm stable 标签的发布元数据地址。 release_endpoint: &'static str, + stable_channel: Option<&'static str>, + refresh_interval: Duration, package_name: &'static str, /// 同一发布必须同时携带的平台二进制包。 platform_targets: &'static [&'static str], @@ -67,6 +71,8 @@ fn publish_claude_code_version(version: &str) -> Result<(), &'static str> { pub(crate) static CODEX_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec { client: "codex", + stable_channel: None, + refresh_interval: PROFILE_REFRESH_INTERVAL, release_endpoint: "https://registry.npmjs.org/@openai%2Fcodex/latest", package_name: "@openai/codex", platform_targets: &[ @@ -91,6 +97,8 @@ pub(crate) static CODEX_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec /// 指纹仍由 transport crate 中带版本号的身份模板统一维护。 pub(crate) static CLAUDE_CODE_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec { client: "claude_code", + stable_channel: None, + refresh_interval: PROFILE_REFRESH_INTERVAL, release_endpoint: "https://registry.npmjs.org/@anthropic-ai%2Fclaude-code/latest", package_name: "@anthropic-ai/claude-code", platform_targets: &[ @@ -112,11 +120,72 @@ pub(crate) static CLAUDE_CODE_CLI_PROFILE: CliClientProfileSpec = CliClientProfi publish_version: publish_claude_code_version, }; +fn publish_xai_version(version: &str) -> Result<(), &'static str> { + aether_provider_transport::xai::set_xai_client_version(version).map(|_| ()) +} +fn publish_gemini_version(version: &str) -> Result<(), &'static str> { + aether_provider_transport::gemini_cli::set_gemini_cli_client_version(version).map(|_| ()) +} + +pub(crate) static XAI_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec { + client: "xai", + release_endpoint: "https://registry.npmjs.org/@xai-official%2Fgrok/latest", + stable_channel: Some("https://x.ai/cli/stable"), + refresh_interval: Duration::from_secs(3 * 60 * 60), + package_name: "@xai-official/grok", + platform_targets: &[ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", + ], + platform_dependency: claude_code_platform_dependency, + cache_key: "aether:xai:client-profile:v1", + refresh_env: "AETHER_XAI_CLIENT_PROFILE_REFRESH", + fixed_version_env: "AETHER_XAI_CLIENT_VERSION", + task_key: crate::task_runtime::TASK_KEY_XAI_CLIENT_PROFILE, + active_version: aether_provider_transport::xai::xai_client_version, + publish_version: publish_xai_version, +}; +pub(crate) static GEMINI_CLI_PROFILE: CliClientProfileSpec = CliClientProfileSpec { + client: "gemini_cli", + release_endpoint: "https://registry.npmjs.org/@google%2Fgemini-cli/latest", + stable_channel: None, + refresh_interval: PROFILE_REFRESH_INTERVAL, + package_name: "@google/gemini-cli", + // Official JS bundle has no same-version platform packages. + platform_targets: &[], + platform_dependency: claude_code_platform_dependency, + cache_key: "aether:gemini_cli:client-profile:v1", + refresh_env: "AETHER_GEMINI_CLI_CLIENT_PROFILE_REFRESH", + fixed_version_env: "AETHER_GEMINI_CLI_CLIENT_VERSION", + task_key: crate::task_runtime::TASK_KEY_GEMINI_CLI_CLIENT_PROFILE, + active_version: aether_provider_transport::gemini_cli::gemini_cli_client_version, + publish_version: publish_gemini_version, +}; +pub(crate) static CLI_PROFILES: &[&CliClientProfileSpec] = &[ + &CODEX_CLI_PROFILE, + &CLAUDE_CODE_CLI_PROFILE, + &XAI_CLI_PROFILE, + &GEMINI_CLI_PROFILE, +]; +impl CliClientProfileSpec { + pub(crate) fn task_key(&self) -> &'static str { + self.task_key + } + pub(crate) fn client_name(&self) -> &'static str { + self.client + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct NpmRelease { name: String, version: String, + #[serde(default)] optional_dependencies: BTreeMap, } @@ -140,6 +209,8 @@ enum ProfileRefreshError { Rollback, #[error("CLI profile cache operation failed: {0}")] Cache(String), + #[error("stable channel failed ({stable}); npm fallback failed ({npm})")] + AllSourcesFailed { stable: String, npm: String }, } fn version_sequence(version: &str) -> Result { @@ -226,12 +297,9 @@ fn build_release_client() -> Result { .map_err(ProfileRefreshError::Client) } -async fn fetch_latest_cli_version( - spec: &CliClientProfileSpec, - client: &Client, -) -> Result { +async fn fetch_bounded(client: &Client, url: &str) -> Result, ProfileRefreshError> { let response = client - .get(spec.release_endpoint) + .get(url) .send() .await .map_err(ProfileRefreshError::Client)?; @@ -254,7 +322,56 @@ async fn fetch_latest_cli_version( } bytes.extend_from_slice(&chunk); } - parse_cli_release(spec, &bytes) + Ok(bytes) +} + +fn parse_stable_channel(bytes: &[u8]) -> Result { + if bytes.len() > MAX_RELEASE_BYTES { + return Err(ProfileRefreshError::ResponseTooLarge); + } + let version = std::str::from_utf8(bytes) + .map_err(|_| ProfileRefreshError::InvalidMetadata)? + .trim(); + version_sequence(version)?; + Ok(version.to_owned()) +} + +async fn fetch_latest_with_fallback( + stable: S, + npm: N, +) -> Result +where + S: FnOnce() -> SF, + SF: Future>, + N: FnOnce() -> NF, + NF: Future>, +{ + match stable().await { + Ok(version) => Ok(version), + Err(stable) => npm() + .await + .map_err(|npm| ProfileRefreshError::AllSourcesFailed { + stable: stable.to_string(), + npm: npm.to_string(), + }), + } +} + +async fn fetch_latest_cli_version( + spec: &CliClientProfileSpec, + client: &Client, +) -> Result { + let npm = + || async { parse_cli_release(spec, &fetch_bounded(client, spec.release_endpoint).await?) }; + if let Some(url) = spec.stable_channel { + fetch_latest_with_fallback( + || async { parse_stable_channel(&fetch_bounded(client, url).await?) }, + npm, + ) + .await + } else { + npm().await + } } fn publish(spec: &CliClientProfileSpec, version: &str) -> Result<(), ProfileRefreshError> { @@ -293,7 +410,7 @@ fn cached_version_to_restore( ) -> Result, ProfileRefreshError> { let cached_sequence = version_sequence(&cached.version)?; let active_sequence = version_sequence(active_version)?; - Ok((cached_sequence >= active_sequence).then(|| cached.version.clone())) + Ok((cached_sequence > active_sequence).then(|| cached.version.clone())) } async fn refresh_once_with_fetch( @@ -326,6 +443,8 @@ where } let version = fetch_latest().await?; + // Another publisher may have advanced shared state while the fetch awaited. + let _ = restore_cached_profile(spec, runtime).await; let current = (spec.active_version)(); if version_sequence(&version)? < version_sequence(¤t)? { return Err(ProfileRefreshError::Rollback); @@ -342,7 +461,7 @@ where .kv_set(spec.cache_key, serialized, Some(PROFILE_CACHE_TTL)) .await { - // 本地画像已经完成原子替换;缓存写失败只影响下次进程启动的恢复。 + // 本地画像已经完成原子替换;缓存写失败会延迟其他节点同步及下次启动的恢复。 warn!( event_name = "cli_client_profile_cache_write_failed", client = spec.client, @@ -358,17 +477,49 @@ async fn refresh_once( runtime: &RuntimeState, ) -> Result { let fixed_version = fixed_version_override(spec); - refresh_once_with_fetch( - spec, - runtime, - fixed_version.as_deref(), - refresh_enabled(spec), - || async { + if fixed_version.is_some() || !refresh_enabled(spec) { + return refresh_once_with_fetch(spec, runtime, fixed_version.as_deref(), false, || async { + Err(ProfileRefreshError::InvalidMetadata) + }) + .await; + } + let _ = restore_cached_profile(spec, runtime).await; + let lock_key = format!("aether:client-profile:refresh:{}", spec.client); + let owner = uuid::Uuid::now_v7().to_string(); + let Some(lease) = runtime + .lock_try_acquire(&lock_key, &owner, Duration::from_secs(120)) + .await + .map_err(|e| ProfileRefreshError::Cache(e.to_string()))? + else { + return Ok((spec.active_version)()); + }; + let result = async { + // Concurrent startups must not all query the release source. A fresh, + // verified cache is sufficient; per-node sync never accesses the network. + if let Ok(Some(raw)) = runtime.kv_get(spec.cache_key).await { + if let Ok(cached) = serde_json::from_str::(&raw) { + let now = chrono::Utc::now().timestamp().max(0) as u64; + if now >= cached.verified_at_unix_secs + && now - cached.verified_at_unix_secs < spec.refresh_interval.as_secs() + && version_sequence(&cached.version).is_ok_and(|cached_seq| { + version_sequence(&(spec.active_version)()) + .is_ok_and(|active_seq| cached_seq >= active_seq) + }) + { + restore_cached_profile(spec, runtime).await?; + return Ok((spec.active_version)()); + } + } + } + refresh_once_with_fetch(spec, runtime, None, true, || async { let client = build_release_client()?; fetch_latest_cli_version(spec, &client).await - }, - ) - .await + }) + .await + } + .await; + let _ = runtime.lock_release(&lease).await; + result } pub(crate) async fn prewarm( @@ -385,7 +536,7 @@ pub(crate) fn spawn_worker( app: AppState, ) -> tokio::task::JoinHandle<()> { crate::task_runtime::spawn_singleton_worker(app, spec.task_key, move |app| async move { - let mut interval = tokio::time::interval(PROFILE_REFRESH_INTERVAL); + let mut interval = tokio::time::interval(spec.refresh_interval); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // 启动阶段由 prewarm 完成一次检查;后台任务只负责后续每日刷新,避免重复建连。 interval.tick().await; @@ -409,6 +560,49 @@ pub(crate) fn spawn_worker( }) } +/// One task per process (including frontdoor-only nodes), independent of the +/// cluster singleton release checkers. Dropping the guard aborts the task. +pub struct ClientProfileSyncGuard(tokio::task::JoinHandle<()>); +impl Drop for ClientProfileSyncGuard { + fn drop(&mut self) { + self.0.abort(); + } +} + +async fn sync_cached_profile( + spec: &CliClientProfileSpec, + runtime: &RuntimeState, + fixed: Option<&str>, +) -> Result<(), ProfileRefreshError> { + if let Some(version) = fixed { + publish(spec, version) + } else { + restore_cached_profile(spec, runtime).await + } +} + +pub(crate) fn spawn_cache_sync(app: AppState) -> ClientProfileSyncGuard { + ClientProfileSyncGuard(aether_runtime::task::spawn_named( + "client-profile-cache-sync", + async move { + let mut interval = tokio::time::interval(PROFILE_SYNC_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + for spec in CLI_PROFILES { + let fixed = fixed_version_override(spec); + if let Err(error) = + sync_cached_profile(spec, app.runtime_state(), fixed.as_deref()).await + { + warn!(event_name = "cli_client_profile_cache_sync_failed", client = spec.client, error = %error, + "keeping the previous local client profile"); + } + } + } + }, + )) +} + #[cfg(test)] mod tests { use std::sync::{ @@ -448,6 +642,8 @@ mod tests { static TEST_PROFILE: CliClientProfileSpec = CliClientProfileSpec { client: "test", + stable_channel: None, + refresh_interval: super::PROFILE_REFRESH_INTERVAL, release_endpoint: "https://registry.invalid/test/latest", package_name: "@test/cli", platform_targets: &["linux-x64"], @@ -466,6 +662,252 @@ mod tests { guard } + #[test] + fn all_release_adapters_keep_distinct_cache_keys_and_registered_tasks() { + let mut keys = std::collections::HashSet::new(); + let mut tasks = std::collections::HashSet::new(); + for spec in super::CLI_PROFILES { + assert!(keys.insert(spec.cache_key)); + assert!(tasks.insert(spec.task_key())); + assert!(spec + .release_endpoint + .starts_with("https://registry.npmjs.org/")); + } + assert_eq!(keys.len(), 4); + assert_eq!( + super::XAI_CLI_PROFILE.refresh_interval, + Duration::from_secs(3 * 60 * 60) + ); + } + + #[test] + fn gemini_bundle_accepts_only_the_official_stable_package() { + let mut body = serde_json::json!({"name":"@google/gemini-cli", "version":"0.62.0"}); + assert_eq!( + parse_cli_release( + &super::GEMINI_CLI_PROFILE, + &serde_json::to_vec(&body).unwrap() + ) + .unwrap(), + "0.62.0" + ); + body["name"] = serde_json::json!("gemini-cli"); + assert!(parse_cli_release( + &super::GEMINI_CLI_PROFILE, + &serde_json::to_vec(&body).unwrap() + ) + .is_err()); + body["name"] = serde_json::json!("@google/gemini-cli"); + body["version"] = serde_json::json!("0.63.0-preview.1"); + assert!(parse_cli_release( + &super::GEMINI_CLI_PROFILE, + &serde_json::to_vec(&body).unwrap() + ) + .is_err()); + } + + #[test] + fn grok_preserves_stable_channel_and_all_platform_release_checks() { + assert_eq!(super::parse_stable_channel(b"1.0.46\n").unwrap(), "1.0.46"); + for bytes in [ + b"1.0.46".as_slice(), + b"1.0.47-alpha.1", + b"", + b"1.0.46+build", + ] { + assert!(super::parse_stable_channel(bytes).is_err()); + } + let spec = &super::XAI_CLI_PROFILE; + let dependencies = spec + .platform_targets + .iter() + .map(|target| { + ( + format!("@xai-official/grok-{target}"), + serde_json::json!("1.0.46"), + ) + }) + .collect::>(); + let mut body = serde_json::json!({"name":"@xai-official/grok", "version":"1.0.46", "optionalDependencies":dependencies}); + assert_eq!( + parse_cli_release(spec, &serde_json::to_vec(&body).unwrap()).unwrap(), + "1.0.46" + ); + body["optionalDependencies"]["@xai-official/grok-linux-x64"] = serde_json::json!("1.0.45"); + assert!(parse_cli_release(spec, &serde_json::to_vec(&body).unwrap()).is_err()); + } + + #[tokio::test] + async fn grok_uses_npm_only_after_stable_fails() { + let called = AtomicBool::new(false); + assert_eq!( + super::fetch_latest_with_fallback( + || async { Ok("1.0.46".into()) }, + || async { + called.store(true, Ordering::SeqCst); + Ok("1.0.47".into()) + } + ) + .await + .unwrap(), + "1.0.46" + ); + assert!(!called.load(Ordering::SeqCst)); + assert_eq!( + super::fetch_latest_with_fallback( + || async { Err(ProfileRefreshError::HttpStatus(503)) }, + || async { Ok("1.0.47".into()) } + ) + .await + .unwrap(), + "1.0.47" + ); + assert!(matches!( + super::fetch_latest_with_fallback( + || async { Err(ProfileRefreshError::HttpStatus(503)) }, + || async { Err(ProfileRefreshError::HttpStatus(502)) } + ) + .await, + Err(ProfileRefreshError::AllSourcesFailed { .. }) + )); + } + + static REPLICA_VERSION: Mutex = Mutex::new(String::new()); + fn replica_version() -> String { + REPLICA_VERSION.lock().unwrap().clone() + } + fn publish_replica(version: &str) -> Result<(), &'static str> { + *REPLICA_VERSION.lock().unwrap() = version.into(); + Ok(()) + } + + #[tokio::test] + async fn a_non_owner_replica_syncs_without_fetching_and_fixed_override_wins() { + let _guard = test_profile_guard().await; + publish_replica(TEST_BUILTIN_VERSION).unwrap(); + let replica = CliClientProfileSpec { + active_version: replica_version, + publish_version: publish_replica, + ..TEST_PROFILE + }; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, true, || async { + Ok("1.2.0".into()) + }) + .await + .unwrap(); + assert_eq!(replica_version(), TEST_BUILTIN_VERSION); + super::sync_cached_profile(&replica, &runtime, None) + .await + .unwrap(); + assert_eq!(replica_version(), "1.2.0"); + super::sync_cached_profile(&replica, &runtime, Some("1.0.0")) + .await + .unwrap(); + assert_eq!(replica_version(), "1.0.0"); + assert!(runtime + .kv_get(TEST_PROFILE.cache_key) + .await + .unwrap() + .unwrap() + .contains("1.2.0")); + } + + #[tokio::test] + async fn newer_shared_version_arriving_during_fetch_rejects_stale_publish() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + let result = refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, true, || async { + runtime + .kv_set( + TEST_PROFILE.cache_key, + serde_json::to_string(&CachedProfile { + version: "1.6.0".into(), + verified_at_unix_secs: 1, + }) + .unwrap(), + None, + ) + .await + .unwrap(); + Ok("1.5.0".into()) + }) + .await; + assert!(matches!(result, Err(ProfileRefreshError::Rollback))); + assert_eq!(test_active_version(), "1.6.0"); + assert!(runtime + .kv_get(TEST_PROFILE.cache_key) + .await + .unwrap() + .unwrap() + .contains("1.6.0")); + } + + #[tokio::test] + async fn corrupt_cache_does_not_block_a_verified_refresh_or_erase_local_state() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + runtime + .kv_set(TEST_PROFILE.cache_key, "invalid-json", None) + .await + .unwrap(); + assert!(super::sync_cached_profile(&TEST_PROFILE, &runtime, None) + .await + .is_err()); + assert_eq!(test_active_version(), TEST_BUILTIN_VERSION); + assert_eq!( + refresh_once_with_fetch(&TEST_PROFILE, &runtime, None, true, || async { + Ok("1.2.0".into()) + }) + .await + .unwrap(), + "1.2.0" + ); + } + + #[tokio::test] + async fn a_fresh_verified_cache_avoids_a_startup_network_check() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + runtime + .kv_set( + TEST_PROFILE.cache_key, + serde_json::to_string(&CachedProfile { + version: "1.2.0".into(), + verified_at_unix_secs: chrono::Utc::now().timestamp().max(0) as u64, + }) + .unwrap(), + None, + ) + .await + .unwrap(); + // TEST_PROFILE's URL cannot return metadata; success demonstrates no HTTP fetch. + assert_eq!( + super::refresh_once(&TEST_PROFILE, &runtime).await.unwrap(), + "1.2.0" + ); + } + + #[tokio::test] + async fn another_startup_holding_the_release_lock_skips_the_network() { + let _guard = test_profile_guard().await; + let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); + let lease = runtime + .lock_try_acquire( + "aether:client-profile:refresh:test", + "other-node", + Duration::from_secs(120), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + super::refresh_once(&TEST_PROFILE, &runtime).await.unwrap(), + TEST_BUILTIN_VERSION + ); + runtime.lock_release(&lease).await.unwrap(); + } + #[test] fn accepts_only_one_verified_codex_release_for_all_targets() { let body = serde_json::json!({ diff --git a/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs b/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs index a6f5e877c..bf3962cce 100644 --- a/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs +++ b/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs @@ -43,12 +43,11 @@ use crate::AppState; const CHATGPT_WEB_INTERNAL_HEADER: &str = "x-aether-chatgpt-web-image"; const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com"; -const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"; -const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"; -const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942"; -const CHATGPT_WEB_SEC_CH_UA: &str = - r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#; -const CHATGPT_WEB_BROWSER_PROFILE: &str = "chrome143"; +use aether_provider_transport::client_identity::CHATGPT_WEB_BROWSER_PROFILE; +use aether_provider_transport::client_identity::{ + CHATGPT_WEB_BUILD_NUMBER, CHATGPT_WEB_CLIENT_VERSION, CHATGPT_WEB_SEC_CH_UA, + CHATGPT_WEB_USER_AGENT, +}; const CHATGPT_WEB_QUOTA_REFRESH_TIMEOUT_MS: u64 = 30_000; const CHATGPT_WEB_QUOTA_REFRESH_PROXY_TIMEOUT_MS: u64 = 60_000; const RUNTIME_METADATA_CAS_MAX_ATTEMPTS: usize = 16; diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs index 0752cb059..7259bf36f 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs @@ -3462,7 +3462,7 @@ async fn provider_query_execute_standard_test_candidate( { request_headers .entry("user-agent".to_string()) - .or_insert_with(|| crate::provider_transport::GEMINI_CLI_USER_AGENT.to_string()); + .or_insert_with(aether_provider_transport::gemini_cli::gemini_cli_client_user_agent); } let protected_headers = if uses_vertex_query_auth { vec!["content-type"] diff --git a/apps/aether-gateway/src/lib.rs b/apps/aether-gateway/src/lib.rs index d869488b4..5003c5ed9 100644 --- a/apps/aether-gateway/src/lib.rs +++ b/apps/aether-gateway/src/lib.rs @@ -92,7 +92,7 @@ mod upstream_admission; mod usage; mod video_tasks; mod wallet_runtime; -mod xai_profile; +pub use cli_client_profile::ClientProfileSyncGuard; pub use self::ai_serving::api::{codex_client_originator, codex_client_user_agent}; pub(crate) use self::ai_serving::api::{ diff --git a/apps/aether-gateway/src/main.rs b/apps/aether-gateway/src/main.rs index 21c809477..f44482b69 100644 --- a/apps/aether-gateway/src/main.rs +++ b/apps/aether-gateway/src/main.rs @@ -2513,53 +2513,16 @@ async fn run() -> Result<(), Box> { ); } } - // 两个 CLI 画像的发布检查互不依赖,并发执行以免叠加启动阶段的网络超时。 - let (codex_profile, claude_code_profile) = tokio::join!( - state.prewarm_codex_client_profile(), - state.prewarm_claude_code_client_profile(), - ); - match codex_profile { - Ok(version) => { - info!( - codex_client_version = %version, - "prewarmed Codex client profile" - ); - } - Err(err) => { - warn!( - error = %err, - "failed to refresh Codex client profile; built-in or cached profile remains active" - ); - } - } - match claude_code_profile { - Ok(version) => { - info!( - claude_code_client_version = %version, - "prewarmed Claude Code client profile" - ); - } - Err(err) => { - warn!( - error = %err, - "failed to refresh Claude Code client profile; built-in or cached profile remains active" - ); - } - } - match state.prewarm_xai_client_profile().await { - Ok(version) => { - info!( - xai_client_version = %version, - "prewarmed Grok CLI client profile" - ); - } - Err(err) => { - warn!( - error = %err, - "failed to refresh Grok CLI client profile; built-in or cached profile remains active" - ); + for (client, result) in state.prewarm_client_profiles().await { + match result { + Ok(version) => info!(client, version = %version, "prewarmed client profile"), + Err(error) => warn!(client, error = %error, + "client profile refresh failed; built-in or cached profile remains active"), } } + // All roles synchronize local snapshots, not just the singleton owner. + // Keep the guard alive until main exits so shutdown cancels the task. + let _client_profile_cache_sync = state.spawn_client_profile_cache_sync(); match prewarm_direct_h2c_sender_cache_from_env_for_startup().await { Ok(Some(report)) => { if report.failed_targets > 0 { diff --git a/apps/aether-gateway/src/state/core.rs b/apps/aether-gateway/src/state/core.rs index 8856d39fb..f5eb65e06 100644 --- a/apps/aether-gateway/src/state/core.rs +++ b/apps/aether-gateway/src/state/core.rs @@ -55,7 +55,8 @@ use super::super::{control::GatewayControlDecision, error::GatewayError}; use super::super::{provider_transport, usage}; use crate::cli_client_profile::{ - spawn_worker as spawn_cli_client_profile_worker, CLAUDE_CODE_CLI_PROFILE, CODEX_CLI_PROFILE, + spawn_worker as spawn_cli_client_profile_worker, CLAUDE_CODE_CLI_PROFILE, CLI_PROFILES, + CODEX_CLI_PROFILE, XAI_CLI_PROFILE, }; use crate::maintenance::spawn_account_self_check_worker; use crate::maintenance::spawn_audit_cleanup_worker; @@ -78,7 +79,6 @@ use crate::maintenance::spawn_stats_hourly_aggregation_worker; use crate::maintenance::spawn_usage_cleanup_worker; use crate::maintenance::spawn_usage_counter_flush_worker; use crate::maintenance::spawn_wallet_daily_usage_aggregation_worker; -use crate::xai_profile::spawn_worker as spawn_xai_client_profile_worker; const SYSTEM_CONFIG_CACHE_TTL: Duration = Duration::from_secs(30); // Requests may use a stale value after the fresh window until the entry reaches @@ -162,7 +162,21 @@ impl AppState { } pub async fn prewarm_xai_client_profile(&self) -> Result { - crate::xai_profile::prewarm(self.runtime_state()).await + crate::cli_client_profile::prewarm(&XAI_CLI_PROFILE, self.runtime_state()).await + } + + pub async fn prewarm_client_profiles(&self) -> Vec<(&'static str, Result)> { + futures_util::future::join_all(CLI_PROFILES.iter().map(|spec| async move { + ( + spec.client_name(), + crate::cli_client_profile::prewarm(spec, self.runtime_state()).await, + ) + })) + .await + } + + pub fn spawn_client_profile_cache_sync(&self) -> crate::ClientProfileSyncGuard { + crate::cli_client_profile::spawn_cache_sync(self.clone()) } pub async fn prewarm_chat_pii_redaction_runtime_config(&self) -> Result { @@ -2363,24 +2377,15 @@ impl AppState { crate::task_runtime::TASK_KEY_MODEL_FETCH_WORKER, spawn_model_fetch_worker(background_state.clone()), ); - supervise_worker( - crate::task_runtime::TASK_KEY_CODEX_CLIENT_PROFILE, - Some(spawn_cli_client_profile_worker( - &CODEX_CLI_PROFILE, - background_state.clone(), - )), - ); - supervise_worker( - crate::task_runtime::TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE, - Some(spawn_cli_client_profile_worker( - &CLAUDE_CODE_CLI_PROFILE, - background_state.clone(), - )), - ); - supervise_worker( - crate::task_runtime::TASK_KEY_XAI_CLIENT_PROFILE, - Some(spawn_xai_client_profile_worker(background_state.clone())), - ); + for spec in CLI_PROFILES { + supervise_worker( + spec.task_key(), + Some(spawn_cli_client_profile_worker( + spec, + background_state.clone(), + )), + ); + } supervise_worker( crate::task_runtime::TASK_KEY_VIDEO_TASK_POLLER, spawn_video_task_poller(background_state.clone()), diff --git a/apps/aether-gateway/src/task_runtime/mod.rs b/apps/aether-gateway/src/task_runtime/mod.rs index 2a1c66190..288fe0058 100644 --- a/apps/aether-gateway/src/task_runtime/mod.rs +++ b/apps/aether-gateway/src/task_runtime/mod.rs @@ -28,6 +28,7 @@ pub(crate) const TASK_KEY_CODEX_CLIENT_PROFILE: &str = "maintenance.codex.client pub(crate) const TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE: &str = "maintenance.claude_code.client.profile"; pub(crate) const TASK_KEY_XAI_CLIENT_PROFILE: &str = "maintenance.xai.client.profile"; +pub(crate) const TASK_KEY_GEMINI_CLI_CLIENT_PROFILE: &str = "maintenance.gemini_cli.client.profile"; pub(crate) const TASK_KEY_PROVIDER_QUOTA_RESET: &str = "provider.quota.reset.worker"; pub(crate) const TASK_KEY_ACCOUNT_SELF_CHECK: &str = "account.self_check.worker"; pub(crate) const TASK_KEY_POOL_SCORE_REBUILD: &str = "pool.score.rebuild.worker"; @@ -222,6 +223,14 @@ const TASK_DEFINITIONS: &[TaskDefinition] = &[ true, RETRY_ONCE, ), + TaskDefinition::new( + TASK_KEY_GEMINI_CLI_CLIENT_PROFILE, + TaskKind::Scheduled, + "daily", + true, + true, + RETRY_ONCE, + ), TaskDefinition::new( TASK_KEY_XAI_CLIENT_PROFILE, TaskKind::Scheduled, @@ -978,6 +987,7 @@ mod client_profile_task_tests { (TASK_KEY_CODEX_CLIENT_PROFILE, "daily"), (TASK_KEY_CLAUDE_CODE_CLIENT_PROFILE, "daily"), (TASK_KEY_XAI_CLIENT_PROFILE, "interval"), + (TASK_KEY_GEMINI_CLI_CLIENT_PROFILE, "daily"), ] { let definitions: Vec<_> = task_definitions() .iter() diff --git a/apps/aether-gateway/src/xai_profile.rs b/apps/aether-gateway/src/xai_profile.rs deleted file mode 100644 index dad4e9be7..000000000 --- a/apps/aether-gateway/src/xai_profile.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! Grok CLI 客户端版本的运行时发布与官方版本刷新。 -//! -//! cli-chat-proxy.grok.com 会对低于最低版本的 `x-grok-client-version` 直接返回 426, -//! 因此网关定期读取官方发布渠道并原子替换传输层使用的版本号。 - -use std::collections::BTreeMap; -use std::future::Future; -use std::time::Duration; - -use aether_runtime_state::RuntimeState; -use futures_util::StreamExt as _; -use reqwest::{redirect::Policy, Client}; -use semver::Version; -use serde::{Deserialize, Serialize}; -use tracing::{info, warn}; - -use crate::provider_transport::{set_xai_client_version, xai_client_version}; -use crate::AppState; - -/// 官方安装脚本读取的 stable 渠道,响应体是纯文本版本号。 -const CLI_STABLE_CHANNEL_ENDPOINT: &str = "https://x.ai/cli/stable"; -/// stable 渠道不可达时(部分部署地区无法直连 x.ai)退回 npm 发布元数据。 -const CLI_NPM_RELEASE_ENDPOINT: &str = "https://registry.npmjs.org/@xai-official%2Fgrok/latest"; -const CLI_NPM_PACKAGE: &str = "@xai-official/grok"; -const PROFILE_CACHE_KEY: &str = "aether:xai:client-profile:v1"; -const PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); -/// xAI 会在发布后很快抬高最低版本,刷新间隔比 Codex 更短。 -const PROFILE_REFRESH_INTERVAL: Duration = Duration::from_secs(3 * 60 * 60); -const RELEASE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const RELEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -const MAX_RELEASE_BYTES: usize = 256 * 1024; -const CLI_TARGETS: [&str; 6] = [ - "darwin-arm64", - "darwin-x64", - "linux-arm64", - "linux-x64", - "win32-arm64", - "win32-x64", -]; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct NpmRelease { - name: String, - version: String, - optional_dependencies: BTreeMap, -} - -#[derive(Debug, Deserialize, Serialize)] -struct CachedProfile { - version: String, - verified_at_unix_secs: u64, -} - -#[derive(Debug, thiserror::Error)] -enum ProfileRefreshError { - #[error("Grok CLI release client initialization failed: {0}")] - Client(#[from] reqwest::Error), - #[error("Grok CLI release request returned HTTP {0}")] - HttpStatus(u16), - #[error("Grok CLI release response exceeded {MAX_RELEASE_BYTES} bytes")] - ResponseTooLarge, - #[error("Grok CLI release metadata is invalid")] - InvalidMetadata, - #[error("Grok CLI release version is older than the active profile")] - Rollback, - #[error("Grok CLI profile cache operation failed: {0}")] - Cache(String), - #[error("Grok CLI stable channel failed ({stable}); npm fallback failed ({npm})")] - AllSourcesFailed { stable: String, npm: String }, -} - -fn version_sequence(version: &str) -> Result { - let parsed = Version::parse(version).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - if !parsed.pre.is_empty() - || !parsed.build.is_empty() - || parsed.major > 999 - || parsed.minor > 999 - || parsed.patch > 999 - { - return Err(ProfileRefreshError::InvalidMetadata); - } - Ok(1 + parsed.major * 1_000_000 + parsed.minor * 1_000 + parsed.patch) -} - -/// stable 渠道只返回一行版本号;任何多余内容都视为异常响应(例如被劫持的 HTML 页面)。 -fn parse_stable_channel(bytes: &[u8]) -> Result { - if bytes.len() > MAX_RELEASE_BYTES { - return Err(ProfileRefreshError::ResponseTooLarge); - } - let text = std::str::from_utf8(bytes).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - let version = text.trim(); - version_sequence(version)?; - Ok(version.to_owned()) -} - -/// 校验 npm latest 标签及六个平台二进制包来自同一版本发布。 -fn parse_npm_release(bytes: &[u8]) -> Result { - if bytes.len() > MAX_RELEASE_BYTES { - return Err(ProfileRefreshError::ResponseTooLarge); - } - let release = serde_json::from_slice::(bytes) - .map_err(|_| ProfileRefreshError::InvalidMetadata)?; - version_sequence(&release.version)?; - if release.name != CLI_NPM_PACKAGE - || CLI_TARGETS.iter().any(|target| { - release - .optional_dependencies - .get(&format!("{CLI_NPM_PACKAGE}-{target}")) - != Some(&release.version) - }) - { - return Err(ProfileRefreshError::InvalidMetadata); - } - Ok(release.version) -} - -fn refresh_enabled_from(value: Option<&str>) -> bool { - !value.is_some_and(|value| { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "off" - ) - }) -} - -fn refresh_enabled() -> bool { - refresh_enabled_from( - std::env::var("AETHER_XAI_CLIENT_PROFILE_REFRESH") - .ok() - .as_deref(), - ) -} - -fn fixed_version_from(value: Option<&str>) -> Option { - let value = value?.trim(); - if value.is_empty() || version_sequence(value).is_err() { - None - } else { - Some(value.to_owned()) - } -} - -fn fixed_version_override() -> Option { - let value = std::env::var("AETHER_XAI_CLIENT_VERSION").ok()?; - let version = fixed_version_from(Some(&value)); - if version.is_none() { - warn!( - event_name = "xai_client_profile_fixed_version_invalid", - "AETHER_XAI_CLIENT_VERSION is invalid; using cached or built-in profile" - ); - } - version -} - -fn build_release_client() -> Result { - Client::builder() - .https_only(true) - .no_proxy() - .redirect(Policy::none()) - .connect_timeout(RELEASE_CONNECT_TIMEOUT) - .timeout(RELEASE_REQUEST_TIMEOUT) - .build() - .map_err(ProfileRefreshError::Client) -} - -async fn fetch_bounded(client: &Client, url: &str) -> Result, ProfileRefreshError> { - let response = client - .get(url) - .send() - .await - .map_err(ProfileRefreshError::Client)?; - if !response.status().is_success() { - return Err(ProfileRefreshError::HttpStatus(response.status().as_u16())); - } - if response - .content_length() - .is_some_and(|length| length > MAX_RELEASE_BYTES as u64) - { - return Err(ProfileRefreshError::ResponseTooLarge); - } - - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(ProfileRefreshError::Client)?; - if bytes.len().saturating_add(chunk.len()) > MAX_RELEASE_BYTES { - return Err(ProfileRefreshError::ResponseTooLarge); - } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) -} - -async fn fetch_latest_with_fallback( - fetch_stable: S, - fetch_npm: N, -) -> Result -where - S: FnOnce() -> SFut, - SFut: Future>, - N: FnOnce() -> NFut, - NFut: Future>, -{ - let stable_error = match fetch_stable().await { - Ok(version) => return Ok(version), - Err(error) => error, - }; - fetch_npm() - .await - .map_err(|npm_error| ProfileRefreshError::AllSourcesFailed { - stable: stable_error.to_string(), - npm: npm_error.to_string(), - }) -} - -async fn fetch_latest_cli_version(client: &Client) -> Result { - fetch_latest_with_fallback( - || async { - let bytes = fetch_bounded(client, CLI_STABLE_CHANNEL_ENDPOINT).await?; - parse_stable_channel(&bytes) - }, - || async { - let bytes = fetch_bounded(client, CLI_NPM_RELEASE_ENDPOINT).await?; - parse_npm_release(&bytes) - }, - ) - .await -} - -fn publish_version(version: &str) -> Result<(), ProfileRefreshError> { - set_xai_client_version(version) - .map(|_| ()) - .map_err(|_| ProfileRefreshError::InvalidMetadata) -} - -async fn restore_cached_profile(runtime: &RuntimeState) -> Result<(), ProfileRefreshError> { - let Some(raw) = runtime - .kv_get(PROFILE_CACHE_KEY) - .await - .map_err(|err| ProfileRefreshError::Cache(err.to_string()))? - else { - return Ok(()); - }; - let cached = serde_json::from_str::(&raw) - .map_err(|_| ProfileRefreshError::InvalidMetadata)?; - if let Some(version) = cached_version_to_restore(&cached, &xai_client_version())? { - publish_version(&version)?; - info!( - event_name = "xai_client_profile_restored", - version = %version, - verified_at_unix_secs = cached.verified_at_unix_secs, - "restored cached Grok CLI profile" - ); - } - Ok(()) -} - -fn cached_version_to_restore( - cached: &CachedProfile, - active_version: &str, -) -> Result, ProfileRefreshError> { - let cached_sequence = version_sequence(&cached.version)?; - let active_sequence = version_sequence(active_version)?; - Ok((cached_sequence > active_sequence).then(|| cached.version.clone())) -} - -async fn refresh_once_with_fetch( - runtime: &RuntimeState, - fixed_version: Option<&str>, - refresh_is_enabled: bool, - fetch_latest: F, -) -> Result -where - F: FnOnce() -> Fut, - Fut: Future>, -{ - if let Some(version) = fixed_version { - publish_version(version)?; - return Ok(version.to_owned()); - } - - if let Err(error) = restore_cached_profile(runtime).await { - // 缓存损坏或暂时不可用不应阻断官方版本检查;当前进程继续使用旧画像。 - warn!( - event_name = "xai_client_profile_cache_restore_failed", - error = %error, - "could not restore cached Grok CLI profile" - ); - } - if !refresh_is_enabled { - return Ok(xai_client_version()); - } - - let version = fetch_latest().await?; - let current = xai_client_version(); - if version_sequence(&version)? < version_sequence(¤t)? { - return Err(ProfileRefreshError::Rollback); - } - - let cached = CachedProfile { - version: version.clone(), - verified_at_unix_secs: chrono::Utc::now().timestamp().max(0) as u64, - }; - let serialized = - serde_json::to_string(&cached).map_err(|_| ProfileRefreshError::InvalidMetadata)?; - publish_version(&version)?; - if let Err(error) = runtime - .kv_set(PROFILE_CACHE_KEY, serialized, Some(PROFILE_CACHE_TTL)) - .await - { - // 本地版本已经完成原子替换;缓存写失败只影响下次进程启动的恢复。 - warn!( - event_name = "xai_client_profile_cache_write_failed", - error = %error, - "published Grok CLI profile locally but could not persist the cache" - ); - } - Ok(version) -} - -async fn refresh_once(runtime: &RuntimeState) -> Result { - let fixed_version = fixed_version_override(); - refresh_once_with_fetch( - runtime, - fixed_version.as_deref(), - refresh_enabled(), - || async { - let client = build_release_client()?; - fetch_latest_cli_version(&client).await - }, - ) - .await -} - -pub(crate) async fn prewarm(runtime: &RuntimeState) -> Result { - refresh_once(runtime).await.map_err(|err| err.to_string()) -} - -pub(crate) fn spawn_worker(app: AppState) -> tokio::task::JoinHandle<()> { - crate::task_runtime::spawn_singleton_worker( - app, - crate::task_runtime::TASK_KEY_XAI_CLIENT_PROFILE, - |app| async move { - let mut interval = tokio::time::interval(PROFILE_REFRESH_INTERVAL); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - // 启动阶段由 prewarm 完成一次检查;后台任务只负责后续定时刷新,避免重复建连。 - interval.tick().await; - loop { - interval.tick().await; - match refresh_once(app.runtime_state()).await { - Ok(version) => info!( - event_name = "xai_client_profile_refreshed", - version = %version, - "refreshed Grok CLI profile" - ), - Err(error) => warn!( - event_name = "xai_client_profile_refresh_failed", - error = %error, - "keeping the previous Grok CLI profile after refresh failure" - ), - } - } - }, - ) -} - -#[cfg(test)] -mod tests { - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Mutex, OnceLock, - }; - use std::time::Duration; - - use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeState}; - - use super::{ - cached_version_to_restore, fetch_latest_with_fallback, fixed_version_from, - parse_npm_release, parse_stable_channel, refresh_enabled_from, refresh_once_with_fetch, - CachedProfile, ProfileRefreshError, PROFILE_CACHE_KEY, - }; - use crate::provider_transport::{set_xai_client_version, xai_client_version}; - - static PROFILE_TEST_LOCK: OnceLock> = OnceLock::new(); - - struct VersionRestore(String); - - impl Drop for VersionRestore { - fn drop(&mut self) { - let _ = set_xai_client_version(&self.0); - } - } - - fn version_restore_guard() -> (std::sync::MutexGuard<'static, ()>, VersionRestore) { - let lock = PROFILE_TEST_LOCK.get_or_init(|| Mutex::new(())); - let guard = lock - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let restore = VersionRestore(xai_client_version()); - (guard, restore) - } - - fn npm_release(version: &str) -> serde_json::Value { - let mut deps = serde_json::Map::new(); - for target in super::CLI_TARGETS { - deps.insert( - format!("@xai-official/grok-{target}"), - serde_json::Value::String(version.to_string()), - ); - } - serde_json::json!({ - "name": "@xai-official/grok", - "version": version, - "optionalDependencies": deps, - }) - } - - #[test] - fn stable_channel_accepts_only_a_bare_release_version() { - assert_eq!(parse_stable_channel(b"1.0.46\n").unwrap(), "1.0.46"); - assert!(parse_stable_channel(b"1.0.46").is_err()); - assert!(parse_stable_channel(b"1.0.47-alpha.1").is_err()); - assert!(parse_stable_channel(b"").is_err()); - } - - #[test] - fn npm_release_requires_every_platform_binary_at_the_same_version() { - let body = npm_release("1.0.46"); - assert_eq!( - parse_npm_release(&serde_json::to_vec(&body).unwrap()).unwrap(), - "1.0.46" - ); - - let mut mismatched = npm_release("1.0.46"); - mismatched["optionalDependencies"]["@xai-official/grok-linux-x64"] = - serde_json::Value::String("1.0.45".to_string()); - assert!(parse_npm_release(&serde_json::to_vec(&mismatched).unwrap()).is_err()); - - let mut wrong_package = npm_release("1.0.46"); - wrong_package["name"] = serde_json::Value::String("grok".to_string()); - assert!(parse_npm_release(&serde_json::to_vec(&wrong_package).unwrap()).is_err()); - } - - #[test] - fn refresh_and_fixed_version_environment_policies_are_strict() { - assert!(!refresh_enabled_from(Some("off"))); - assert!(!refresh_enabled_from(Some(" FALSE "))); - assert!(refresh_enabled_from(None)); - assert_eq!( - fixed_version_from(Some(" 1.0.46 ")).as_deref(), - Some("1.0.46") - ); - assert!(fixed_version_from(Some("1.0.46-beta.1")).is_none()); - assert!(fixed_version_from(Some("1.0")).is_none()); - } - - #[test] - fn cached_profile_never_rewinds_active_profile() { - let cached = CachedProfile { - version: "1.0.50".to_string(), - verified_at_unix_secs: 1, - }; - assert_eq!( - cached_version_to_restore(&cached, "1.0.46").unwrap(), - Some("1.0.50".to_string()) - ); - assert_eq!(cached_version_to_restore(&cached, "1.1.0").unwrap(), None); - } - - #[tokio::test] - async fn npm_is_used_only_when_the_stable_channel_fails() { - let npm_called = AtomicBool::new(false); - let version = fetch_latest_with_fallback( - || async { Ok("1.0.46".to_string()) }, - || async { - npm_called.store(true, Ordering::SeqCst); - Ok("1.0.45".to_string()) - }, - ) - .await - .unwrap(); - assert_eq!(version, "1.0.46"); - assert!(!npm_called.load(Ordering::SeqCst)); - - let version = fetch_latest_with_fallback( - || async { Err(ProfileRefreshError::HttpStatus(503)) }, - || async { Ok("1.0.46".to_string()) }, - ) - .await - .unwrap(); - assert_eq!(version, "1.0.46"); - - let result = fetch_latest_with_fallback( - || async { Err(ProfileRefreshError::HttpStatus(503)) }, - || async { Err(ProfileRefreshError::InvalidMetadata) }, - ) - .await; - assert!(matches!( - result, - Err(ProfileRefreshError::AllSourcesFailed { .. }) - )); - } - - #[tokio::test] - async fn cache_hit_is_restored_without_network_when_refresh_is_disabled() { - let (_lock, _restore) = version_restore_guard(); - set_xai_client_version("1.0.46").unwrap(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - runtime - .kv_set( - PROFILE_CACHE_KEY, - serde_json::to_string(&CachedProfile { - version: "1.0.50".to_string(), - verified_at_unix_secs: 1, - }) - .unwrap(), - Some(Duration::from_secs(60)), - ) - .await - .unwrap(); - - let result = refresh_once_with_fetch(&runtime, None, false, || async { - Err(ProfileRefreshError::HttpStatus(599)) - }) - .await - .unwrap(); - - assert_eq!(result, "1.0.50"); - assert_eq!(xai_client_version(), "1.0.50"); - } - - #[tokio::test] - async fn refresh_failure_keeps_previous_profile() { - let (_lock, _restore) = version_restore_guard(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let before = xai_client_version(); - let result = refresh_once_with_fetch(&runtime, None, true, || async { - Err(ProfileRefreshError::HttpStatus(503)) - }) - .await; - - assert!(matches!(result, Err(ProfileRefreshError::HttpStatus(503)))); - assert_eq!(xai_client_version(), before); - } - - #[tokio::test] - async fn successful_refresh_publishes_and_caches_version() { - let (_lock, _restore) = version_restore_guard(); - set_xai_client_version("1.0.46").unwrap(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let result = - refresh_once_with_fetch(&runtime, None, true, || async { Ok("1.0.51".to_string()) }) - .await - .unwrap(); - - assert_eq!(result, "1.0.51"); - assert_eq!(xai_client_version(), "1.0.51"); - let cached = runtime.kv_get(PROFILE_CACHE_KEY).await.unwrap().unwrap(); - assert!(cached.contains("\"1.0.51\"")); - } - - #[tokio::test] - async fn fixed_version_override_skips_network_and_publishes_version() { - let (_lock, _restore) = version_restore_guard(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let fetch_called = AtomicBool::new(false); - let result = refresh_once_with_fetch(&runtime, Some("1.0.60"), true, || async { - fetch_called.store(true, Ordering::SeqCst); - Ok("1.0.61".to_string()) - }) - .await - .unwrap(); - - assert_eq!(result, "1.0.60"); - assert!(!fetch_called.load(Ordering::SeqCst)); - assert_eq!(xai_client_version(), "1.0.60"); - } - - #[tokio::test] - async fn rollback_is_rejected_without_replacing_profile() { - let (_lock, _restore) = version_restore_guard(); - set_xai_client_version("1.0.60").unwrap(); - let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); - let result = - refresh_once_with_fetch(&runtime, None, true, || async { Ok("1.0.59".to_string()) }) - .await; - - assert!(matches!(result, Err(ProfileRefreshError::Rollback))); - assert_eq!(xai_client_version(), "1.0.60"); - } -} diff --git a/crates/aether-ai/formats/src/client_profile.rs b/crates/aether-ai/formats/src/client_profile.rs new file mode 100644 index 000000000..737d184b9 --- /dev/null +++ b/crates/aether-ai/formats/src/client_profile.rs @@ -0,0 +1,67 @@ +//! Immutable client identity snapshots shared by provider adapters. +use std::sync::{Arc, RwLock}; + +/// Readers release the lock before constructing a request. Publishing never +/// changes snapshots already held by an in-flight request. +#[derive(Debug)] +pub struct ClientProfileStore { + current: RwLock>, +} + +impl ClientProfileStore { + pub fn new(profile: T) -> Self { + Self { + current: RwLock::new(Arc::new(profile)), + } + } + + pub fn snapshot(&self) -> Arc { + self.current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub fn publish(&self, profile: T) -> Arc { + let mut current = self + .current + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::replace(&mut *current, Arc::new(profile)) + } +} + +/// Constructors validate wire safety; release adapters separately validate +/// official stable semantic versions before publishing them. +pub fn validate_client_version(version: &str) -> Result<&str, &'static str> { + let version = version.trim(); + if version.is_empty() || version.len() > 64 || !version.bytes().all(|b| (33..=126).contains(&b)) + { + return Err("invalid client version"); + } + Ok(version) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publishing_does_not_mutate_inflight_snapshots() { + let store = ClientProfileStore::new(("1.0.0", "client/1.0.0")); + let inflight = store.snapshot(); + let old = store.publish(("1.1.0", "client/1.1.0")); + assert_eq!(inflight, old); + assert_eq!(inflight.0, "1.0.0"); + assert_eq!(store.snapshot().1, "client/1.1.0"); + } + + #[test] + fn rejects_header_injection_and_oversized_versions() { + for version in ["", "1.0\r\nx: y", "1.0 0", "é"] { + assert!(validate_client_version(version).is_err()); + } + assert!(validate_client_version(&"1".repeat(65)).is_err()); + assert_eq!(validate_client_version(" 1.0.0 ").unwrap(), "1.0.0"); + } +} diff --git a/crates/aether-ai/formats/src/codex_profile.rs b/crates/aether-ai/formats/src/codex_profile.rs index 5d5c4eb89..ddd16b3cc 100644 --- a/crates/aether-ai/formats/src/codex_profile.rs +++ b/crates/aether-ai/formats/src/codex_profile.rs @@ -1,4 +1,6 @@ -use std::sync::{LazyLock, OnceLock, RwLock}; +use std::sync::{LazyLock, OnceLock}; + +use crate::client_profile::ClientProfileStore; static OS_INFO: LazyLock = LazyLock::new(os_info::get); @@ -59,26 +61,20 @@ impl Default for CodexClientProfile { } } -static ACTIVE_PROFILE: OnceLock> = OnceLock::new(); +static ACTIVE_PROFILE: OnceLock> = OnceLock::new(); -fn active_profile() -> &'static RwLock { - ACTIVE_PROFILE.get_or_init(|| RwLock::new(CodexClientProfile::default())) +fn active_profile() -> &'static ClientProfileStore { + ACTIVE_PROFILE.get_or_init(|| ClientProfileStore::new(CodexClientProfile::default())) } /// 返回当前画像的独立快照,调用方不会持有全局锁。 pub fn codex_client_profile() -> CodexClientProfile { - active_profile() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + (*active_profile().snapshot()).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) + (*active_profile().publish(profile)).clone() } /// 发布一份新的 CLI 画像。 diff --git a/crates/aether-ai/formats/src/lib.rs b/crates/aether-ai/formats/src/lib.rs index a7af48b23..160c81d0d 100644 --- a/crates/aether-ai/formats/src/lib.rs +++ b/crates/aether-ai/formats/src/lib.rs @@ -1,6 +1,7 @@ extern crate self as aether_ai_formats; pub mod api; +pub mod client_profile; pub mod codex_profile; pub mod contracts; pub mod formats; diff --git a/crates/aether-model-fetch/src/transport.rs b/crates/aether-model-fetch/src/transport.rs index 3f71ae349..dd644985e 100644 --- a/crates/aether-model-fetch/src/transport.rs +++ b/crates/aether-model-fetch/src/transport.rs @@ -27,8 +27,6 @@ use crate::{ build_models_fetch_url_for_client_version, deepseek_anthropic_models_fetch_uses_openai_auth, }; -const CLAUDE_CLI_USER_AGENT: &str = "claude-code/1.0.1"; -const GEMINI_CLI_USER_AGENT: &str = "GeminiCLI/0.1.5 (Windows; AMD64)"; const CLAUDE_VERSION_HEADER: &str = "2023-06-01"; const ANTIGRAVITY_FETCH_PROVIDER_API_FORMAT: &str = "antigravity:fetch_available_models"; const ANTIGRAVITY_LOAD_CODE_ASSIST_PROVIDER_API_FORMAT: &str = "antigravity:load_code_assist"; @@ -37,7 +35,7 @@ const KIRO_LIST_AVAILABLE_MODELS_PROVIDER_API_FORMAT: &str = "kiro:list_availabl const WINDSURF_MODEL_CONFIGS_PROVIDER_API_FORMAT: &str = "windsurf:model_configs"; const WINDSURF_MODEL_CONFIGS_PATH: &str = "/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs"; -const WINDSURF_IDE_VERSION: &str = "1.9600.41"; +const WINDSURF_IDE_VERSION: &str = aether_provider_transport::client_identity::WINDSURF.version; const BROWSER_FINGERPRINT_HEADERS: &[(&str, &str)] = &[ ( @@ -319,7 +317,10 @@ pub async fn build_gemini_cli_load_code_assist_plan( .ok_or_else(|| "GeminiCLI loadCodeAssist requires bearer or OAuth auth".to_string())?; let mut headers = BTreeMap::from([ - ("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string()), + ( + "user-agent".to_string(), + aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + ), ("accept-encoding".to_string(), "identity".to_string()), ("content-type".to_string(), "application/json".to_string()), ]); @@ -662,8 +663,19 @@ fn standard_models_fetch_headers( "anthropic-version".to_string(), CLAUDE_VERSION_HEADER.to_string(), )]); - if matches!(provider_type.as_str(), "claude_code" | "kiro") { - headers.insert("user-agent".to_string(), CLAUDE_CLI_USER_AGENT.to_string()); + if provider_type == "claude_code" { + headers.insert( + "user-agent".to_string(), + aether_provider_transport::claude_code::claude_code_client_profile() + .user_agent + .clone(), + ); + } else if provider_type == "kiro" { + headers.insert( + "user-agent".to_string(), + aether_provider_transport::client_identity::KIRO_MODELS_LEGACY_USER_AGENT + .to_string(), + ); } headers } @@ -673,7 +685,10 @@ fn standard_models_fetch_headers( .map(|(key, value)| (key.to_string(), value.to_string())) .collect::>(); if provider_type == "gemini_cli" { - headers.insert("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string()); + headers.insert( + "user-agent".to_string(), + aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + ); } headers } diff --git a/crates/aether-provider/pool/src/providers/chatgpt_web.rs b/crates/aether-provider/pool/src/providers/chatgpt_web.rs index c3fd4a54a..8a8d2d140 100644 --- a/crates/aether-provider/pool/src/providers/chatgpt_web.rs +++ b/crates/aether-provider/pool/src/providers/chatgpt_web.rs @@ -20,11 +20,10 @@ use crate::quota_refresh::ProviderPoolQuotaRequestSpec; pub const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com"; pub const CHATGPT_WEB_CONVERSATION_INIT_PATH: &str = "/backend-api/conversation/init"; -const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"; -const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"; -const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942"; -const CHATGPT_WEB_SEC_CH_UA: &str = - r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#; +use aether_provider_transport::client_identity::{ + CHATGPT_WEB_BUILD_NUMBER, CHATGPT_WEB_CLIENT_VERSION, CHATGPT_WEB_SEC_CH_UA, + CHATGPT_WEB_USER_AGENT, +}; #[derive(Debug, Clone, Default)] pub struct ChatGptWebProviderPoolAdapter; diff --git a/crates/aether-provider/pool/src/providers/gemini_cli.rs b/crates/aether-provider/pool/src/providers/gemini_cli.rs index 50d267dd7..4aeb375ee 100644 --- a/crates/aether-provider/pool/src/providers/gemini_cli.rs +++ b/crates/aether-provider/pool/src/providers/gemini_cli.rs @@ -10,7 +10,7 @@ use crate::provider::{ use crate::quota_refresh::ProviderPoolQuotaRequestSpec; pub const GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH: &str = "/v1internal:retrieveUserQuota"; -pub const GEMINI_CLI_USER_AGENT: &str = "GeminiCLI/0.1.5 (Windows; AMD64)"; +pub use aether_provider_transport::gemini_cli::GEMINI_CLI_USER_AGENT; #[derive(Debug, Clone, Default)] pub struct GeminiCliProviderPoolAdapter; @@ -52,7 +52,10 @@ pub fn build_gemini_cli_pool_quota_request( ("authorization".to_string(), authorization.1), ("content-type".to_string(), "application/json".to_string()), ("accept".to_string(), "application/json".to_string()), - ("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string()), + ( + "user-agent".to_string(), + aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + ), ]); ProviderPoolQuotaRequestSpec { diff --git a/crates/aether-provider/pool/src/providers/kiro.rs b/crates/aether-provider/pool/src/providers/kiro.rs index 27bfc8c3b..e914d880c 100644 --- a/crates/aether-provider/pool/src/providers/kiro.rs +++ b/crates/aether-provider/pool/src/providers/kiro.rs @@ -158,7 +158,7 @@ fn normalize_region(value: &str) -> &str { fn normalize_kiro_version(value: &str) -> &str { let value = value.trim(); if value.is_empty() { - "0.3.210" + aether_provider_transport::client_identity::DEFAULT_KIRO_VERSION } else { value } diff --git a/crates/aether-provider/pool/src/providers/windsurf.rs b/crates/aether-provider/pool/src/providers/windsurf.rs index ad152324a..c65e10807 100644 --- a/crates/aether-provider/pool/src/providers/windsurf.rs +++ b/crates/aether-provider/pool/src/providers/windsurf.rs @@ -177,7 +177,12 @@ fn build_windsurf_connect_rpc_request( headers.insert("content-type".to_string(), "application/json".to_string()); headers.insert("accept".to_string(), "application/json".to_string()); headers.insert("connect-protocol-version".to_string(), "1".to_string()); - headers.insert("user-agent".to_string(), "windsurf/1.9600.41".to_string()); + headers.insert( + "user-agent".to_string(), + aether_provider_transport::client_identity::WINDSURF + .user_agent + .to_string(), + ); ProviderPoolQuotaRequestSpec { request_id, @@ -200,9 +205,9 @@ fn windsurf_metadata(api_key: &str) -> Value { json!({ "apiKey": api_key, "ideName": "windsurf", - "ideVersion": "1.9600.41", + "ideVersion": aether_provider_transport::client_identity::WINDSURF.version, "extensionName": "windsurf", - "extensionVersion": "1.9600.41", + "extensionVersion": aether_provider_transport::client_identity::WINDSURF.version, "locale": "en", }) } diff --git a/crates/aether-provider/transport/src/agent_identity.rs b/crates/aether-provider/transport/src/agent_identity.rs index d7b7ee3ac..4afce03c4 100644 --- a/crates/aether-provider/transport/src/agent_identity.rs +++ b/crates/aether-provider/transport/src/agent_identity.rs @@ -769,6 +769,7 @@ async fn register_codex_agent_identity_from_access_token_with_auth_api_base_url( .map_err(|_| CodexAgentIdentityEnrollmentError::KeyGenerationFailed)?; let agent_private_key = STANDARD.encode(private_key_der.as_bytes()); let agent_public_key = agent_identity_ssh_public_key(&signing_key); + let client_profile = aether_ai_formats::codex_client_profile(); let registration_url = agent_registration_url(auth_api_base_url) .map_err(|_| CodexAgentIdentityEnrollmentError::RegistrationRequestFailed)?; let mut headers = BTreeMap::from([ @@ -778,14 +779,8 @@ async fn register_codex_agent_identity_from_access_token_with_auth_api_base_url( "authorization".to_string(), format!("Bearer {access_token}"), ), - ( - "user-agent".to_string(), - aether_ai_formats::codex_client_user_agent(), - ), - ( - "originator".to_string(), - aether_ai_formats::codex_client_originator(), - ), + ("user-agent".to_string(), client_profile.user_agent.clone()), + ("originator".to_string(), client_profile.originator.clone()), ]); if options.is_fedramp_account { headers.insert("x-openai-fedramp".to_string(), "true".to_string()); @@ -799,7 +794,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": client_profile.codex_version, "agent_harness_id": CODEX_AGENT_IDENTITY_AGENT_HARNESS_ID, "running_location": format!("cli-{}", std::env::consts::OS), }, diff --git a/crates/aether-provider/transport/src/antigravity/auth.rs b/crates/aether-provider/transport/src/antigravity/auth.rs index c8043a7b5..5f6d6d5ff 100644 --- a/crates/aether-provider/transport/src/antigravity/auth.rs +++ b/crates/aether-provider/transport/src/antigravity/auth.rs @@ -5,8 +5,8 @@ use serde_json::Value; use super::super::snapshot::GatewayProviderTransportSnapshot; pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity"; -pub const ANTIGRAVITY_CLIENT_VERSION: &str = "4.3.0"; -pub const ANTIGRAVITY_REQUEST_USER_AGENT: &str = "vscode/1.X.X (Antigravity/4.3.0)"; +pub const ANTIGRAVITY_CLIENT_VERSION: &str = crate::client_identity::ANTIGRAVITY.version; +pub const ANTIGRAVITY_REQUEST_USER_AGENT: &str = crate::client_identity::ANTIGRAVITY.user_agent; const ANTIGRAVITY_CLIENT_NAME: &str = "antigravity"; const ANTIGRAVITY_GOOG_API_CLIENT: &str = "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x"; diff --git a/crates/aether-provider/transport/src/claude_code/mod.rs b/crates/aether-provider/transport/src/claude_code/mod.rs index 003f2a6bf..5089f0732 100644 --- a/crates/aether-provider/transport/src/claude_code/mod.rs +++ b/crates/aether-provider/transport/src/claude_code/mod.rs @@ -28,7 +28,7 @@ pub use profile::{ CLAUDE_CODE_CONTEXT_MANAGEMENT_BETA, CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04, }; pub use request::{ - build_claude_code_passthrough_headers, sanitize_claude_code_request_body, - sanitize_claude_code_request_body_for_beta_header, + build_claude_code_passthrough_headers, finalize_claude_code_request_identity, + sanitize_claude_code_request_body, sanitize_claude_code_request_body_for_beta_header, }; pub use url::build_claude_code_messages_url; diff --git a/crates/aether-provider/transport/src/claude_code/profile.rs b/crates/aether-provider/transport/src/claude_code/profile.rs index 01cc6de0c..f2a7a4799 100644 --- a/crates/aether-provider/transport/src/claude_code/profile.rs +++ b/crates/aether-provider/transport/src/claude_code/profile.rs @@ -1,5 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::{Arc, OnceLock}; + +use aether_ai_formats::client_profile::ClientProfileStore; use aether_ai_formats::ApiOperation; @@ -94,28 +96,24 @@ impl Default for ClaudeCodeClientProfile { } } -static ACTIVE_CLIENT_PROFILE: OnceLock>> = OnceLock::new(); +static ACTIVE_CLIENT_PROFILE: OnceLock> = + OnceLock::new(); -fn active_client_profile() -> &'static RwLock> { - ACTIVE_CLIENT_PROFILE.get_or_init(|| RwLock::new(Arc::new(ClaudeCodeClientProfile::default()))) +fn active_client_profile() -> &'static ClientProfileStore { + ACTIVE_CLIENT_PROFILE + .get_or_init(|| ClientProfileStore::new(ClaudeCodeClientProfile::default())) } /// Returns a snapshot of the active CLI profile without holding the global lock. pub fn claude_code_client_profile() -> Arc { - active_client_profile() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + active_client_profile().snapshot() } /// Atomically replaces the active CLI profile and returns the previous one. pub fn set_claude_code_client_profile( profile: ClaudeCodeClientProfile, ) -> Arc { - let mut current = active_client_profile() - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - std::mem::replace(&mut *current, Arc::new(profile)) + active_client_profile().publish(profile) } /// Publishes a CLI profile for the given release version. @@ -310,8 +308,13 @@ impl ClaudeCodeTransportIdentityProfile { ("x-stainless-retry-count", template.stainless_retry_count), ("x-stainless-timeout", template.stainless_timeout), ] { + headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name)); headers.insert(name.to_string(), value.to_string()); } + headers.retain(|name, _| { + !name.eq_ignore_ascii_case("user-agent") + && !name.eq_ignore_ascii_case("x-stainless-helper-method") + }); headers.insert("user-agent".to_string(), self.user_agent().to_string()); if stream { headers.insert( @@ -328,8 +331,14 @@ impl ClaudeCodeTransportIdentityProfile { headers: &mut BTreeMap, operation: Option, ) { - let incoming = headers.get("anthropic-beta").map(String::as_str); - let merged = self.merge_beta_tokens(incoming, operation); + let incoming = headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("anthropic-beta")) + .map(|(_, value)| value.as_str()) + .collect::>() + .join(","); + let merged = self.merge_beta_tokens(Some(&incoming), operation); + headers.retain(|name, _| !name.eq_ignore_ascii_case("anthropic-beta")); if merged.is_empty() { headers.remove("anthropic-beta"); } else { diff --git a/crates/aether-provider/transport/src/claude_code/request.rs b/crates/aether-provider/transport/src/claude_code/request.rs index c63b68f91..6f5e9a018 100644 --- a/crates/aether-provider/transport/src/claude_code/request.rs +++ b/crates/aether-provider/transport/src/claude_code/request.rs @@ -56,6 +56,33 @@ pub fn build_claude_code_passthrough_headers( out } +/// Reconcile header/body identity using one immutable profile at the common +/// outbound boundary. Preserve the planner's content negotiation and count-token contract. +pub fn finalize_claude_code_request_identity( + headers: &mut BTreeMap, + body: &mut Value, + profile: &ClaudeCodeTransportIdentityProfile, + operation: Option, +) { + let accept = headers.get("accept").cloned(); + let stream = body.get("stream").and_then(Value::as_bool).unwrap_or(false) + || accept + .as_deref() + .is_some_and(|v| v.contains("text/event-stream")); + profile.apply_fixed_headers(headers, stream); + if stream { + crate::headers::force_identity_accept_encoding(headers); + } + profile.apply_beta_policy(headers, operation); + if let Some(accept) = accept { + headers.insert("accept".to_string(), accept); + } + if operation != Some(aether_ai_formats::ApiOperation::ClaudeCountTokens) { + let beta = headers.get("anthropic-beta").cloned().unwrap_or_default(); + sanitize_claude_code_request_body_for_beta_header(body, &beta, profile); + } +} + pub fn sanitize_claude_code_request_body(body: &mut Value) { let profile = current_claude_code_transport_identity_profile(); let beta_header = profile.merge_beta_tokens(None, None); @@ -371,3 +398,55 @@ mod tests { ); } } +#[cfg(test)] +mod final_identity_tests { + use super::*; + use crate::claude_code::{ClaudeCodeClientProfile, CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04}; + use std::sync::Arc; + fn profile() -> ClaudeCodeTransportIdentityProfile { + ClaudeCodeTransportIdentityProfile::new( + &CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04, + Arc::new(ClaudeCodeClientProfile::cli("2.1.286").unwrap()), + ) + } + #[test] + fn terminal_snapshot_reconciles_billing_and_headers_without_changing_negotiation() { + let mut headers = BTreeMap::from([ + ("accept".into(), "text/event-stream".into()), + ("User-Agent".into(), "old-client".into()), + ("X-Stainless-Package-Version".into(), "old-sdk".into()), + ("Anthropic-Beta".into(), "custom-beta".into()), + ]); + let mut body = serde_json::json!({"stream":true, "system":[{"type":"text", "text":"x-anthropic-billing-header: cc_version=2.1.280.abc; cc_entrypoint=cli;"}]}); + finalize_claude_code_request_identity(&mut headers, &mut body, &profile(), None); + assert_eq!(headers["user-agent"], "claude-cli/2.1.286 (external, cli)"); + assert_eq!(headers["accept"], "text/event-stream"); + assert_eq!(headers["accept-encoding"], "identity"); + assert_eq!(headers["x-stainless-package-version"], "0.112.1"); + assert!(!headers.contains_key("User-Agent")); + assert!(!headers.contains_key("X-Stainless-Package-Version")); + assert!(!headers.contains_key("Anthropic-Beta")); + assert!(headers["anthropic-beta"].contains("custom-beta")); + assert!(body["system"][0]["text"] + .as_str() + .unwrap() + .contains("cc_version=2.1.286.abc;")); + let before = body.clone(); + finalize_claude_code_request_identity(&mut headers, &mut body, &profile(), None); + assert_eq!(body, before); + } + #[test] + fn count_token_body_remains_untouched() { + let mut headers = BTreeMap::new(); + let mut body = serde_json::json!({"model":"claude-test", "messages":[], "thinking":{"type":"enabled"}}); + let before = body.clone(); + finalize_claude_code_request_identity( + &mut headers, + &mut body, + &profile(), + Some(aether_ai_formats::ApiOperation::ClaudeCountTokens), + ); + assert_eq!(body, before); + assert_eq!(headers["user-agent"], "claude-cli/2.1.286 (external, cli)"); + } +} diff --git a/crates/aether-provider/transport/src/client_identity.rs b/crates/aether-provider/transport/src/client_identity.rs new file mode 100644 index 000000000..02670f5eb --- /dev/null +++ b/crates/aether-provider/transport/src/client_identity.rs @@ -0,0 +1,65 @@ +//! Provider wire templates. Dynamic CLI versions are independent of SDK and +//! browser fingerprints; pinned templates are changed only after verification. +use aether_ai_formats::client_profile::validate_client_version; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionedClientIdentity { + pub version: String, + pub user_agent: String, +} + +impl VersionedClientIdentity { + pub fn new(version: &str, agent: &str, suffix: &str) -> Result { + let version = validate_client_version(version)?; + Ok(Self { + version: version.to_owned(), + user_agent: format!("{agent}/{version}{suffix}"), + }) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct PinnedClientIdentity { + pub version: &'static str, + pub user_agent: &'static str, +} + +pub const GEMINI_CLI: PinnedClientIdentity = PinnedClientIdentity { + version: "0.1.5", + user_agent: "GeminiCLI/0.1.5 (Windows; AMD64)", +}; +// Legacy standard-models fallback; dedicated Kiro requests use OAuth SDK identity. +pub const KIRO_MODELS_LEGACY_USER_AGENT: &str = "claude-code/1.0.1"; + +pub const ANTIGRAVITY: PinnedClientIdentity = PinnedClientIdentity { + version: "4.3.0", + user_agent: "vscode/1.X.X (Antigravity/4.3.0)", +}; +pub const WINDSURF: PinnedClientIdentity = PinnedClientIdentity { + version: "1.9600.41", + user_agent: "windsurf/1.9600.41", +}; +// Verified browser release tuple, shared by inference and quota requests. +// Do not update these independently or derive a web build from a CLI release. +pub const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"; +pub const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"; +pub const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942"; +pub const CHATGPT_WEB_SEC_CH_UA: &str = + r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#; +pub const CHATGPT_WEB_BROWSER_PROFILE: &str = "chrome143"; + +// Kiro authentication and request adapters already share the OAuth model. +pub use aether_oauth::provider::providers::{ + DEFAULT_KIRO_VERSION, DEFAULT_NODE_VERSION, DEFAULT_SYSTEM_VERSION, +}; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn pinned_headers_and_body_versions_are_calibrated_together() { + for identity in [ANTIGRAVITY, WINDSURF] { + assert!(identity.user_agent.contains(identity.version)); + } + } +} diff --git a/crates/aether-provider/transport/src/gemini_cli/mod.rs b/crates/aether-provider/transport/src/gemini_cli/mod.rs index d15f4d542..e2e4854ea 100644 --- a/crates/aether-provider/transport/src/gemini_cli/mod.rs +++ b/crates/aether-provider/transport/src/gemini_cli/mod.rs @@ -1,5 +1,9 @@ mod auth; mod policy; +mod profile; +pub use profile::{ + gemini_cli_client_user_agent, gemini_cli_client_version, set_gemini_cli_client_version, +}; mod request; mod url; diff --git a/crates/aether-provider/transport/src/gemini_cli/profile.rs b/crates/aether-provider/transport/src/gemini_cli/profile.rs new file mode 100644 index 000000000..51b6b3edc --- /dev/null +++ b/crates/aether-provider/transport/src/gemini_cli/profile.rs @@ -0,0 +1,41 @@ +use crate::client_identity::VersionedClientIdentity; +use aether_ai_formats::client_profile::ClientProfileStore; +use std::sync::OnceLock; + +pub const GEMINI_CLI_BUILTIN_VERSION: &str = crate::client_identity::GEMINI_CLI.version; +// Keep the established template; a version release does not change platform identity. +fn identity(version: &str) -> Result { + VersionedClientIdentity::new(version, "GeminiCLI", " (Windows; AMD64)") +} +static ACTIVE: OnceLock> = OnceLock::new(); +fn active() -> &'static ClientProfileStore { + ACTIVE.get_or_init(|| { + ClientProfileStore::new( + identity(GEMINI_CLI_BUILTIN_VERSION).expect("valid built-in Gemini identity"), + ) + }) +} +pub fn gemini_cli_client_version() -> String { + active().snapshot().version.clone() +} +pub fn gemini_cli_client_user_agent() -> String { + active().snapshot().user_agent.clone() +} +pub fn set_gemini_cli_client_version(version: &str) -> Result { + Ok(active().publish(identity(version)?).version.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn version_updates_preserve_the_existing_platform_template() { + let profile = identity("0.62.0").unwrap(); + assert_eq!(profile.user_agent, "GeminiCLI/0.62.0 (Windows; AMD64)"); + assert!(identity("0.62.0\nx: y").is_err()); + assert_eq!( + identity(GEMINI_CLI_BUILTIN_VERSION).unwrap().user_agent, + super::super::GEMINI_CLI_USER_AGENT + ); + } +} diff --git a/crates/aether-provider/transport/src/gemini_cli/url.rs b/crates/aether-provider/transport/src/gemini_cli/url.rs index 18e3c778d..354b61086 100644 --- a/crates/aether-provider/transport/src/gemini_cli/url.rs +++ b/crates/aether-provider/transport/src/gemini_cli/url.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use url::form_urlencoded; -pub const GEMINI_CLI_USER_AGENT: &str = "GeminiCLI/0.1.5 (Windows; AMD64)"; +pub const GEMINI_CLI_USER_AGENT: &str = crate::client_identity::GEMINI_CLI.user_agent; pub const GEMINI_CLI_V1INTERNAL_PATH_TEMPLATE: &str = "/v1internal:{action}"; pub const GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH: &str = "/v1internal:retrieveUserQuota"; diff --git a/crates/aether-provider/transport/src/headers.rs b/crates/aether-provider/transport/src/headers.rs index 81c8f6f96..a65758961 100644 --- a/crates/aether-provider/transport/src/headers.rs +++ b/crates/aether-provider/transport/src/headers.rs @@ -81,7 +81,7 @@ pub fn should_skip_request_header(name: &str) -> bool { } fn is_untrusted_forwarding_metadata_header(normalized_name: &str) -> bool { - matches!(normalized_name, "forwarded" | "via") + matches!(normalized_name, "forwarded" | "via" | "x-envoy-internal") || normalized_name.starts_with("x-forwarded-") || normalized_name.starts_with("x_forwarded_") || normalized_name.starts_with("x-real-") @@ -186,6 +186,7 @@ pub(crate) fn remove_declared_connection_headers( )); headers.retain(|name, _| { !name.eq_ignore_ascii_case("connection") + && !name.eq_ignore_ascii_case("x-envoy-internal") && !is_declared_connection_header(name, &all_declared) }); } @@ -455,6 +456,7 @@ mod tests { "X-Rewrite-URL", "X-Override-URL", "X-Envoy-Original-Path", + "X-Envoy-Internal", ] { assert!(should_skip_request_header(header)); assert!(should_skip_upstream_passthrough_header(header)); diff --git a/crates/aether-provider/transport/src/lib.rs b/crates/aether-provider/transport/src/lib.rs index 17dfe421c..22268127a 100644 --- a/crates/aether-provider/transport/src/lib.rs +++ b/crates/aether-provider/transport/src/lib.rs @@ -5,6 +5,7 @@ pub mod auth; mod auth_config; mod cache; pub mod claude_code; +pub mod client_identity; mod codex_fingerprint; pub mod conversion; mod diagnostics; diff --git a/crates/aether-provider/transport/src/outbound_request_policy.rs b/crates/aether-provider/transport/src/outbound_request_policy.rs index 397cd3fca..39660ec6c 100644 --- a/crates/aether-provider/transport/src/outbound_request_policy.rs +++ b/crates/aether-provider/transport/src/outbound_request_policy.rs @@ -199,6 +199,23 @@ pub fn apply_provider_outbound_request_policies( provider_request_headers: &mut BTreeMap, provider_request_body: &mut Value, ) -> Vec { + // This is the common boundary after planner rules and before transport. + provider_request_headers.retain(|name, _| !name.eq_ignore_ascii_case("x-envoy-internal")); + if transport + .provider + .provider_type + .trim() + .eq_ignore_ascii_case("claude_code") + && aether_ai_formats::normalize_api_format_alias(provider_api_format) == "claude:messages" + { + let profile = crate::claude_code::current_claude_code_transport_identity_profile(); + crate::claude_code::finalize_claude_code_request_identity( + provider_request_headers, + provider_request_body, + &profile, + context.api_operation(), + ); + } if !transport .provider .provider_type diff --git a/crates/aether-provider/transport/src/windsurf.rs b/crates/aether-provider/transport/src/windsurf.rs index 89ca15909..461fd49ea 100644 --- a/crates/aether-provider/transport/src/windsurf.rs +++ b/crates/aether-provider/transport/src/windsurf.rs @@ -23,7 +23,7 @@ pub mod proto; pub const PROVIDER_TYPE: &str = "windsurf"; pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage"; pub const GET_CHAT_MESSAGE_PATH: &str = "/exa.api_server_pb.ApiServerService/GetChatMessage"; -const DEFAULT_IDE_VERSION: &str = "1.9600.41"; +pub const DEFAULT_IDE_VERSION: &str = crate::client_identity::WINDSURF.version; const PLACEHOLDER_API_KEY: &str = "__placeholder__"; pub fn is_windsurf_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool { diff --git a/crates/aether-provider/transport/src/xai.rs b/crates/aether-provider/transport/src/xai.rs index 7da488600..060c68d8d 100644 --- a/crates/aether-provider/transport/src/xai.rs +++ b/crates/aether-provider/transport/src/xai.rs @@ -1,7 +1,10 @@ pub mod video; use std::collections::BTreeMap; -use std::sync::{OnceLock, RwLock}; +use std::sync::OnceLock; + +use crate::client_identity::VersionedClientIdentity; +use aether_ai_formats::client_profile::ClientProfileStore; use aether_ai_formats::normalize_api_format_alias; use serde_json::Value; @@ -23,37 +26,31 @@ pub const XAI_CLIENT_IDENTIFIER_VALUE: &str = "grok-shell"; pub const XAI_AUTHENTICATE_RESPONSE_HEADER: &str = "x-authenticateresponse"; pub const XAI_AUTHENTICATE_RESPONSE_VALUE: &str = "authenticate-response"; -static ACTIVE_CLIENT_VERSION: OnceLock> = OnceLock::new(); +static ACTIVE_CLIENT_VERSION: OnceLock> = + OnceLock::new(); -fn active_client_version() -> &'static RwLock { - ACTIVE_CLIENT_VERSION.get_or_init(|| RwLock::new(XAI_DEFAULT_CLIENT_VERSION.to_owned())) +fn active_client_version() -> &'static ClientProfileStore { + ACTIVE_CLIENT_VERSION.get_or_init(|| { + ClientProfileStore::new( + VersionedClientIdentity::new(XAI_DEFAULT_CLIENT_VERSION, "xai-grok-workspace", "") + .expect("valid built-in Grok identity"), + ) + }) } /// 返回当前发布的 Grok CLI 版本快照。 pub fn xai_client_version() -> String { - active_client_version() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + active_client_version().snapshot().version.clone() } -/// 原子替换当前 Grok CLI 版本,返回替换前的版本;版本校验由发布检查器负责,这里只拒绝明显非法值。 +/// 原子替换当前 Grok CLI 身份,返回替换前的版本。 pub fn set_xai_client_version(version: &str) -> Result { - let version = version.trim(); - if version.is_empty() - || version.len() > 64 - || !version.bytes().all(|byte| (33..=126).contains(&byte)) - { - return Err("invalid Grok CLI version"); - } - let mut current = active_client_version() - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - Ok(std::mem::replace(&mut *current, version.to_owned())) + let profile = VersionedClientIdentity::new(version, "xai-grok-workspace", "")?; + Ok(active_client_version().publish(profile).version.clone()) } pub fn xai_cli_user_agent() -> String { - format!("xai-grok-workspace/{}", xai_client_version()) + active_client_version().snapshot().user_agent.clone() } pub fn is_xai_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool { @@ -125,7 +122,7 @@ pub fn should_attach_cli_identity_headers( pub fn insert_cli_identity_headers(headers: &mut BTreeMap) { let client_version = xai_client_version(); - let user_agent = xai_cli_user_agent(); + let user_agent = format!("xai-grok-workspace/{client_version}"); for (name, value) in [ (XAI_TOKEN_AUTH_HEADER, XAI_TOKEN_AUTH_VALUE), (XAI_CLIENT_VERSION_HEADER, client_version.as_str()), diff --git a/docs/operations/codex-cli-alignment.md b/docs/operations/codex-cli-alignment.md index 186b89127..0cc8016fe 100644 --- a/docs/operations/codex-cli-alignment.md +++ b/docs/operations/codex-cli-alignment.md @@ -1,5 +1,6 @@ # Codex CLI 通用协议对齐 +运行时版本刷新、每节点缓存同步及固定版本配置见[统一客户端画像](provider-client-profiles.md)。 本次对齐以官方 `openai/codex` 稳定标签 `rust-v0.159.3` 为可发布版本依据,同时核查最新 `main` 的协议实现。稳定标签提交为 `01fc69f4026735edfdf6789820549727a4867b11`,核查的 main 提交为 `444da310e108da16aaeb18fd790b0ac464f08aca`。 网关承接的是客户端与上游之间的协议,不复制 CLI 的本地工具执行、终端界面或个人账户状态。 diff --git a/docs/operations/provider-client-profiles.md b/docs/operations/provider-client-profiles.md new file mode 100644 index 000000000..18dfc00ce --- /dev/null +++ b/docs/operations/provider-client-profiles.md @@ -0,0 +1,102 @@ +# 提供商客户端画像 + +网关统一管理客户端身份,但不把所有提供商伪装成同一个客户端。 +客户端发行版本、SDK/运行时模板、浏览器构建和账户/会话标识是不同维度。 +本次改造保留已有认证、请求体转换、原生端点和显式配置优先级。 + +## 策略 + +| 客户端 | 版本来源 | 检查间隔 | 不随发行版本改变的内容 | +| --- | --- | --- | --- | +| Codex CLI/Core | 官方 `@openai/codex` npm latest | 每日 | `codex_cli_rs` originator、现有 OS/架构 UA 模板 | +| Claude Code | 官方 `@anthropic-ai/claude-code` npm latest | 每日 | 已校准的 Stainless SDK、运行时字段和 beta/能力模板 | +| Grok CLI | `https://x.ai/cli/stable`,失败回退官方 npm latest | 3 小时 | 现有 CLI 身份字段、OAuth/API-key 路由与自定义网关优先级 | +| Gemini CLI | 官方 `@google/gemini-cli` npm latest | 每日 | 现有 `(Windows; AMD64)` UA 模板与 Code Assist 请求协议 | +| Antigravity、Windsurf | 经核验的固定模板 | 不自动更新 | IDE UA、请求体版本及现有平台模板 | +| Kiro | 已有 OAuth SDK 模板及显式配置 | 不自动更新 | SDK、Node 和系统版本;保留旧模型查询兼容回退 | +| ChatGPT Web、Grok Web | 固定浏览器构建或已有可配置浏览器画像 | 不自动更新 | 浏览器 UA、Client Hints、TLS/HTTP 指纹及 Web 构建信息 | +| 原生/其他提供商 | 原有协议和配置 | 无 CLI 版本刷新 | 不注入无依据的 CLI、SDK 或浏览器指纹 | + +Codex Desktop 不仅凭客户端名称切换 originator 或 SDK 字段;沿用已确认的 +Codex Core 身份。代理客户端传入的版本不是官方发行版本来源。 +不会为 Rust CLI 强行添加 JavaScript Stainless 字段。 + +## 集群刷新与本地同步 + +- `apps/aether-gateway/src/cli_client_profile.rs` 保存四个动态 CLI 描述和共同引擎。 +- 启动预热和后台 singleton 共用发行查询互斥锁。新鲜的已验证缓存避免多个节点 + 启动时重复查询官方源。原有缓存 key 和字段保持兼容。 +- 各进程独立运行缓存同步任务,每 60 秒同步一次,包括仅承接请求的 frontdoor 节点。 + 此任务不查询发行源、不竞争 singleton;启动守卫随主程序退出而中止任务。 +- 多节点必须使用同一个共享运行时缓存。内存后端只提供进程内的缓存和互斥语义。 +- 自动恢复和刷新不接受比本地画像更旧的稳定版本。外部查询等待期间若共享缓存已升级, + 发布前再次恢复并检查版本,拒绝过期结果。 +- 官方请求仅允许 HTTPS,禁止重定向及系统环境代理;连接、请求和响应大小均有上限。 + npm 元数据需要包名、稳定版本以及相应原生平台依赖一致。Gemini CLI 的官方 JS 包 + 不要求不存在的原生平台依赖。 +- 发行源、校验或缓存读取失败不清空现有画像。缓存写入失败保留本地已验证画像并告警; + 其他节点只能继续使用最近成功持久化的画像,因此需关注缓存失败日志并恢复共享缓存。 + +## 配置 + +| 客户端 | 固定版本 | 禁用外部刷新 | +| --- | --- | --- | +| Codex | `AETHER_CODEX_CLIENT_VERSION` | `AETHER_CODEX_CLIENT_PROFILE_REFRESH=false` | +| Claude Code | `AETHER_CLAUDE_CODE_CLIENT_VERSION` | `AETHER_CLAUDE_CODE_CLIENT_PROFILE_REFRESH=false` | +| Grok CLI | `AETHER_XAI_CLIENT_VERSION` | `AETHER_XAI_CLIENT_PROFILE_REFRESH=false` | +| Gemini CLI | `AETHER_GEMINI_CLI_CLIENT_VERSION` | `AETHER_GEMINI_CLI_CLIENT_PROFILE_REFRESH=false` | + +有效固定版本优先于缓存、内置值和外部发行检查,可用于显式回退;该覆盖只改变当前 +进程,不写入集群缓存。需要集群固定版本时应为各节点配置同一个值。 +禁用刷新仅停止外部查询,仍允许从共享缓存同步。未配置固定版本或刷新开关时, +默认使用缓存/内置值并启用外部检查。 + +## 请求边界 + +`aether-ai-formats::client_profile::ClientProfileStore` 用不可变 `Arc` 快照发布画像。 +请求已经持有的快照不会被后台更新修改。 + +- Grok CLI 默认版本头和 UA 从同一个快照产生;保留既有显式头覆盖规则。 +- Gemini CLI 的推理、配额和模型发现读取同一进程内画像来源,而不是各自维护版本常量。 +- Claude Code 在共同出站策略边界重新对齐固定头、beta 和 billing `cc_version`, + 使用一次取得的画像完成头/体校准。保留内容协商和流式行为;count-tokens 不执行 + 消息请求体改写。 +- Codex 使用现有受保护认证头、Core UA/originator 和可选会话指纹收敛策略;不改变 + Responses-lite、compact、agent-identity 和原生记忆端点的契约。 +- 客户端的 `x-envoy-internal` 不能透传为上游可信内部标记;同格式、跨格式、 + passthrough 和共同出站策略均包含过滤。 +- 不把用户 Bearer、Cookie、账号 ID、安装/窗口 ID 或 SDK 字段保存为发行画像。 + +静态模板集中在 `crates/aether-provider/transport/src/client_identity.rs`; +Kiro 复用 OAuth 的 SDK 模板,Grok Web 继续复用既有浏览器画像。 +静态版本只能按核验后的整套模板更新,不能直接套用 npm 的 CLI 版本。 + +## 回归验证 + +```bash +cargo fmt --all -- --check +cargo test -p aether-ai-formats --locked +cargo test -p aether-provider-transport --locked +cargo test -p aether-provider-pool --locked +cargo test -p aether-model-fetch --locked +cargo check -p aether-gateway --locked +cargo test -p aether-gateway --lib client_profile --locked +``` + +回归覆盖不可变快照、头/体版本一致性、稳定源回退、npm 平台校验、缓存损坏和防回退、 +刷新期间共享缓存升级、独立节点同步、固定版本优先级,以及保留原有提供商协议行为。 +验证使用本地数据和模拟回调,不需要个人凭据或付费模型调用。 + +## 已执行验证 + +- Transport:550 项通过;formats:993 项通过;pool:71 项通过;model-fetch:83 项通过。 +- `cargo check -p aether-gateway --locked` 通过。 +- 网关专项:client_profile 21 项、Codex 249 项、Gemini CLI 39 项通过; + Claude Code 13 项通过,1 项失败。共 2019 项通过。 +- 格式与 `git diff --check` 通过。 +- 已知失败:`handlers::shared::catalog::tests::provider_key_status_snapshot_payload_backfills_claude_code_usage_windows`。 + 在独立 worktree 的改造前提交 `716c35bf5` 上单独运行,同样在 `catalog.rs:4373` + 得到 `exhausted=false` 与预期 `true` 的断言失败。该配额展示逻辑未在本次改造中修改, + 此失败不能计作通过,也不是画像改造引入的回归。 + +相关说明:[Codex CLI 对齐](codex-cli-alignment.md)、[xAI 行为](xai-provider.md)。 diff --git a/docs/operations/xai-provider.md b/docs/operations/xai-provider.md index 71aee7f72..bd65671d0 100644 --- a/docs/operations/xai-provider.md +++ b/docs/operations/xai-provider.md @@ -3,6 +3,9 @@ The following rules preserve the provider-specific behavior of the `xai` provider across Aether's request and transport layers. +Release refresh, per-node cache synchronization, and version overrides are documented +in [provider client profiles](provider-client-profiles.md). Grok retains its official stable +channel with npm fallback and the existing three-hour refresh interval. ## Responses and tools - HTTP requests drop `previous_response_id`. Clients must supply conversation From ac7e3abab393cffd70e93ff1824de0a27e39ca75 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Mon, 5 Oct 2026 16:01:41 +0800 Subject: [PATCH 3/3] fix: route Gemini client identity through transport facade --- apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs | 2 +- .../ai_serving/planner/passthrough/provider/family/request.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs b/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs index 73cfbda8c..f0f144b03 100644 --- a/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs +++ b/apps/aether-gateway/src/ai_serving/planner/gemini_cli.rs @@ -66,7 +66,7 @@ pub(crate) async fn build_gemini_cli_v1internal_provider_request( let extra_headers = BTreeMap::from([( "user-agent".to_string(), - aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + crate::ai_serving::transport::gemini_cli::gemini_cli_client_user_agent(), )]); let headers = build_standard_provider_request_headers(StandardProviderRequestHeadersInput { transport: &payload.transport, diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs index f27c599f2..fccdde24f 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs @@ -534,7 +534,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( if prepared.behavior.is_gemini_cli { extra_headers.insert( "user-agent".to_string(), - aether_provider_transport::gemini_cli::gemini_cli_client_user_agent(), + crate::ai_serving::transport::gemini_cli::gemini_cli_client_user_agent(), ); } let Some(mut provider_request_headers) = (if is_grok {