From d13f52bdf88bf56ff67cf64f21efcf6238fc7ddc Mon Sep 17 00:00:00 2001 From: dalamudx Date: Thu, 1 Oct 2026 15:56:21 +0800 Subject: [PATCH] =?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,