feat(claude-code): 支持通过 /api/oauth/usage 查询账号 5H/周额度并在号池展示

This commit is contained in:
dalamudx
2026-09-29 22:40:44 +08:00
parent 00a315e3bf
commit 8093899b5a
15 changed files with 653 additions and 33 deletions
@@ -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<ProviderQuotaExecutionOutcome, GatewayError> {
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<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> Result<Option<serde_json::Value>, 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::<serde_json::Value>;
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::<String>;
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,
})))
}
@@ -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<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> 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,
@@ -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;
@@ -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",
@@ -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<Value> {
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::<Vec<_>>();
let blocking_windows = account_windows
.iter()
.filter(|window| window.get("is_exhausted").and_then(Value::as_bool) == Some(true))
.cloned()
.collect::<Vec<_>>();
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<String, Value>,
observed_at_unix_secs: Option<u64>,
@@ -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();
@@ -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 {
+96
View File
@@ -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<serde_json::Value> {
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::<f64>().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());
}
}
+27 -16
View File
@@ -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"),
@@ -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<StoredProviderCatalogEndpoint> {
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()),
}
}
@@ -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,
@@ -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",
+5 -5
View File
@@ -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))
@@ -1,4 +1,10 @@
export const legacyAdminEnglishMessages: Record<string, string> = {
'响应中未包含配额信息': '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',
+11
View File
@@ -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`],
@@ -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<string, { labelKey: MessageKey, sortOrder: number }> = {
'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 []