From 8093899b5a8ed5f190943e42f53fe4f638543b9b Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 21:51:53 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(claude-code):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20/api/oauth/usage=20=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=205H/=E5=91=A8=E9=A2=9D=E5=BA=A6=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E5=8F=B7=E6=B1=A0=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/provider/oauth/quota/claude_code.rs | 221 ++++++++++++++++++ .../admin/provider/oauth/quota/dispatch.rs | 21 ++ .../admin/provider/oauth/quota/mod.rs | 1 + .../admin/provider/oauth/quota/shared.rs | 1 + .../src/handlers/shared/catalog.rs | 140 +++++++++++ .../tests/architecture/ai_serving.rs | 10 +- crates/aether-admin/src/provider/quota.rs | 96 ++++++++ crates/aether-provider/pool/src/lib.rs | 43 ++-- .../pool/src/providers/claude_code.rs | 79 +++++++ .../aether-provider/pool/src/providers/mod.rs | 11 +- .../pool/src/providers/unsupported.rs | 6 - crates/aether-provider/pool/src/service.rs | 10 +- frontend/src/i18n/legacy-admin-messages.ts | 6 + frontend/src/i18n/messages.ts | 11 + frontend/src/views/admin/PoolManagement.vue | 30 +++ 15 files changed, 653 insertions(+), 33 deletions(-) create mode 100644 apps/aether-gateway/src/handlers/admin/provider/oauth/quota/claude_code.rs create mode 100644 crates/aether-provider/pool/src/providers/claude_code.rs diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/claude_code.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/claude_code.rs new file mode 100644 index 000000000..dd8d3a3c6 --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/claude_code.rs @@ -0,0 +1,221 @@ +use super::shared::{ + build_provider_quota_execution_plan, build_quota_snapshot_payload, + default_provider_quota_execution_timeouts, execute_provider_quota_plan, + extract_execution_error_message, oauth_refresh_auto_removed_result, + persist_provider_quota_refresh_state, quota_key_auto_removed, + quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome, +}; +use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot}; +use crate::GatewayError; +use aether_admin::provider::quota::parse_claude_code_oauth_usage_response; +use aether_admin::provider::redaction::admin_provider_metadata_bucket_safe_json; +use aether_contracts::ProxySnapshot; +use aether_data_contracts::repository::provider_catalog::{ + StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, +}; +use aether_provider_pool::build_claude_code_pool_quota_request; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; + +async fn execute_claude_code_quota_plan( + state: &AdminAppState<'_>, + transport: &AdminGatewayProviderTransportSnapshot, + authorization: (String, String), + proxy_override: Option<&ProxySnapshot>, +) -> Result { + let proxy = match proxy_override { + Some(proxy) => Some(proxy.clone()), + None => { + state + .resolve_transport_proxy_snapshot_with_tunnel_affinity(transport) + .await + } + }; + let timeouts = state + .resolve_transport_execution_timeouts(transport) + .or(Some(default_provider_quota_execution_timeouts( + proxy.as_ref(), + ))); + let spec = build_claude_code_pool_quota_request(&transport.key.id, authorization); + let plan = build_provider_quota_execution_plan( + transport, + spec, + proxy, + state.resolve_transport_profile(transport), + timeouts, + ); + + execute_provider_quota_plan(state, transport, plan, "claude_code").await +} + +pub(crate) async fn refresh_claude_code_provider_quota_locally( + state: &AdminAppState<'_>, + provider: &StoredProviderCatalogProvider, + endpoint: &StoredProviderCatalogEndpoint, + keys: Vec, + proxy_override: Option, +) -> Result, GatewayError> { + let mut results = Vec::new(); + let mut success_count = 0usize; + let mut failed_count = 0usize; + let mut auto_removed_count = 0usize; + + for key in keys { + let transport = match state + .read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id) + .await? + { + Some(transport) => transport, + None => { + failed_count += 1; + results.push(json!({ + "key_id": key.id, + "key_name": key.name, + "status": "error", + "message": "Provider transport snapshot unavailable", + })); + continue; + } + }; + + let authorization = match state.resolve_local_oauth_header_auth(&transport).await? { + Some(auth) => auth, + _ => { + if quota_key_auto_removed(state, &key.id).await? { + auto_removed_count += 1; + results.push(oauth_refresh_auto_removed_result(&key)); + continue; + } + failed_count += 1; + results.push(json!({ + "key_id": key.id, + "key_name": key.name, + "status": "error", + "message": "缺少 OAuth 认证信息,请先授权/刷新 Token", + })); + continue; + } + }; + + let result = match execute_claude_code_quota_plan( + state, + &transport, + authorization, + proxy_override.as_ref(), + ) + .await? + { + ProviderQuotaExecutionOutcome::Response(result) => result, + ProviderQuotaExecutionOutcome::Failure(_) => { + failed_count += 1; + results.push(json!({ + "key_id": key.id, + "key_name": key.name, + "status": "error", + "message": "oauth/usage 请求执行失败", + "status_code": 502, + })); + continue; + } + }; + + let now_unix_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let mut metadata_update = None::; + let (oauth_invalid_at_unix_secs, oauth_invalid_reason) = + quota_refresh_success_invalid_state(&key); + let mut status = "error".to_string(); + let mut message = None::; + + if result.status_code == 200 { + if let Some(body_json) = result + .body + .as_ref() + .and_then(|body| body.json_body.as_ref()) + { + metadata_update = parse_claude_code_oauth_usage_response(body_json, now_unix_secs) + .map(|metadata| json!({ "claude_code": metadata })); + if metadata_update.is_some() { + status = "success".to_string(); + } else { + status = "no_metadata".to_string(); + message = Some("响应中未包含额度窗口".to_string()); + } + } else { + status = "no_metadata".to_string(); + message = Some("响应中未包含配额信息".to_string()); + } + } else { + message = Some(match result.status_code { + 401 => "oauth/usage 返回 401,Token 可能已失效,请刷新 Token".to_string(), + 403 => "oauth/usage 返回 403,该账号缺少 user:profile 权限(如 Setup Token),无法查询额度" + .to_string(), + 429 => "oauth/usage 被限流,请稍后重试".to_string(), + code => format!("oauth/usage 返回状态码 {code}"), + }); + } + + if !persist_provider_quota_refresh_state( + state, + &key.id, + metadata_update.as_ref(), + oauth_invalid_at_unix_secs, + oauth_invalid_reason, + None, + ) + .await? + { + failed_count += 1; + results.push(json!({ + "key_id": key.id, + "key_name": key.name, + "status": "error", + "message": "Key 状态写入失败", + })); + continue; + } + + if status == "success" { + success_count += 1; + } else { + failed_count += 1; + } + + let mut payload = serde_json::Map::new(); + payload.insert("key_id".to_string(), json!(key.id)); + payload.insert("key_name".to_string(), json!(key.name)); + payload.insert("status".to_string(), json!(status)); + if let Some(message) = message { + payload.insert("message".to_string(), json!(message)); + } + if let Some(metadata) = metadata_update + .as_ref() + .and_then(|value| value.get("claude_code")) + { + payload.insert( + "metadata".to_string(), + admin_provider_metadata_bucket_safe_json("claude_code", Some(metadata)), + ); + } + if let Some(quota_snapshot) = build_quota_snapshot_payload( + "claude_code", + key.status_snapshot.as_ref(), + metadata_update.as_ref(), + ) { + payload.insert("quota_snapshot".to_string(), quota_snapshot); + } + results.push(serde_json::Value::Object(payload)); + } + + Ok(Some(json!({ + "success": success_count, + "failed": failed_count, + "total": results.len(), + "results": results, + "message": format!("已处理 {} 个 Key", results.len()), + "auto_removed": auto_removed_count, + }))) +} diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/dispatch.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/dispatch.rs index 268b261df..fee4e20ef 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/dispatch.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/dispatch.rs @@ -3,6 +3,7 @@ use std::pin::Pin; use super::antigravity::refresh_antigravity_provider_quota_locally; use super::chatgpt_web::refresh_chatgpt_web_provider_quota_locally; +use super::claude_code::refresh_claude_code_provider_quota_locally; use super::codex::refresh_codex_provider_quota_locally; use super::gemini_cli::refresh_gemini_cli_provider_quota_locally; use super::grok::refresh_grok_provider_quota_locally; @@ -36,6 +37,10 @@ const PROVIDER_QUOTA_REFRESH_HANDLERS: &[(&str, ProviderQuotaRefreshHandler)] = "chatgpt_web", refresh_chatgpt_web_provider_quota_locally_boxed, ), + ( + "claude_code", + refresh_claude_code_provider_quota_locally_boxed, + ), ("codex", refresh_codex_provider_quota_locally_boxed), ( "gemini_cli", @@ -113,6 +118,22 @@ fn refresh_codex_provider_quota_locally_boxed<'a>( )) } +fn refresh_claude_code_provider_quota_locally_boxed<'a>( + state: &'a AdminAppState<'a>, + provider: &'a StoredProviderCatalogProvider, + endpoint: &'a StoredProviderCatalogEndpoint, + keys: Vec, + proxy_override: Option, +) -> ProviderQuotaRefreshFuture<'a> { + Box::pin(refresh_claude_code_provider_quota_locally( + state, + provider, + endpoint, + keys, + proxy_override, + )) +} + fn refresh_gemini_cli_provider_quota_locally_boxed<'a>( state: &'a AdminAppState<'a>, provider: &'a StoredProviderCatalogProvider, diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/mod.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/mod.rs index 3f794abae..1ab388d30 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod antigravity; pub(crate) mod chatgpt_web; +pub(crate) mod claude_code; pub(crate) mod codex; pub(crate) mod dispatch; pub(crate) mod gemini_cli; diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs index e9c086b7e..5be9c5cb0 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs @@ -1713,6 +1713,7 @@ fn provider_quota_url_has_allowed_origin(provider_name: &str, value: &str) -> bo | "daily-cloudcode-pa.sandbox.googleapis.com" ), "gemini_cli" => host == "cloudcode-pa.googleapis.com", + "claude_code" => host == "api.anthropic.com", "chatgpt_web" | "codex" => host == "chatgpt.com", "grok" => host == "grok.com", "xai" => host == "cli-chat-proxy.grok.com", diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 96ab2f78c..c9038926e 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -2294,6 +2294,107 @@ fn build_gemini_cli_quota_status_snapshot( })) } +fn build_claude_code_quota_status_snapshot( + upstream_metadata: Option<&Value>, + source: &str, +) -> Option { + let metadata = provider_quota_metadata_bucket(upstream_metadata, "claude_code")?; + let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at")); + // (metadata prefix, window code, window minutes, account-wide?). Display labels are + // resolved by the frontend from `code` so they follow the UI locale. + let definitions: [(&str, &str, u64, bool); 4] = [ + ("five_hour", "5h", 300, true), + ("seven_day", "weekly", 10_080, true), + ("seven_day_sonnet", "weekly_sonnet", 10_080, false), + ("seven_day_fable", "weekly_fable", 10_080, false), + ]; + let mut windows = Vec::new(); + for (prefix, code, window_minutes, account_wide) in definitions { + let used_percent = metadata + .get(&format!("{prefix}_used_percent")) + .and_then(Value::as_f64); + let reset_at = + provider_quota_timestamp_unix_secs(metadata.get(&format!("{prefix}_reset_at"))); + // A window whose reset time already passed no longer describes current usage. + let expired = reset_at + .zip(observed_at_unix_secs) + .is_some_and(|(reset_at, observed_at)| reset_at <= observed_at); + let Some(used_percent) = used_percent else { + continue; + }; + let used_ratio = if expired { + 0.0 + } else { + (used_percent / 100.0).clamp(0.0, 1.0) + }; + let reset_seconds = reset_at + .zip(observed_at_unix_secs) + .map(|(reset_at, observed_at)| reset_at.saturating_sub(observed_at)); + let mut window = json!({ + "code": code, + "scope": if account_wide { "account" } else { "model" }, + "unit": "percent", + "used_ratio": used_ratio, + "remaining_ratio": 1.0 - used_ratio, + "reset_at": reset_at, + "reset_seconds": reset_seconds, + "window_minutes": window_minutes, + "is_exhausted": used_ratio >= 1.0 - 1e-6, + }); + if !account_wide { + window["quota_group"] = json!(code); + } + windows.push(window); + } + if windows.is_empty() { + return None; + } + + let account_windows = windows + .iter() + .filter(|window| window.get("scope").and_then(Value::as_str) == Some("account")) + .cloned() + .collect::>(); + let blocking_windows = account_windows + .iter() + .filter(|window| window.get("is_exhausted").and_then(Value::as_bool) == Some(true)) + .cloned() + .collect::>(); + let exhausted = !blocking_windows.is_empty(); + // The account is usable again only once every exhausted window resets. + let reset_at = if exhausted { + blocking_windows + .iter() + .filter_map(|window| provider_quota_timestamp_unix_secs(window.get("reset_at"))) + .max() + } else { + None + }; + let reset_seconds = if exhausted { + blocking_windows + .iter() + .filter_map(|window| window.get("reset_seconds").and_then(Value::as_u64)) + .max() + } else { + None + }; + + Some(json!({ + "version": 2, + "provider_type": "claude_code", + "code": if exhausted { "exhausted" } else { "ok" }, + "freshness": "fresh", + "source": source, + "observed_at": observed_at_unix_secs, + "exhausted": exhausted, + "usage_ratio": quota_windows_usage_ratio(&account_windows), + "updated_at": observed_at_unix_secs, + "reset_at": reset_at, + "reset_seconds": reset_seconds, + "windows": windows, + })) +} + fn build_codex_reset_credits_status_snapshot( metadata: &Map, observed_at_unix_secs: Option, @@ -2423,6 +2524,7 @@ pub(crate) fn sync_provider_key_quota_status_snapshot( "antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source), "grok" => build_grok_quota_status_snapshot(upstream_metadata, source), "gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source), + "claude_code" => build_claude_code_quota_status_snapshot(upstream_metadata, source), _ => None, }?; if normalized_provider_type == "codex" { @@ -3737,6 +3839,44 @@ mod tests { assert_eq!(window.get("used_value"), Some(&json!(0.0))); } + #[test] + fn provider_key_status_snapshot_payload_backfills_claude_code_usage_windows() { + let mut key = sample_catalog_key(); + key.upstream_metadata = Some(json!({ + "claude_code": { + "updated_at": 1_800_000_000u64, + "five_hour_used_percent": 100.0, + "five_hour_reset_at": 1_800_003_600u64, + "seven_day_used_percent": 40.0, + "seven_day_reset_at": 1_800_400_000u64, + "seven_day_sonnet_used_percent": 10.0, + "seven_day_sonnet_reset_at": 1_800_400_000u64 + } + })); + + let payload = provider_key_status_snapshot_payload(&key, "claude_code"); + let quota = payload + .get("quota") + .and_then(Value::as_object) + .expect("quota snapshot should be object"); + assert_eq!(quota.get("provider_type"), Some(&json!("claude_code"))); + // An exhausted 5h window blocks the whole account until it resets. + assert_eq!(quota.get("exhausted"), Some(&json!(true))); + assert_eq!(quota.get("reset_at"), Some(&json!(1_800_003_600u64))); + let windows = quota + .get("windows") + .and_then(Value::as_array) + .expect("windows should exist"); + assert_eq!(windows.len(), 3); + assert_eq!(windows[0]["code"], json!("5h")); + assert_eq!(windows[0]["scope"], json!("account")); + assert_eq!(windows[0]["window_minutes"], json!(300)); + assert_eq!(windows[1]["code"], json!("weekly")); + assert_eq!(windows[1]["used_ratio"], json!(0.4)); + assert_eq!(windows[2]["code"], json!("weekly_sonnet")); + assert_eq!(windows[2]["scope"], json!("model")); + } + #[test] fn provider_key_status_snapshot_payload_backfills_grok_model_quota() { let mut key = sample_catalog_key(); diff --git a/apps/aether-gateway/tests/architecture/ai_serving.rs b/apps/aether-gateway/tests/architecture/ai_serving.rs index c429de37c..555a460e7 100644 --- a/apps/aether-gateway/tests/architecture/ai_serving.rs +++ b/apps/aether-gateway/tests/architecture/ai_serving.rs @@ -1453,7 +1453,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() { "KiroProviderPoolAdapter", "ChatGptWebProviderPoolAdapter", "XaiProviderPoolAdapter", - "CLAUDE_CODE_PROVIDER_POOL_ADAPTER", + "ClaudeCodeProviderPoolAdapter", "VERTEX_AI_PROVIDER_POOL_ADAPTER", "provider_types_for_capability", "supports_quota_refresh", @@ -1537,10 +1537,16 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() { "crates/aether-provider/pool/src/providers/unsupported.rs", vec![ "UnsupportedQuotaProviderPoolAdapter", - "CLAUDE_CODE_PROVIDER_POOL_ADAPTER", "VERTEX_AI_PROVIDER_POOL_ADAPTER", ], ), + ( + "crates/aether-provider/pool/src/providers/claude_code.rs", + vec![ + "ClaudeCodeProviderPoolAdapter", + "build_claude_code_pool_quota_request", + ], + ), ] { let source = read_workspace_file(path); for pattern in patterns { diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index 1ed101ad8..f515fe156 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -404,6 +404,68 @@ pub fn parse_antigravity_quota_summary_response( (!parsed_groups.is_empty()).then_some(serde_json::Value::Array(parsed_groups)) } +/// Windows reported by `GET /api/oauth/usage`, as `(response key, metadata prefix)`. +pub const CLAUDE_CODE_USAGE_WINDOWS: [(&str, &str); 4] = [ + ("five_hour", "five_hour"), + ("seven_day", "seven_day"), + ("seven_day_sonnet", "seven_day_sonnet"), + ("seven_day_overage_included", "seven_day_fable"), +]; + +/// Parses the Anthropic OAuth usage response into the `claude_code` metadata bucket. +/// +/// Each window carries `utilization` (percent, 0-100) and `resets_at` (RFC 3339). +/// Windows that are absent or `null` (e.g. plans without a Sonnet/Fable window) are +/// skipped; `None` is returned when no window is present at all. +pub fn parse_claude_code_oauth_usage_response( + value: &serde_json::Value, + updated_at_unix_secs: u64, +) -> Option { + let root = value.as_object()?; + let mut bucket = serde_json::Map::new(); + for (response_key, prefix) in CLAUDE_CODE_USAGE_WINDOWS { + let Some(window) = root + .get(response_key) + .and_then(serde_json::Value::as_object) + else { + continue; + }; + let utilization = window + .get("utilization") + .and_then(|value| match value { + serde_json::Value::Number(number) => number.as_f64(), + serde_json::Value::String(text) => text.trim().parse::().ok(), + _ => None, + }) + .filter(|value| value.is_finite()); + let reset_at = window + .get("resets_at") + .and_then(serde_json::Value::as_str) + .and_then(|text| chrono::DateTime::parse_from_rfc3339(text.trim()).ok()) + .and_then(|time| u64::try_from(time.timestamp()).ok()); + if utilization.is_none() && reset_at.is_none() { + continue; + } + if let Some(utilization) = utilization { + bucket.insert( + format!("{prefix}_used_percent"), + serde_json::json!(utilization.clamp(0.0, 100.0)), + ); + } + if let Some(reset_at) = reset_at { + bucket.insert(format!("{prefix}_reset_at"), serde_json::json!(reset_at)); + } + } + if bucket.is_empty() { + return None; + } + bucket.insert( + "updated_at".to_string(), + serde_json::json!(updated_at_unix_secs), + ); + Some(serde_json::Value::Object(bucket)) +} + pub fn parse_gemini_cli_retrieve_user_quota_response( value: &serde_json::Value, updated_at_unix_secs: u64, @@ -7704,3 +7766,37 @@ mod tests { assert!(!serialized.contains("user:password")); } } + +#[cfg(test)] +mod claude_code_quota_tests { + use super::parse_claude_code_oauth_usage_response; + use serde_json::json; + + #[test] + fn parses_windows_and_skips_null_ones() { + let parsed = parse_claude_code_oauth_usage_response( + &json!({ + "five_hour": {"utilization": 37.5, "resets_at": "2027-01-15T08:00:00.000000+00:00"}, + "seven_day": {"utilization": 12, "resets_at": "2027-01-20T00:00:00Z"}, + "seven_day_sonnet": null, + "seven_day_overage_included": {"utilization": 3.0, "resets_at": null} + }), + 1_800_000_000, + ) + .expect("usage windows should parse"); + + assert_eq!(parsed["updated_at"], json!(1_800_000_000u64)); + assert_eq!(parsed["five_hour_used_percent"], json!(37.5)); + assert_eq!(parsed["five_hour_reset_at"], json!(1_800_000_000u64)); + assert_eq!(parsed["seven_day_used_percent"], json!(12.0)); + assert!(parsed.get("seven_day_sonnet_used_percent").is_none()); + assert_eq!(parsed["seven_day_fable_used_percent"], json!(3.0)); + assert!(parsed.get("seven_day_fable_reset_at").is_none()); + } + + #[test] + fn returns_none_without_any_window() { + assert!(parse_claude_code_oauth_usage_response(&json!({}), 1).is_none()); + assert!(parse_claude_code_oauth_usage_response(&json!({"five_hour": null}), 1).is_none()); + } +} diff --git a/crates/aether-provider/pool/src/lib.rs b/crates/aether-provider/pool/src/lib.rs index 088987fff..64c6e7048 100644 --- a/crates/aether-provider/pool/src/lib.rs +++ b/crates/aether-provider/pool/src/lib.rs @@ -16,26 +16,26 @@ pub use presets::{ pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput}; pub use providers::{ build_antigravity_pool_quota_request, build_antigravity_pool_quota_summary_request, - build_chatgpt_web_pool_quota_request, build_codex_pool_quota_request, - build_codex_pool_reset_credit_consume_request, build_codex_pool_reset_credits_request, - build_gemini_cli_pool_quota_request, build_kiro_pool_quota_request, - build_windsurf_pool_model_configs_request, + build_chatgpt_web_pool_quota_request, build_claude_code_pool_quota_request, + build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request, + build_codex_pool_reset_credits_request, build_gemini_cli_pool_quota_request, + build_kiro_pool_quota_request, build_windsurf_pool_model_configs_request, build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request, build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request, build_windsurf_pool_rate_limit_request_with_base_url, build_xai_pool_billing_request, build_xai_pool_user_request, enrich_chatgpt_web_quota_metadata, grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model, grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit, - AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter, - DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter, - KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter, - XaiProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, - ANTIGRAVITY_RETRIEVE_USER_QUOTA_SUMMARY_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH, - CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_RESET_CREDITS_CONSUME_URL, - CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH, - GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION, - WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH, - XAI_BILLING_PATH, XAI_USER_PATH, + AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, ClaudeCodeProviderPoolAdapter, + CodexProviderPoolAdapter, DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, + GrokProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, + UnsupportedQuotaProviderPoolAdapter, XaiProviderPoolAdapter, + ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, ANTIGRAVITY_RETRIEVE_USER_QUOTA_SUMMARY_PATH, + CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, + CODEX_WHAM_RESET_CREDITS_CONSUME_URL, CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, + GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH, GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, + KIRO_USAGE_SDK_VERSION, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, + WINDSURF_USER_STATUS_PATH, XAI_BILLING_PATH, XAI_USER_PATH, }; pub use quota::{ provider_pool_codex_metadata_has_account_quota, provider_pool_key_account_quota_exhausted, @@ -104,6 +104,7 @@ mod tests { [ "antigravity", "chatgpt_web", + "claude_code", "codex", "gemini_cli", "grok", @@ -112,15 +113,25 @@ mod tests { "xai" ] ); + assert!(service.supports_quota_refresh("claude_code")); assert!(service.supports_quota_refresh("codex")); assert!(service.supports_quota_refresh("antigravity")); assert!(service.supports_quota_refresh("grok")); assert!(service.supports_quota_refresh("gemini_cli")); assert!(service.supports_quota_refresh("windsurf")); assert!(service.supports_quota_refresh("xai")); + let claude_spec = build_claude_code_pool_quota_request( + "key-1", + ("authorization".to_string(), "Bearer access".to_string()), + ); + assert_eq!(claude_spec.method, "GET"); + assert_eq!(claude_spec.url, "https://api.anthropic.com/api/oauth/usage"); assert_eq!( - service.quota_refresh_unsupported_message("claude_code"), - "Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口" + claude_spec + .headers + .get("anthropic-beta") + .map(String::as_str), + Some("oauth-2025-04-20") ); assert_eq!( service.quota_refresh_unsupported_message("vertex_ai"), diff --git a/crates/aether-provider/pool/src/providers/claude_code.rs b/crates/aether-provider/pool/src/providers/claude_code.rs new file mode 100644 index 000000000..84f1e92f5 --- /dev/null +++ b/crates/aether-provider/pool/src/providers/claude_code.rs @@ -0,0 +1,79 @@ +use std::collections::BTreeMap; + +use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint; + +use crate::capability::ProviderPoolCapabilities; +use crate::provider::{ + provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter, +}; +use crate::quota_refresh::ProviderPoolQuotaRequestSpec; + +pub const CLAUDE_CODE_OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +pub const CLAUDE_CODE_OAUTH_BETA: &str = "oauth-2025-04-20"; +pub const CLAUDE_CODE_USAGE_USER_AGENT: &str = "claude-code/2.1.284"; + +#[derive(Debug, Clone, Default)] +pub struct ClaudeCodeProviderPoolAdapter; + +impl ProviderPoolAdapter for ClaudeCodeProviderPoolAdapter { + fn provider_type(&self) -> &'static str { + "claude_code" + } + + fn capabilities(&self) -> ProviderPoolCapabilities { + ProviderPoolCapabilities { + quota_refresh: true, + ..ProviderPoolCapabilities::default() + } + } + + fn quota_refresh_endpoint( + &self, + endpoints: &[StoredProviderCatalogEndpoint], + include_inactive: bool, + ) -> Option { + provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| { + provider_pool_endpoint_format_matches(endpoint, "claude:messages") + }) + } + + fn quota_refresh_missing_endpoint_message(&self) -> String { + "找不到有效的 claude:messages 端点".to_string() + } +} + +/// Builds the `GET /api/oauth/usage` request that reports the account's 5h / 7d +/// utilization windows. The URL is fixed (the origin allowlist only accepts +/// `api.anthropic.com`), independent of the inference endpoint's base URL. +pub fn build_claude_code_pool_quota_request( + key_id: &str, + authorization: (String, String), +) -> ProviderPoolQuotaRequestSpec { + let headers = BTreeMap::from([ + ("authorization".to_string(), authorization.1), + ("accept".to_string(), "application/json".to_string()), + ("content-type".to_string(), "application/json".to_string()), + ( + "anthropic-beta".to_string(), + CLAUDE_CODE_OAUTH_BETA.to_string(), + ), + ( + "user-agent".to_string(), + CLAUDE_CODE_USAGE_USER_AGENT.to_string(), + ), + ]); + + ProviderPoolQuotaRequestSpec { + request_id: format!("claude-code-quota:{key_id}"), + provider_name: "claude_code".to_string(), + quota_kind: "claude_code".to_string(), + method: "GET".to_string(), + url: CLAUDE_CODE_OAUTH_USAGE_URL.to_string(), + headers, + content_type: None, + json_body: None, + client_api_format: "claude:messages".to_string(), + provider_api_format: "claude_code:oauth_usage".to_string(), + model_name: Some("oauth_usage".to_string()), + } +} diff --git a/crates/aether-provider/pool/src/providers/mod.rs b/crates/aether-provider/pool/src/providers/mod.rs index 9260caaf9..fe166a912 100644 --- a/crates/aether-provider/pool/src/providers/mod.rs +++ b/crates/aether-provider/pool/src/providers/mod.rs @@ -1,5 +1,6 @@ pub mod antigravity; pub mod chatgpt_web; +pub mod claude_code; pub mod codex; pub mod default; pub mod gemini_cli; @@ -20,6 +21,11 @@ pub use chatgpt_web::{ normalize_chatgpt_web_image_quota_limit, CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, }; +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::{ build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request, @@ -40,10 +46,7 @@ pub use kiro::{ build_kiro_pool_quota_request, KiroPoolQuotaAuthInput, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION, }; -pub use unsupported::{ - UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER, - VERTEX_AI_PROVIDER_POOL_ADAPTER, -}; +pub use unsupported::{UnsupportedQuotaProviderPoolAdapter, VERTEX_AI_PROVIDER_POOL_ADAPTER}; pub use windsurf::{ build_windsurf_pool_model_configs_request, build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request, diff --git a/crates/aether-provider/pool/src/providers/unsupported.rs b/crates/aether-provider/pool/src/providers/unsupported.rs index 0a9dbaf1f..5b7fabb05 100644 --- a/crates/aether-provider/pool/src/providers/unsupported.rs +++ b/crates/aether-provider/pool/src/providers/unsupported.rs @@ -28,12 +28,6 @@ impl ProviderPoolAdapter for UnsupportedQuotaProviderPoolAdapter { } } -pub const CLAUDE_CODE_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter = - UnsupportedQuotaProviderPoolAdapter::new( - "claude_code", - "Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口", - ); - pub const VERTEX_AI_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter = UnsupportedQuotaProviderPoolAdapter::new( "vertex_ai", diff --git a/crates/aether-provider/pool/src/service.rs b/crates/aether-provider/pool/src/service.rs index a0668c817..f86d68a13 100644 --- a/crates/aether-provider/pool/src/service.rs +++ b/crates/aether-provider/pool/src/service.rs @@ -11,10 +11,10 @@ use crate::capability::ProviderPoolCapability; use crate::presets::normalize_provider_scheduling_presets; use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput}; use crate::providers::{ - AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter, - DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter, - KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, XaiProviderPoolAdapter, - CLAUDE_CODE_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER, + AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, ClaudeCodeProviderPoolAdapter, + CodexProviderPoolAdapter, DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, + GrokProviderPoolAdapter, KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, + XaiProviderPoolAdapter, VERTEX_AI_PROVIDER_POOL_ADAPTER, }; #[derive(Clone)] @@ -48,7 +48,7 @@ impl ProviderPoolService { pub fn with_builtin_adapters() -> Self { Self::new() .with_adapter(Arc::new(AntigravityProviderPoolAdapter)) - .with_adapter(Arc::new(CLAUDE_CODE_PROVIDER_POOL_ADAPTER)) + .with_adapter(Arc::new(ClaudeCodeProviderPoolAdapter)) .with_adapter(Arc::new(CodexProviderPoolAdapter)) .with_adapter(Arc::new(GeminiCliProviderPoolAdapter)) .with_adapter(Arc::new(GrokProviderPoolAdapter)) diff --git a/frontend/src/i18n/legacy-admin-messages.ts b/frontend/src/i18n/legacy-admin-messages.ts index c81f44423..4b7cdff04 100644 --- a/frontend/src/i18n/legacy-admin-messages.ts +++ b/frontend/src/i18n/legacy-admin-messages.ts @@ -1,4 +1,10 @@ export const legacyAdminEnglishMessages: Record = { + '响应中未包含配额信息': 'The response contains no quota information', + '响应中未包含额度窗口': 'The response contains no quota windows', + 'oauth/usage 请求执行失败': 'oauth/usage request failed', + 'oauth/usage 返回 401,Token 可能已失效,请刷新 Token': 'oauth/usage returned 401. The token may have expired; please refresh it', + 'oauth/usage 返回 403,该账号缺少 user:profile 权限(如 Setup Token),无法查询额度': 'oauth/usage returned 403. This account lacks the user:profile scope (e.g. a Setup Token), so quota cannot be queried', + 'oauth/usage 被限流,请稍后重试': 'oauth/usage was rate limited; please retry later', '配置额度、流量限制、会员权益和组合套餐': 'Configure credit, usage limits, membership benefits, and bundled plans', '新建套餐': 'Create plan', '套餐列表': 'Plans', diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index bffeb1737..657da96d5 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -6,6 +6,11 @@ import { legacyGuideEnglishMessages } from './legacy-guide-messages' export const messages = { 'zh-CN': { 'common.loading': '加载中...', + 'poolQuota.claudeCode.window5h': '5H', + 'poolQuota.claudeCode.weekly': '周', + 'poolQuota.claudeCode.weeklySonnet': '周 Sonnet', + 'poolQuota.claudeCode.weeklyFable': '周 Fable', + 'poolQuota.claudeCode.unknownWindow': '额度', 'common.confirmRead': '确认已读', 'common.confirming': '确认中...', 'common.copy': '复制', @@ -491,6 +496,11 @@ export const messages = { }, 'en-US': { 'common.loading': 'Loading...', + 'poolQuota.claudeCode.window5h': '5H', + 'poolQuota.claudeCode.weekly': 'Weekly', + 'poolQuota.claudeCode.weeklySonnet': 'Weekly Sonnet', + 'poolQuota.claudeCode.weeklyFable': 'Weekly Fable', + 'poolQuota.claudeCode.unknownWindow': 'Quota', 'common.confirmRead': 'Confirm read', 'common.confirming': 'Confirming...', 'common.copy': 'Copy', @@ -3066,6 +3076,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string] [/^(1[0-2]|[1-9])月$/u, match => new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2020, Number(match[1]) - 1, 1)))], [/^总可用:(.+)$/u, match => `Total available: ${match[1]}`], [/^余额:(.+)$/u, match => `Balance: ${match[1]}`], + [/^oauth\/usage 返回状态码 (\d+)$/u, match => `oauth/usage returned status ${match[1]}`], [/^(.+) 秒$/u, match => `${translateLegacyText(match[1], 'en-US')}s`], [/^(.+) 分钟$/u, match => `${translateLegacyText(match[1], 'en-US')} min`], [/^(.+) 小时$/u, match => `${translateLegacyText(match[1], 'en-US')} h`], diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue index 17a8bf2b4..5a063db2a 100644 --- a/frontend/src/views/admin/PoolManagement.vue +++ b/frontend/src/views/admin/PoolManagement.vue @@ -1071,6 +1071,7 @@ import { useConfirm } from '@/composables/useConfirm' import { useRouteQuery } from '@/composables/useRouteQuery' import { useBatchSelection } from '@/composables/useBatchSelection' import { useI18n } from '@/i18n' +import type { MessageKey } from '@/i18n/messages' import { parseApiError } from '@/utils/errorParser' import { getPoolOverview, @@ -1696,6 +1697,7 @@ watch(showAdaptiveHotPoolMetricsButton, (enabled) => { const showAccountQuotaColumn = computed(() => { return selectedProviderType.value === 'codex' + || selectedProviderType.value === 'claude_code' || selectedProviderType.value === 'gemini_cli' || selectedProviderType.value === 'kiro' || selectedProviderType.value === 'windsurf' @@ -2146,6 +2148,7 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] { const quotaRefreshSupported = computed(() => { return selectedProviderType.value === 'codex' + || selectedProviderType.value === 'claude_code' || selectedProviderType.value === 'kiro' || selectedProviderType.value === 'gemini_cli' || selectedProviderType.value === 'windsurf' @@ -3902,6 +3905,33 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI .filter((item): item is QuotaProgressItem => item != null) } + if (providerType === 'claude_code') { + const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota) + const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota) + const windowPresentations: Record = { + '5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 }, + weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 }, + weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 }, + weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 }, + } + return (quota.windows ?? []) + .map((window): QuotaProgressItem | null => { + const remainingPercent = getQuotaWindowRemainingPercent(window) + if (remainingPercent == null) return null + const presentation = windowPresentations[String(window.code || '')] + return { + label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'), + sortOrder: presentation?.sortOrder ?? 9, + remainingPercent, + resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null), + resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null), + updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota), + allowDynamicReset: true, + } + }) + .filter((item): item is QuotaProgressItem => item != null) + } + if (providerType === 'gemini_cli') { const windows = getQuotaSnapshotWindowsByScope(quota, 'model') if (windows.length === 0) return [] From 491364867edb970b345f6f6bc4b25499f8933d88 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 22:36:46 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(claude-code):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=95=86=E8=AF=A6=E6=83=85=E6=8A=BD=E5=B1=89=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=205H/=E5=91=A8=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/endpoints/types/provider.ts | 13 ++ .../components/ProviderDetailDrawer.vue | 128 +++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index fb2d27970..46abebf12 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -529,8 +529,21 @@ export interface GeminiCliUpstreamMetadata { quota_by_model?: Record | null } +export interface ClaudeCodeUpstreamMetadata { + updated_at?: number + five_hour_used_percent?: number + five_hour_reset_at?: number + seven_day_used_percent?: number + seven_day_reset_at?: number + seven_day_sonnet_used_percent?: number + seven_day_sonnet_reset_at?: number + seven_day_fable_used_percent?: number + seven_day_fable_reset_at?: number +} + export interface UpstreamMetadata { codex?: CodexUpstreamMetadata + claude_code?: ClaudeCodeUpstreamMetadata antigravity?: AntigravityUpstreamMetadata kiro?: KiroUpstreamMetadata windsurf?: WindsurfUpstreamMetadata diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 43967910e..851779a53 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -406,6 +406,42 @@ + +
+ +
+ + + +
+
AUTO_QUOTA_REFRESH_STALE_SECONDS) { + return true + } + } + + return false +} + function shouldAutoRefreshKiroQuota(): boolean { if (provider.value?.provider_type !== 'kiro') return false const now = Math.floor(Date.now() / 1000) @@ -2990,12 +3045,14 @@ async function autoRefreshQuotaInBackground(): Promise { if (refreshingQuota.value) return false const providerType = provider.value?.provider_type - if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok' && providerType !== 'xai') return false + if (providerType !== 'codex' && providerType !== 'claude_code' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok' && providerType !== 'xai') return false // 检查是否需要刷新 let shouldRefresh = false if (providerType === 'codex') { shouldRefresh = shouldAutoRefreshCodexQuota() + } else if (providerType === 'claude_code') { + shouldRefresh = shouldAutoRefreshClaudeCodeQuota() } else if (providerType === 'gemini_cli') { shouldRefresh = shouldAutoRefreshGeminiCliQuota() } else if (providerType === 'antigravity') { @@ -3016,6 +3073,8 @@ async function autoRefreshQuotaInBackground(): Promise { let hadCachedQuota = false if (providerType === 'codex') { hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key)) + } else if (providerType === 'claude_code') { + hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasClaudeCodeQuotaDisplayData(key)) } else if (providerType === 'gemini_cli') { hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGeminiCliQuotaDisplayData(key)) } else if (providerType === 'antigravity') { @@ -3564,6 +3623,71 @@ interface AntigravityQuotaItem { detail?: string } +interface ClaudeCodeQuotaItem { + code: string + label: string + usedPercent: number + remainingPercent: number + resetSeconds: number | null +} + +const CLAUDE_CODE_QUOTA_WINDOWS: Record = { + '5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 }, + weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 }, + weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 }, + weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 }, +} + +function getClaudeCodeQuotaUpdatedAt(key: EndpointAPIKey): number | undefined { + const quota = getQuotaSnapshotForProvider(key, 'claude_code') + const quotaUpdatedAt = getQuotaSnapshotUpdatedAt(quota) + if (typeof quotaUpdatedAt === 'number') return quotaUpdatedAt + const updatedAt = Number(key.upstream_metadata?.claude_code?.updated_at ?? NaN) + return Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : undefined +} + +function hasClaudeCodeQuotaDisplayData(key: EndpointAPIKey): boolean { + return getClaudeCodeQuotaItems(key).length > 0 +} + +function getClaudeCodeQuotaItems(key: EndpointAPIKey): ClaudeCodeQuotaItem[] { + const quota = getQuotaSnapshotForProvider(key, 'claude_code') + const windows = quota?.windows + if (!quota || !Array.isArray(windows)) return [] + + return windows + .map((window) => { + const code = String(window?.code || '').trim().toLowerCase() + if (!code) return null + + const usedPercent = getQuotaWindowUsedPercent(window) + const remainingPercent = getQuotaWindowRemainingPercent(window) + if (usedPercent === undefined && remainingPercent === undefined) return null + + const normalizedUsedPercent = usedPercent !== undefined + ? usedPercent + : Math.max(100 - (remainingPercent ?? 0), 0) + const normalizedRemainingPercent = remainingPercent !== undefined + ? remainingPercent + : Math.max(100 - normalizedUsedPercent, 0) + const presentation = CLAUDE_CODE_QUOTA_WINDOWS[code] + + return { + item: { + code, + label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'), + usedPercent: normalizedUsedPercent, + remainingPercent: normalizedRemainingPercent, + resetSeconds: getQuotaWindowLiveResetSeconds(quota, window), + } satisfies ClaudeCodeQuotaItem, + sortOrder: presentation?.sortOrder ?? 9, + } + }) + .filter((entry): entry is { item: ClaudeCodeQuotaItem, sortOrder: number } => entry !== null) + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(entry => entry.item) +} + interface GeminiCliQuotaItem { model: string label: string From 125cd40aa5e15d057b529fee0dc33b4293c9cd04 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 22:57:39 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(claude-code):=20=E8=A2=AB=E5=8A=A8?= =?UTF-8?q?=E9=87=87=E6=A0=B7=20anthropic-ratelimit-unified=20=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=A4=B4=E6=9B=B4=E6=96=B0=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/orchestration/report_effects.rs | 164 ++++++++++++++++++ crates/aether-admin/src/provider/quota.rs | 106 ++++++++++- 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/apps/aether-gateway/src/orchestration/report_effects.rs b/apps/aether-gateway/src/orchestration/report_effects.rs index 7e28a9e78..b86414637 100644 --- a/apps/aether-gateway/src/orchestration/report_effects.rs +++ b/apps/aether-gateway/src/orchestration/report_effects.rs @@ -551,6 +551,24 @@ async fn sync_grok_quota_from_report_context( async fn apply_local_sync_report_effect(state: &AppState, payload: &GatewaySyncReportRequest) { apply_local_gemini_file_mapping_report_effect(state, payload).await; + if claude_code_quota_headers_reportable(payload.status_code) { + if let Err(err) = sync_claude_code_quota_from_response_headers( + state, + payload.report_context.as_ref(), + &payload.headers, + ) + .await + { + warn!( + event_name = "claude_code_realtime_quota_sync_failed", + log_type = "ops", + report_kind = %payload.report_kind, + report_request_id = %short_request_id(report_request_id(payload.report_context.as_ref())), + error = ?err, + "gateway failed to persist claude_code realtime quota from sync response headers" + ); + } + } if (200..300).contains(&payload.status_code) { if let Err(err) = sync_codex_quota_from_response_headers( state, @@ -641,6 +659,24 @@ async fn apply_local_stream_report_effect(state: &AppState, payload: &GatewayStr ); } } + if claude_code_quota_headers_reportable(payload.status_code) { + if let Err(err) = sync_claude_code_quota_from_response_headers( + state, + payload.report_context.as_ref(), + &payload.headers, + ) + .await + { + warn!( + event_name = "claude_code_realtime_quota_sync_failed", + log_type = "ops", + report_kind = %payload.report_kind, + report_request_id = %short_request_id(report_request_id(payload.report_context.as_ref())), + error = ?err, + "gateway failed to persist claude_code realtime quota from stream response headers" + ); + } + } if let Err(err) = sync_grok_quota_from_report_context( state, payload.report_context.as_ref(), @@ -896,6 +932,134 @@ async fn sync_codex_quota_from_response_headers( .await } +fn claude_code_quota_headers_reportable(status_code: u16) -> bool { + // Real limit 429s carry the unified headers (fingerprint-rejection 429s do not, and then + // the parser finds nothing to record). + (200..300).contains(&status_code) || status_code == 429 +} + +/// Passive sampling of Anthropic's `anthropic-ratelimit-unified-*` response headers into the +/// `claude_code` quota metadata, so the 5H / weekly windows stay fresh between active refreshes. +async fn sync_claude_code_quota_from_response_headers( + state: &AppState, + report_context: Option<&Value>, + headers: &BTreeMap, +) -> Result { + let observed_at_unix_secs = report_context_u64( + report_context, + "provider_response_headers_observed_at_unix_ms", + ) + .map(|value| value / 1_000) + .filter(|value| *value > 0) + .unwrap_or_else(current_unix_secs); + let parsed = report_context_provider_response_headers(report_context) + .and_then(|headers| { + admin_provider_quota_pure::parse_claude_code_usage_headers( + &headers, + observed_at_unix_secs, + ) + }) + .or_else(|| { + admin_provider_quota_pure::parse_claude_code_usage_headers( + headers, + observed_at_unix_secs, + ) + }); + let Some(parsed) = parsed else { + return Ok(false); + }; + let Some(key_id) = report_context_key_id(report_context) else { + return Ok(false); + }; + + for attempt in 0..RUNTIME_METADATA_CAS_MAX_ATTEMPTS { + let Some(key) = state + .read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id)) + .await? + .into_iter() + .next() + else { + return Ok(false); + }; + let Some(provider) = state + .read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id)) + .await? + .into_iter() + .next() + else { + return Ok(false); + }; + if !provider + .provider_type + .trim() + .eq_ignore_ascii_case("claude_code") + { + return Ok(false); + } + + let expected_namespace_value = + upstream_metadata_namespace_value(key.upstream_metadata.as_ref(), "claude_code"); + let mut bucket = expected_namespace_value + .as_ref() + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + // Never let an older observation overwrite a newer active refresh. + if bucket + .get("updated_at") + .and_then(admin_provider_quota_pure::coerce_json_u64) + .is_some_and(|stored| stored > observed_at_unix_secs) + { + return Ok(false); + } + let Some(patch) = parsed.as_object() else { + return Ok(false); + }; + // Headers can describe only some windows; absent windows keep their stored value. + for (field, value) in patch { + bucket.insert(field.clone(), value.clone()); + } + let next_bucket = Value::Object(bucket); + if expected_namespace_value.as_ref() == Some(&next_bucket) { + return Ok(false); + } + + let updated_upstream_metadata = merge_metadata_object( + key.upstream_metadata.as_ref(), + "claude_code", + next_bucket.clone(), + ); + let updated_status_snapshot = sync_provider_key_quota_status_snapshot( + key.status_snapshot.as_ref(), + provider.provider_type.as_str(), + updated_upstream_metadata.as_ref(), + "response_headers", + ); + let updated = state + .update_provider_catalog_key_runtime_metadata( + &ProviderCatalogKeyRuntimeMetadataUpdate { + key_id: key_id.clone(), + namespace: "claude_code".to_string(), + expected_upstream_metadata_value: expected_namespace_value, + upstream_metadata_value: next_bucket, + status_snapshot_patch: quota_status_snapshot_patch( + updated_status_snapshot.as_ref(), + ), + updated_at_unix_secs: Some(observed_at_unix_secs), + }, + ) + .await?; + if updated { + return Ok(true); + } + if attempt + 1 < RUNTIME_METADATA_CAS_MAX_ATTEMPTS { + let backoff_us = 50_u64.saturating_mul((attempt + 1) as u64).min(1_000); + tokio::time::sleep(Duration::from_micros(backoff_us)).await; + } + } + Ok(false) +} + async fn sync_codex_websocket_quota_from_stream_payload( state: &AppState, payload: &GatewayStreamReportRequest, diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index f515fe156..43d0c65f6 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -466,6 +466,65 @@ pub fn parse_claude_code_oauth_usage_response( Some(serde_json::Value::Object(bucket)) } +/// Parses the `anthropic-ratelimit-unified-*` response headers into a partial `claude_code` +/// metadata bucket (same field names as [`parse_claude_code_oauth_usage_response`]). +/// +/// Utilization headers are 0-1 fractions and are stored as percent; reset headers are Unix +/// seconds (millisecond values are normalized). Returns `None` when no window is reported. +pub fn parse_claude_code_usage_headers( + headers: &BTreeMap, + updated_at_unix_secs: u64, +) -> Option { + let normalized = headers + .iter() + .map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect::>(); + let mut bucket = serde_json::Map::new(); + for (header_window, prefix) in [ + ("5h", "five_hour"), + ("7d", "seven_day"), + ("7d_oi", "seven_day_fable"), + ] { + let header = |suffix: &str| { + normalized + .get(&format!( + "anthropic-ratelimit-unified-{header_window}-{suffix}" + )) + .map(String::as_str) + }; + if let Some(utilization) = header("utilization") + .and_then(|value| value.parse::().ok()) + .filter(|value| value.is_finite()) + { + bucket.insert( + format!("{prefix}_used_percent"), + serde_json::json!((utilization * 100.0).clamp(0.0, 100.0)), + ); + } + if let Some(reset_at) = header("reset") + .and_then(|value| value.parse::().ok()) + .map(|value| { + if value > 100_000_000_000 { + value / 1_000 + } else { + value + } + }) + .filter(|value| *value > 0) + { + bucket.insert(format!("{prefix}_reset_at"), serde_json::json!(reset_at)); + } + } + if bucket.is_empty() { + return None; + } + bucket.insert( + "updated_at".to_string(), + serde_json::json!(updated_at_unix_secs), + ); + Some(serde_json::Value::Object(bucket)) +} + pub fn parse_gemini_cli_retrieve_user_quota_response( value: &serde_json::Value, updated_at_unix_secs: u64, @@ -7769,8 +7828,53 @@ mod tests { #[cfg(test)] mod claude_code_quota_tests { - use super::parse_claude_code_oauth_usage_response; + use super::{parse_claude_code_oauth_usage_response, parse_claude_code_usage_headers}; use serde_json::json; + use std::collections::BTreeMap; + + #[test] + fn parses_unified_ratelimit_headers_into_percent_and_reset() { + let headers = BTreeMap::from([ + ( + "Anthropic-Ratelimit-Unified-5h-Utilization".to_string(), + "0.42".to_string(), + ), + ( + "anthropic-ratelimit-unified-5h-reset".to_string(), + "1800003600".to_string(), + ), + ( + "anthropic-ratelimit-unified-7d-utilization".to_string(), + "1.0".to_string(), + ), + ( + "anthropic-ratelimit-unified-7d-reset".to_string(), + "1800400000000".to_string(), + ), + ( + "anthropic-ratelimit-unified-7d_oi-utilization".to_string(), + "0.1".to_string(), + ), + ]); + let parsed = + parse_claude_code_usage_headers(&headers, 1_800_000_000).expect("headers should parse"); + + assert_eq!(parsed["five_hour_used_percent"], json!(42.0)); + assert_eq!(parsed["five_hour_reset_at"], json!(1_800_003_600u64)); + assert_eq!(parsed["seven_day_used_percent"], json!(100.0)); + // Millisecond timestamps are normalized to seconds. + assert_eq!(parsed["seven_day_reset_at"], json!(1_800_400_000u64)); + assert_eq!(parsed["seven_day_fable_used_percent"], json!(10.0)); + assert!(parsed.get("seven_day_fable_reset_at").is_none()); + assert_eq!(parsed["updated_at"], json!(1_800_000_000u64)); + } + + #[test] + fn unified_ratelimit_headers_absent_yield_none() { + let headers = + BTreeMap::from([("content-type".to_string(), "application/json".to_string())]); + assert!(parse_claude_code_usage_headers(&headers, 1).is_none()); + } #[test] fn parses_windows_and_skips_null_ones() { From b49f5c0fd7246848d3699eb1da25faddc5d85ce9 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 23:55:27 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(claude-code):=20=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E9=87=8D=E7=BD=AE=E6=9C=BA=E4=BC=9A=EF=BC=88?= =?UTF-8?q?cedar=5Fember=EF=BC=89=E5=B9=B6=E8=A1=A5=E9=BD=90=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E6=96=87=E6=A1=88=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/handlers/shared/catalog.rs | 21 +++- crates/aether-admin/src/provider/quota.rs | 112 ++++++++++++++++++ crates/aether-provider/pool/src/lib.rs | 5 +- .../pool/src/providers/claude_code.rs | 5 +- frontend/src/api/endpoints/types/provider.ts | 1 + .../components/ProviderDetailDrawer.vue | 46 +++++++ .../components/codex-reset-credit-display.ts | 3 +- frontend/src/i18n/legacy-admin-messages.ts | 3 + frontend/src/i18n/legacy-ui-messages.ts | 1 + frontend/src/i18n/messages.ts | 2 + frontend/src/views/admin/PoolManagement.vue | 18 ++- 11 files changed, 211 insertions(+), 6 deletions(-) diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index c9038926e..6a1ff5a74 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -2391,6 +2391,10 @@ fn build_claude_code_quota_status_snapshot( "updated_at": observed_at_unix_secs, "reset_at": reset_at, "reset_seconds": reset_seconds, + "reset_credits": build_codex_reset_credits_status_snapshot( + &metadata, + observed_at_unix_secs, + ), "windows": windows, })) } @@ -3850,7 +3854,17 @@ mod tests { "seven_day_used_percent": 40.0, "seven_day_reset_at": 1_800_400_000u64, "seven_day_sonnet_used_percent": 10.0, - "seven_day_sonnet_reset_at": 1_800_400_000u64 + "seven_day_sonnet_reset_at": 1_800_400_000u64, + "reset_credits": { + "available_count": 2, + "updated_at": 1_800_000_000u64, + "detail_source": "claude_oauth_usage", + "credits": [{ + "display_key": "Key-1", + "status": "available", + "expires_at": 1_800_144_000u64 + }] + } } })); @@ -3875,6 +3889,11 @@ mod tests { assert_eq!(windows[1]["used_ratio"], json!(0.4)); assert_eq!(windows[2]["code"], json!("weekly_sonnet")); assert_eq!(windows[2]["scope"], json!("model")); + assert_eq!(quota["reset_credits"]["available_count"], json!(2)); + assert_eq!( + quota["reset_credits"]["credits"][0]["remaining_seconds"], + json!(144_000u64) + ); } #[test] diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index 43d0c65f6..3778bdccd 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -456,6 +456,9 @@ pub fn parse_claude_code_oauth_usage_response( bucket.insert(format!("{prefix}_reset_at"), serde_json::json!(reset_at)); } } + if let Some(reset_credits) = parse_claude_code_reset_credits(root, updated_at_unix_secs) { + bucket.insert("reset_credits".to_string(), reset_credits); + } if bucket.is_empty() { return None; } @@ -466,6 +469,73 @@ pub fn parse_claude_code_oauth_usage_response( Some(serde_json::Value::Object(bucket)) } +/// Projects the `cedar_ember` block (returned with `?cedar_ember=1`) into the same +/// `reset_credits` shape codex uses. Upstream grant/organization ids are never copied; each +/// usable grant becomes one entry, and `available_count` sums their remaining resets. +fn parse_claude_code_reset_credits( + root: &serde_json::Map, + now_unix_secs: u64, +) -> Option { + let grants = root + .get("cedar_ember") + .and_then(serde_json::Value::as_object)? + .get("grants") + .and_then(serde_json::Value::as_array)?; + let parse_time = |grant: &serde_json::Map, field: &str| { + grant + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|text| chrono::DateTime::parse_from_rfc3339(text.trim()).ok()) + .and_then(|time| u64::try_from(time.timestamp()).ok()) + }; + let mut available_count = 0u64; + let mut credits = Vec::new(); + for grant in grants.iter().filter_map(serde_json::Value::as_object) { + let resets_left = grant + .get("resets_left") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let has_clears = grant + .get("clears") + .and_then(serde_json::Value::as_array) + .is_some_and(|clears| !clears.is_empty()); + let paused = grant + .get("paused") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let starts_at = parse_time(grant, "starts_at"); + let expires_at = parse_time(grant, "ends_at"); + if resets_left == 0 + || !has_clears + || paused + || starts_at.is_some_and(|value| value > now_unix_secs) + || expires_at.is_some_and(|value| value <= now_unix_secs) + { + continue; + } + available_count += resets_left; + let Some(expires_at) = expires_at else { + continue; + }; + credits.push(serde_json::json!({ + "display_key": format!("Key-{}", credits.len() + 1), + "status": "available", + "expires_at": expires_at, + "remaining_seconds": expires_at - now_unix_secs, + })); + } + if available_count == 0 { + return None; + } + Some(serde_json::json!({ + "available_count": available_count, + "updated_at": now_unix_secs, + "detail_source": "claude_oauth_usage", + "detail_status": "ok", + "credits": credits, + })) +} + /// Parses the `anthropic-ratelimit-unified-*` response headers into a partial `claude_code` /// metadata bucket (same field names as [`parse_claude_code_oauth_usage_response`]). /// @@ -7832,6 +7902,48 @@ mod claude_code_quota_tests { use serde_json::json; use std::collections::BTreeMap; + #[test] + fn parses_cedar_ember_grants_into_reset_credits() { + let parsed = parse_claude_code_oauth_usage_response( + &json!({ + "five_hour": {"utilization": 65.0, "resets_at": "2027-01-15T08:00:00Z"}, + "cedar_ember": { + "eligible": true, + "grants": [ + {"id": "launch", "resets_left": 2, "clears": ["five_hour"], + "ends_at": "2027-01-20T00:00:00Z"}, + {"id": "later", "resets_left": 1, "clears": ["five_hour"], + "starts_at": "2027-02-01T00:00:00Z"}, + {"id": "paused", "resets_left": 1, "clears": ["five_hour"], "paused": true}, + {"id": "spent", "resets_left": 0, "clears": ["five_hour"]}, + {"id": "expired", "resets_left": 1, "clears": ["five_hour"], + "ends_at": "2027-01-01T00:00:00Z"} + ] + } + }), + 1_800_000_000, + ) + .expect("usage should parse"); + + let credits = &parsed["reset_credits"]; + assert_eq!(credits["available_count"], json!(2)); + assert_eq!(credits["credits"].as_array().map(Vec::len), Some(1)); + assert_eq!(credits["credits"][0]["display_key"], json!("Key-1")); + assert_eq!(credits["credits"][0]["expires_at"], json!(1_800_403_200u64)); + assert!(credits["credits"][0].get("id").is_none()); + assert!(!credits.to_string().contains("launch")); + } + + #[test] + fn cedar_ember_null_or_empty_omits_reset_credits() { + let parsed = parse_claude_code_oauth_usage_response( + &json!({"five_hour": {"utilization": 1.0}, "cedar_ember": null}), + 1, + ) + .expect("usage should parse"); + assert!(parsed.get("reset_credits").is_none()); + } + #[test] fn parses_unified_ratelimit_headers_into_percent_and_reset() { let headers = BTreeMap::from([ diff --git a/crates/aether-provider/pool/src/lib.rs b/crates/aether-provider/pool/src/lib.rs index 64c6e7048..6b2c59adf 100644 --- a/crates/aether-provider/pool/src/lib.rs +++ b/crates/aether-provider/pool/src/lib.rs @@ -125,7 +125,10 @@ mod tests { ("authorization".to_string(), "Bearer access".to_string()), ); assert_eq!(claude_spec.method, "GET"); - assert_eq!(claude_spec.url, "https://api.anthropic.com/api/oauth/usage"); + assert_eq!( + claude_spec.url, + "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1" + ); assert_eq!( claude_spec .headers diff --git a/crates/aether-provider/pool/src/providers/claude_code.rs b/crates/aether-provider/pool/src/providers/claude_code.rs index 84f1e92f5..27bac2186 100644 --- a/crates/aether-provider/pool/src/providers/claude_code.rs +++ b/crates/aether-provider/pool/src/providers/claude_code.rs @@ -8,7 +8,8 @@ use crate::provider::{ }; use crate::quota_refresh::ProviderPoolQuotaRequestSpec; -pub const CLAUDE_CODE_OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +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-code/2.1.284"; @@ -43,7 +44,7 @@ impl ProviderPoolAdapter for ClaudeCodeProviderPoolAdapter { } /// Builds the `GET /api/oauth/usage` request that reports the account's 5h / 7d -/// utilization windows. The URL is fixed (the origin allowlist only accepts +/// utilization windows (and, via `cedar_ember=1`, its quota reset credits). The URL is fixed (the origin allowlist only accepts /// `api.anthropic.com`), independent of the inference endpoint's base URL. pub fn build_claude_code_pool_quota_request( key_id: &str, diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index 46abebf12..ee6f53fd2 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -539,6 +539,7 @@ export interface ClaudeCodeUpstreamMetadata { seven_day_sonnet_reset_at?: number seven_day_fable_used_percent?: number seven_day_fable_reset_at?: number + reset_credits?: QuotaResetCreditsSnapshot } export interface UpstreamMetadata { diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 851779a53..f91490c39 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -441,6 +441,33 @@
+
+
+ {{ formatCodexResetCreditCountLabel(getClaudeCodeResetCreditAvailableCount(key)) }} + +
+
0 ? updatedAt : undefined } +function getClaudeCodeResetCreditsDisplay(key: EndpointAPIKey): QuotaResetCreditsSnapshot | null { + return getQuotaSnapshotForProvider(key, 'claude_code')?.reset_credits + ?? key.upstream_metadata?.claude_code?.reset_credits + ?? null +} + +function getClaudeCodeResetCreditAvailableCount(key: EndpointAPIKey): number | null { + return getCodexResetCreditAvailableCountFromSnapshot(getClaudeCodeResetCreditsDisplay(key)) +} + +function getVisibleClaudeCodeResetCreditItems(key: EndpointAPIKey) { + return getVisibleCodexResetCreditItemsFromSnapshot( + getClaudeCodeResetCreditsDisplay(key), + undefined, + 5, + legacyT('Claude 重置机会'), + ) +} + function hasClaudeCodeQuotaDisplayData(key: EndpointAPIKey): boolean { return getClaudeCodeQuotaItems(key).length > 0 } diff --git a/frontend/src/features/providers/components/codex-reset-credit-display.ts b/frontend/src/features/providers/components/codex-reset-credit-display.ts index 62a2f99f9..abecb066c 100644 --- a/frontend/src/features/providers/components/codex-reset-credit-display.ts +++ b/frontend/src/features/providers/components/codex-reset-credit-display.ts @@ -272,6 +272,7 @@ export function getVisibleCodexResetCreditItems( snapshot: QuotaResetCreditsSnapshot | null | undefined, nowUnixSecs = Math.floor(Date.now() / 1000), limit = 5, + titleLabel = 'Codex 重置机会', ): CodexResetCreditDisplayItem[] { const credits = snapshot?.credits if (!snapshot || !Array.isArray(credits)) return [] @@ -295,7 +296,7 @@ export function getVisibleCodexResetCreditItems( return { ...item, displayKey, - title: `Codex 重置机会 ${displayKey}`, + title: `${titleLabel} ${displayKey}`, } satisfies CodexResetCreditDisplayItem }) } diff --git a/frontend/src/i18n/legacy-admin-messages.ts b/frontend/src/i18n/legacy-admin-messages.ts index 4b7cdff04..b914deb08 100644 --- a/frontend/src/i18n/legacy-admin-messages.ts +++ b/frontend/src/i18n/legacy-admin-messages.ts @@ -1,4 +1,7 @@ export const legacyAdminEnglishMessages: Record = { + '缺少 OAuth 认证信息,请先授权/刷新 Token': 'Missing OAuth credentials; please authorize or refresh the token first', + 'Key 状态写入失败': 'Failed to save key status', + '找不到有效的 claude:messages 端点': 'No active claude:messages endpoint found', '响应中未包含配额信息': 'The response contains no quota information', '响应中未包含额度窗口': 'The response contains no quota windows', 'oauth/usage 请求执行失败': 'oauth/usage request failed', diff --git a/frontend/src/i18n/legacy-ui-messages.ts b/frontend/src/i18n/legacy-ui-messages.ts index 5c2c616c7..fcfde9655 100644 --- a/frontend/src/i18n/legacy-ui-messages.ts +++ b/frontend/src/i18n/legacy-ui-messages.ts @@ -1420,6 +1420,7 @@ export const legacyUiEnglishMessages: Record = { '批量导入': 'Bulk import', '继续确认重置': 'Continue checking reset', '临近过期': 'Expiring soon', + 'Claude 重置机会': 'Claude quota reset', '暂无密钥配置': 'No keys configured', '暂无账号配置': 'No accounts configured', '点击上方"添加密钥"按钮创建第一个密钥': 'Add your first key to get started', diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 657da96d5..f4d664510 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -3077,6 +3077,8 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string] [/^总可用:(.+)$/u, match => `Total available: ${match[1]}`], [/^余额:(.+)$/u, match => `Balance: ${match[1]}`], [/^oauth\/usage 返回状态码 (\d+)$/u, match => `oauth/usage returned status ${match[1]}`], + [/^共 (\d+) 次机会$/u, match => `${match[1]} ${match[1] === '1' ? 'chance' : 'chances'} in total`], + [/^已处理 (\d+) 个 Key$/u, match => `Processed ${match[1]} ${match[1] === '1' ? 'key' : 'keys'}`], [/^(.+) 秒$/u, match => `${translateLegacyText(match[1], 'en-US')}s`], [/^(.+) 分钟$/u, match => `${translateLegacyText(match[1], 'en-US')} min`], [/^(.+) 小时$/u, match => `${translateLegacyText(match[1], 'en-US')} h`], diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue index 5a063db2a..6cf862b85 100644 --- a/frontend/src/views/admin/PoolManagement.vue +++ b/frontend/src/views/admin/PoolManagement.vue @@ -2274,7 +2274,19 @@ function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string | : readPendingCodexResetCreditIdempotencyKey(key.key_id, generation) } +function getClaudeCodeResetCredits(key: PoolKeyDetail) { + if (getQuotaSnapshotProviderType(key) !== 'claude_code') return null + return key.status_snapshot?.quota?.reset_credits + ?? key.upstream_metadata?.claude_code?.reset_credits + ?? null +} + function getCodexResetCreditCountText(key: PoolKeyDetail): string | null { + const claudeCredits = getClaudeCodeResetCredits(key) + if (claudeCredits) { + const claudeCount = getCodexResetCreditAvailableCount(claudeCredits) + return claudeCount === null ? null : formatCodexResetCreditCount(claudeCount) + } const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key)) return count === null && !getPendingCodexResetCreditIdempotencyKey(key) ? null @@ -2282,7 +2294,11 @@ function getCodexResetCreditCountText(key: PoolKeyDetail): string | null { } function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] { - return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3) + return getVisibleCodexResetCreditItems( + getClaudeCodeResetCredits(key) ?? getCodexResetCredits(key), + undefined, + 3, + ) .map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`) } From 9cc4018a3775f75798046896790e64f544fd25fd Mon Sep 17 00:00:00 2001 From: dalamudx Date: Wed, 30 Sep 2026 00:29:18 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(claude-code):=20=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=90=BA=E5=B8=A6=20x-app/claude-cli=20UA=20?= =?UTF-8?q?=E4=BB=A5=E8=8E=B7=E5=8F=96=20cedar=5Fember=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E6=97=A0=E9=87=8D=E7=BD=AE=E6=9C=BA=E4=BC=9A=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/aether-admin/src/provider/quota.rs | 50 +++++++++++++++++-- .../pool/src/providers/claude_code.rs | 4 +- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index 3778bdccd..7e4b5174b 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -456,12 +456,15 @@ pub fn parse_claude_code_oauth_usage_response( bucket.insert(format!("{prefix}_reset_at"), serde_json::json!(reset_at)); } } - if let Some(reset_credits) = parse_claude_code_reset_credits(root, updated_at_unix_secs) { - bucket.insert("reset_credits".to_string(), reset_credits); - } if bucket.is_empty() { return None; } + // Explicit null so a refresh overwrites (rather than keeps) credits that were used up. + bucket.insert( + "reset_credits".to_string(), + parse_claude_code_reset_credits(root, updated_at_unix_secs) + .unwrap_or(serde_json::Value::Null), + ); bucket.insert( "updated_at".to_string(), serde_json::json!(updated_at_unix_secs), @@ -7934,6 +7937,45 @@ mod claude_code_quota_tests { assert!(!credits.to_string().contains("launch")); } + #[test] + fn real_cedar_ember_response_survives_metadata_redaction() { + // Shape captured from a live Claude Pro account (unrelated fields trimmed). + let parsed = parse_claude_code_oauth_usage_response( + &json!({ + "five_hour": {"utilization": 32.0, "resets_at": "2026-09-29T19:19:59.933008+00:00"}, + "cedar_ember": { + "eligible": true, + "at_limit": false, + "grants": [{ + "id": "opus55-launch-promax-20260921", + "label": "Claude Opus 5.5 launch", + "resets_total": 1, + "resets_left": 1, + "starts_at": "2026-09-22T16:00:00+00:00", + "ends_at": "2026-10-22T16:00:00+00:00", + "clears": ["five_hour", "seven_day"], + "paused": false, + "usable_now": true, + "use_requires_limit": false + }], + "next_grant_id": "opus55-launch-promax-20260921" + } + }), + 1_790_699_000, + ) + .expect("usage should parse"); + assert_eq!(parsed["reset_credits"]["available_count"], json!(1)); + + let safe = crate::provider::redaction::admin_provider_upstream_metadata_safe_json(Some( + &json!({ "claude_code": parsed }), + )); + assert_eq!( + safe["claude_code"]["reset_credits"]["available_count"], + json!(1), + "redaction dropped reset_credits: {safe}" + ); + } + #[test] fn cedar_ember_null_or_empty_omits_reset_credits() { let parsed = parse_claude_code_oauth_usage_response( @@ -7941,7 +7983,7 @@ mod claude_code_quota_tests { 1, ) .expect("usage should parse"); - assert!(parsed.get("reset_credits").is_none()); + assert!(parsed["reset_credits"].is_null()); } #[test] diff --git a/crates/aether-provider/pool/src/providers/claude_code.rs b/crates/aether-provider/pool/src/providers/claude_code.rs index 27bac2186..195dd40cb 100644 --- a/crates/aether-provider/pool/src/providers/claude_code.rs +++ b/crates/aether-provider/pool/src/providers/claude_code.rs @@ -11,7 +11,7 @@ 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-code/2.1.284"; +pub const CLAUDE_CODE_USAGE_USER_AGENT: &str = "claude-cli/2.1.284 (external, cli)"; #[derive(Debug, Clone, Default)] pub struct ClaudeCodeProviderPoolAdapter; @@ -58,6 +58,8 @@ pub fn build_claude_code_pool_quota_request( "anthropic-beta".to_string(), CLAUDE_CODE_OAUTH_BETA.to_string(), ), + // The reset-credit (`cedar_ember`) block is only returned to first-party CLI callers. + ("x-app".to_string(), "cli".to_string()), ( "user-agent".to_string(), CLAUDE_CODE_USAGE_USER_AGENT.to_string(), From 7f45617f79efcf47a4636c53e54a024401238e46 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Wed, 30 Sep 2026 01:43:22 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(claude-code):=20=E4=BF=AE=E5=A4=8D=20cl?= =?UTF-8?q?ippy=20needless=5Fborrow=20=E5=B9=B6=E6=9B=B4=E6=96=B0=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=97=E6=94=AF=E6=8C=81=E7=9A=84=20claude=5Fcode?= =?UTF-8?q?=20=E9=A2=9D=E5=BA=A6=E5=88=B7=E6=96=B0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/handlers/shared/catalog.rs | 2 +- .../tests/control/admin/endpoints/quota.rs | 28 +++++++------------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 6a1ff5a74..9d1936ece 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -2392,7 +2392,7 @@ fn build_claude_code_quota_status_snapshot( "reset_at": reset_at, "reset_seconds": reset_seconds, "reset_credits": build_codex_reset_credits_status_snapshot( - &metadata, + metadata, observed_at_unix_secs, ), "windows": windows, diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs b/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs index 2f9b61bd5..7b1c4372c 100644 --- a/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs +++ b/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs @@ -2041,24 +2041,16 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_gemini_cli_with_trus #[tokio::test] async fn gateway_refresh_quota_reconciles_unsupported_fixed_provider_endpoints_before_clear_message( ) { - let cases = [ - ( - "provider-claude-code-reconcile", - "claude_code", - 1usize, - "claude:messages", - "https://api.anthropic.com/v1", - "Claude Code 暂不支持自动刷新额度", - ), - ( - "provider-vertex-ai-reconcile", - "vertex_ai", - 2usize, - "gemini:generate_content", - "https://aiplatform.googleapis.com", - "Vertex AI 暂不支持自动刷新额度", - ), - ]; + // Claude Code supports quota refresh now, so Vertex AI is the remaining fixed provider + // whose refresh is unsupported. + let cases = [( + "provider-vertex-ai-reconcile", + "vertex_ai", + 2usize, + "gemini:generate_content", + "https://aiplatform.googleapis.com", + "Vertex AI 暂不支持自动刷新额度", + )]; let providers = cases .iter() From 017d08d45e7b42bbeb974ea020b0e4f869d4b5fc Mon Sep 17 00:00:00 2001 From: dalamudx Date: Wed, 30 Sep 2026 02:19:15 +0800 Subject: [PATCH 7/7] =?UTF-8?q?test(claude-code):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=B5=8B=E8=AF=95=E5=85=BC=E5=AE=B9=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E5=90=8E=E5=BC=82=E6=AD=A5=E9=A2=9D=E5=BA=A6=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tests/control/admin/oauth.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/aether-gateway/src/tests/control/admin/oauth.rs b/apps/aether-gateway/src/tests/control/admin/oauth.rs index fea3ca978..5d307c626 100644 --- a/apps/aether-gateway/src/tests/control/admin/oauth.rs +++ b/apps/aether-gateway/src/tests/control/admin/oauth.rs @@ -410,6 +410,10 @@ async fn gateway_authorizes_claude_cookie_without_persisting_cookie_impl() { "email_address": "claude@example.com" } }), + // Newly authorized accounts get their 5H/weekly quota fetched right away. + quota if quota.starts_with("claude-code-quota:") => { + json!({"five_hour": {"utilization": 10.0}}) + } unexpected => panic!("unexpected execution plan: {unexpected}"), }; Json(json!({ @@ -811,7 +815,15 @@ async fn gateway_batch_authorizes_claude_cookies_as_redacted_task_impl() { } let plans = execution_plans.lock().expect("mutex should lock"); - assert_eq!(plans.len(), 6); + // Post-authorization quota refreshes are fire-and-forget, so their count is not + // deterministic here; only the OAuth flow plans are asserted. + assert_eq!( + plans + .iter() + .filter(|plan| !plan.request_id.starts_with("claude-code-quota:")) + .count(), + 6 + ); assert_eq!( plans .iter()