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