mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-03 16:07:46 +08:00
Merge pull request #871 from dalamudx/feat/claude-code-usage-quota
feat(claude-code): 支持查询 Claude Code 账号 5H/周额度并在号池和提供商详情展示
This commit is contained in:
@@ -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::antigravity::refresh_antigravity_provider_quota_locally;
|
||||||
use super::chatgpt_web::refresh_chatgpt_web_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::codex::refresh_codex_provider_quota_locally;
|
||||||
use super::gemini_cli::refresh_gemini_cli_provider_quota_locally;
|
use super::gemini_cli::refresh_gemini_cli_provider_quota_locally;
|
||||||
use super::grok::refresh_grok_provider_quota_locally;
|
use super::grok::refresh_grok_provider_quota_locally;
|
||||||
@@ -36,6 +37,10 @@ const PROVIDER_QUOTA_REFRESH_HANDLERS: &[(&str, ProviderQuotaRefreshHandler)] =
|
|||||||
"chatgpt_web",
|
"chatgpt_web",
|
||||||
refresh_chatgpt_web_provider_quota_locally_boxed,
|
refresh_chatgpt_web_provider_quota_locally_boxed,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"claude_code",
|
||||||
|
refresh_claude_code_provider_quota_locally_boxed,
|
||||||
|
),
|
||||||
("codex", refresh_codex_provider_quota_locally_boxed),
|
("codex", refresh_codex_provider_quota_locally_boxed),
|
||||||
(
|
(
|
||||||
"gemini_cli",
|
"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>(
|
fn refresh_gemini_cli_provider_quota_locally_boxed<'a>(
|
||||||
state: &'a AdminAppState<'a>,
|
state: &'a AdminAppState<'a>,
|
||||||
provider: &'a StoredProviderCatalogProvider,
|
provider: &'a StoredProviderCatalogProvider,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub(crate) mod antigravity;
|
pub(crate) mod antigravity;
|
||||||
pub(crate) mod chatgpt_web;
|
pub(crate) mod chatgpt_web;
|
||||||
|
pub(crate) mod claude_code;
|
||||||
pub(crate) mod codex;
|
pub(crate) mod codex;
|
||||||
pub(crate) mod dispatch;
|
pub(crate) mod dispatch;
|
||||||
pub(crate) mod gemini_cli;
|
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"
|
| "daily-cloudcode-pa.sandbox.googleapis.com"
|
||||||
),
|
),
|
||||||
"gemini_cli" => host == "cloudcode-pa.googleapis.com",
|
"gemini_cli" => host == "cloudcode-pa.googleapis.com",
|
||||||
|
"claude_code" => host == "api.anthropic.com",
|
||||||
"chatgpt_web" | "codex" => host == "chatgpt.com",
|
"chatgpt_web" | "codex" => host == "chatgpt.com",
|
||||||
"grok" => host == "grok.com",
|
"grok" => host == "grok.com",
|
||||||
"xai" => host == "cli-chat-proxy.grok.com",
|
"xai" => host == "cli-chat-proxy.grok.com",
|
||||||
|
|||||||
@@ -2294,6 +2294,111 @@ 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,
|
||||||
|
"reset_credits": build_codex_reset_credits_status_snapshot(
|
||||||
|
metadata,
|
||||||
|
observed_at_unix_secs,
|
||||||
|
),
|
||||||
|
"windows": windows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_codex_reset_credits_status_snapshot(
|
fn build_codex_reset_credits_status_snapshot(
|
||||||
metadata: &Map<String, Value>,
|
metadata: &Map<String, Value>,
|
||||||
observed_at_unix_secs: Option<u64>,
|
observed_at_unix_secs: Option<u64>,
|
||||||
@@ -2423,6 +2528,7 @@ pub(crate) fn sync_provider_key_quota_status_snapshot(
|
|||||||
"antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source),
|
"antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source),
|
||||||
"grok" => build_grok_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),
|
"gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source),
|
||||||
|
"claude_code" => build_claude_code_quota_status_snapshot(upstream_metadata, source),
|
||||||
_ => None,
|
_ => None,
|
||||||
}?;
|
}?;
|
||||||
if normalized_provider_type == "codex" {
|
if normalized_provider_type == "codex" {
|
||||||
@@ -3737,6 +3843,59 @@ mod tests {
|
|||||||
assert_eq!(window.get("used_value"), Some(&json!(0.0)));
|
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,
|
||||||
|
"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
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
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"));
|
||||||
|
assert_eq!(quota["reset_credits"]["available_count"], json!(2));
|
||||||
|
assert_eq!(
|
||||||
|
quota["reset_credits"]["credits"][0]["remaining_seconds"],
|
||||||
|
json!(144_000u64)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_key_status_snapshot_payload_backfills_grok_model_quota() {
|
fn provider_key_status_snapshot_payload_backfills_grok_model_quota() {
|
||||||
let mut key = sample_catalog_key();
|
let mut key = sample_catalog_key();
|
||||||
|
|||||||
@@ -551,6 +551,24 @@ async fn sync_grok_quota_from_report_context(
|
|||||||
|
|
||||||
async fn apply_local_sync_report_effect(state: &AppState, payload: &GatewaySyncReportRequest) {
|
async fn apply_local_sync_report_effect(state: &AppState, payload: &GatewaySyncReportRequest) {
|
||||||
apply_local_gemini_file_mapping_report_effect(state, payload).await;
|
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 (200..300).contains(&payload.status_code) {
|
||||||
if let Err(err) = sync_codex_quota_from_response_headers(
|
if let Err(err) = sync_codex_quota_from_response_headers(
|
||||||
state,
|
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(
|
if let Err(err) = sync_grok_quota_from_report_context(
|
||||||
state,
|
state,
|
||||||
payload.report_context.as_ref(),
|
payload.report_context.as_ref(),
|
||||||
@@ -896,6 +932,134 @@ async fn sync_codex_quota_from_response_headers(
|
|||||||
.await
|
.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<String, String>,
|
||||||
|
) -> Result<bool, GatewayError> {
|
||||||
|
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(
|
async fn sync_codex_websocket_quota_from_stream_payload(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
payload: &GatewayStreamReportRequest,
|
payload: &GatewayStreamReportRequest,
|
||||||
|
|||||||
@@ -2041,24 +2041,16 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_gemini_cli_with_trus
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_refresh_quota_reconciles_unsupported_fixed_provider_endpoints_before_clear_message(
|
async fn gateway_refresh_quota_reconciles_unsupported_fixed_provider_endpoints_before_clear_message(
|
||||||
) {
|
) {
|
||||||
let cases = [
|
// Claude Code supports quota refresh now, so Vertex AI is the remaining fixed provider
|
||||||
(
|
// whose refresh is unsupported.
|
||||||
"provider-claude-code-reconcile",
|
let cases = [(
|
||||||
"claude_code",
|
"provider-vertex-ai-reconcile",
|
||||||
1usize,
|
"vertex_ai",
|
||||||
"claude:messages",
|
2usize,
|
||||||
"https://api.anthropic.com/v1",
|
"gemini:generate_content",
|
||||||
"Claude Code 暂不支持自动刷新额度",
|
"https://aiplatform.googleapis.com",
|
||||||
),
|
"Vertex AI 暂不支持自动刷新额度",
|
||||||
(
|
)];
|
||||||
"provider-vertex-ai-reconcile",
|
|
||||||
"vertex_ai",
|
|
||||||
2usize,
|
|
||||||
"gemini:generate_content",
|
|
||||||
"https://aiplatform.googleapis.com",
|
|
||||||
"Vertex AI 暂不支持自动刷新额度",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let providers = cases
|
let providers = cases
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -410,6 +410,10 @@ async fn gateway_authorizes_claude_cookie_without_persisting_cookie_impl() {
|
|||||||
"email_address": "[email protected]"
|
"email_address": "[email protected]"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
// 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}"),
|
unexpected => panic!("unexpected execution plan: {unexpected}"),
|
||||||
};
|
};
|
||||||
Json(json!({
|
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");
|
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!(
|
assert_eq!(
|
||||||
plans
|
plans
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1453,7 +1453,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
|
|||||||
"KiroProviderPoolAdapter",
|
"KiroProviderPoolAdapter",
|
||||||
"ChatGptWebProviderPoolAdapter",
|
"ChatGptWebProviderPoolAdapter",
|
||||||
"XaiProviderPoolAdapter",
|
"XaiProviderPoolAdapter",
|
||||||
"CLAUDE_CODE_PROVIDER_POOL_ADAPTER",
|
"ClaudeCodeProviderPoolAdapter",
|
||||||
"VERTEX_AI_PROVIDER_POOL_ADAPTER",
|
"VERTEX_AI_PROVIDER_POOL_ADAPTER",
|
||||||
"provider_types_for_capability",
|
"provider_types_for_capability",
|
||||||
"supports_quota_refresh",
|
"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",
|
"crates/aether-provider/pool/src/providers/unsupported.rs",
|
||||||
vec![
|
vec![
|
||||||
"UnsupportedQuotaProviderPoolAdapter",
|
"UnsupportedQuotaProviderPoolAdapter",
|
||||||
"CLAUDE_CODE_PROVIDER_POOL_ADAPTER",
|
|
||||||
"VERTEX_AI_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);
|
let source = read_workspace_file(path);
|
||||||
for pattern in patterns {
|
for pattern in patterns {
|
||||||
|
|||||||
@@ -404,6 +404,200 @@ pub fn parse_antigravity_quota_summary_response(
|
|||||||
(!parsed_groups.is_empty()).then_some(serde_json::Value::Array(parsed_groups))
|
(!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;
|
||||||
|
}
|
||||||
|
// 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),
|
||||||
|
);
|
||||||
|
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<String, serde_json::Value>,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
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<String, serde_json::Value>, 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`]).
|
||||||
|
///
|
||||||
|
/// 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<String, String>,
|
||||||
|
updated_at_unix_secs: u64,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let normalized = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
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::<f64>().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::<u64>().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(
|
pub fn parse_gemini_cli_retrieve_user_quota_response(
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
updated_at_unix_secs: u64,
|
updated_at_unix_secs: u64,
|
||||||
@@ -7704,3 +7898,163 @@ mod tests {
|
|||||||
assert!(!serialized.contains("user:password"));
|
assert!(!serialized.contains("user:password"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod claude_code_quota_tests {
|
||||||
|
use super::{parse_claude_code_oauth_usage_response, parse_claude_code_usage_headers};
|
||||||
|
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 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(
|
||||||
|
&json!({"five_hour": {"utilization": 1.0}, "cedar_ember": null}),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("usage should parse");
|
||||||
|
assert!(parsed["reset_credits"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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() {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,26 +16,26 @@ pub use presets::{
|
|||||||
pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||||
pub use providers::{
|
pub use providers::{
|
||||||
build_antigravity_pool_quota_request, build_antigravity_pool_quota_summary_request,
|
build_antigravity_pool_quota_request, build_antigravity_pool_quota_summary_request,
|
||||||
build_chatgpt_web_pool_quota_request, build_codex_pool_quota_request,
|
build_chatgpt_web_pool_quota_request, build_claude_code_pool_quota_request,
|
||||||
build_codex_pool_reset_credit_consume_request, build_codex_pool_reset_credits_request,
|
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
|
||||||
build_gemini_cli_pool_quota_request, build_kiro_pool_quota_request,
|
build_codex_pool_reset_credits_request, build_gemini_cli_pool_quota_request,
|
||||||
build_windsurf_pool_model_configs_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_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_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_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,
|
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_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
|
||||||
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
|
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
|
||||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, ClaudeCodeProviderPoolAdapter,
|
||||||
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
|
CodexProviderPoolAdapter, DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter,
|
||||||
KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
|
GrokProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
||||||
XaiProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
UnsupportedQuotaProviderPoolAdapter, XaiProviderPoolAdapter,
|
||||||
ANTIGRAVITY_RETRIEVE_USER_QUOTA_SUMMARY_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
|
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, ANTIGRAVITY_RETRIEVE_USER_QUOTA_SUMMARY_PATH,
|
||||||
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
|
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL,
|
||||||
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
|
CODEX_WHAM_RESET_CREDITS_CONSUME_URL, CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL,
|
||||||
GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH, GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH,
|
||||||
WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
|
KIRO_USAGE_SDK_VERSION, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
|
||||||
XAI_BILLING_PATH, XAI_USER_PATH,
|
WINDSURF_USER_STATUS_PATH, XAI_BILLING_PATH, XAI_USER_PATH,
|
||||||
};
|
};
|
||||||
pub use quota::{
|
pub use quota::{
|
||||||
provider_pool_codex_metadata_has_account_quota, provider_pool_key_account_quota_exhausted,
|
provider_pool_codex_metadata_has_account_quota, provider_pool_key_account_quota_exhausted,
|
||||||
@@ -104,6 +104,7 @@ mod tests {
|
|||||||
[
|
[
|
||||||
"antigravity",
|
"antigravity",
|
||||||
"chatgpt_web",
|
"chatgpt_web",
|
||||||
|
"claude_code",
|
||||||
"codex",
|
"codex",
|
||||||
"gemini_cli",
|
"gemini_cli",
|
||||||
"grok",
|
"grok",
|
||||||
@@ -112,15 +113,28 @@ mod tests {
|
|||||||
"xai"
|
"xai"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert!(service.supports_quota_refresh("claude_code"));
|
||||||
assert!(service.supports_quota_refresh("codex"));
|
assert!(service.supports_quota_refresh("codex"));
|
||||||
assert!(service.supports_quota_refresh("antigravity"));
|
assert!(service.supports_quota_refresh("antigravity"));
|
||||||
assert!(service.supports_quota_refresh("grok"));
|
assert!(service.supports_quota_refresh("grok"));
|
||||||
assert!(service.supports_quota_refresh("gemini_cli"));
|
assert!(service.supports_quota_refresh("gemini_cli"));
|
||||||
assert!(service.supports_quota_refresh("windsurf"));
|
assert!(service.supports_quota_refresh("windsurf"));
|
||||||
assert!(service.supports_quota_refresh("xai"));
|
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!(
|
assert_eq!(
|
||||||
service.quota_refresh_unsupported_message("claude_code"),
|
claude_spec.url,
|
||||||
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口"
|
"https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
claude_spec
|
||||||
|
.headers
|
||||||
|
.get("anthropic-beta")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("oauth-2025-04-20")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
service.quota_refresh_unsupported_message("vertex_ai"),
|
service.quota_refresh_unsupported_message("vertex_ai"),
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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?cedar_ember=1&skip_spend=1";
|
||||||
|
pub const CLAUDE_CODE_OAUTH_BETA: &str = "oauth-2025-04-20";
|
||||||
|
pub const CLAUDE_CODE_USAGE_USER_AGENT: &str = "claude-cli/2.1.284 (external, cli)";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ClaudeCodeProviderPoolAdapter;
|
||||||
|
|
||||||
|
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 (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,
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
// 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(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 antigravity;
|
||||||
pub mod chatgpt_web;
|
pub mod chatgpt_web;
|
||||||
|
pub mod claude_code;
|
||||||
pub mod codex;
|
pub mod codex;
|
||||||
pub mod default;
|
pub mod default;
|
||||||
pub mod gemini_cli;
|
pub mod gemini_cli;
|
||||||
@@ -20,6 +21,11 @@ pub use chatgpt_web::{
|
|||||||
normalize_chatgpt_web_image_quota_limit, CHATGPT_WEB_CONVERSATION_INIT_PATH,
|
normalize_chatgpt_web_image_quota_limit, CHATGPT_WEB_CONVERSATION_INIT_PATH,
|
||||||
CHATGPT_WEB_DEFAULT_BASE_URL,
|
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::CodexProviderPoolAdapter;
|
||||||
pub use codex::{
|
pub use codex::{
|
||||||
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
|
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,
|
build_kiro_pool_quota_request, KiroPoolQuotaAuthInput, KIRO_USAGE_LIMITS_PATH,
|
||||||
KIRO_USAGE_SDK_VERSION,
|
KIRO_USAGE_SDK_VERSION,
|
||||||
};
|
};
|
||||||
pub use unsupported::{
|
pub use unsupported::{UnsupportedQuotaProviderPoolAdapter, VERTEX_AI_PROVIDER_POOL_ADAPTER};
|
||||||
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
|
|
||||||
VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
|
||||||
};
|
|
||||||
pub use windsurf::{
|
pub use windsurf::{
|
||||||
build_windsurf_pool_model_configs_request,
|
build_windsurf_pool_model_configs_request,
|
||||||
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_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 =
|
pub const VERTEX_AI_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter =
|
||||||
UnsupportedQuotaProviderPoolAdapter::new(
|
UnsupportedQuotaProviderPoolAdapter::new(
|
||||||
"vertex_ai",
|
"vertex_ai",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ use crate::capability::ProviderPoolCapability;
|
|||||||
use crate::presets::normalize_provider_scheduling_presets;
|
use crate::presets::normalize_provider_scheduling_presets;
|
||||||
use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||||
use crate::providers::{
|
use crate::providers::{
|
||||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, ClaudeCodeProviderPoolAdapter,
|
||||||
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
|
CodexProviderPoolAdapter, DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter,
|
||||||
KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, XaiProviderPoolAdapter,
|
GrokProviderPoolAdapter, KiroProviderPoolAdapter, WindsurfProviderPoolAdapter,
|
||||||
CLAUDE_CODE_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
XaiProviderPoolAdapter, VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -48,7 +48,7 @@ impl ProviderPoolService {
|
|||||||
pub fn with_builtin_adapters() -> Self {
|
pub fn with_builtin_adapters() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
.with_adapter(Arc::new(AntigravityProviderPoolAdapter))
|
.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(CodexProviderPoolAdapter))
|
||||||
.with_adapter(Arc::new(GeminiCliProviderPoolAdapter))
|
.with_adapter(Arc::new(GeminiCliProviderPoolAdapter))
|
||||||
.with_adapter(Arc::new(GrokProviderPoolAdapter))
|
.with_adapter(Arc::new(GrokProviderPoolAdapter))
|
||||||
|
|||||||
@@ -529,8 +529,22 @@ export interface GeminiCliUpstreamMetadata {
|
|||||||
quota_by_model?: Record<string, GeminiCliModelQuota> | null
|
quota_by_model?: Record<string, GeminiCliModelQuota> | 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
|
||||||
|
reset_credits?: QuotaResetCreditsSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpstreamMetadata {
|
export interface UpstreamMetadata {
|
||||||
codex?: CodexUpstreamMetadata
|
codex?: CodexUpstreamMetadata
|
||||||
|
claude_code?: ClaudeCodeUpstreamMetadata
|
||||||
antigravity?: AntigravityUpstreamMetadata
|
antigravity?: AntigravityUpstreamMetadata
|
||||||
kiro?: KiroUpstreamMetadata
|
kiro?: KiroUpstreamMetadata
|
||||||
windsurf?: WindsurfUpstreamMetadata
|
windsurf?: WindsurfUpstreamMetadata
|
||||||
|
|||||||
@@ -406,6 +406,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Claude Code 账号额度(5H / 周窗口) -->
|
||||||
|
<div
|
||||||
|
v-if="provider.provider_type === 'claude_code' && hasClaudeCodeQuotaDisplayData(key)"
|
||||||
|
class="mt-2 p-2 rounded-md bg-muted/30"
|
||||||
|
>
|
||||||
|
<ProviderQuotaSectionHeader
|
||||||
|
:title="legacyT('账号配额')"
|
||||||
|
:loading="refreshingQuota"
|
||||||
|
:updated-text="getClaudeCodeQuotaUpdatedAt(key) ? formatUpdatedAt(getClaudeCodeQuotaUpdatedAt(key) || 0) : null"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<ProviderQuotaProgressRow
|
||||||
|
v-for="item in getClaudeCodeQuotaItems(key)"
|
||||||
|
:key="item.code"
|
||||||
|
:label="item.label"
|
||||||
|
:used-percent="item.usedPercent"
|
||||||
|
:remaining-percent="item.remainingPercent"
|
||||||
|
:meter-class="getQuotaRemainingClass(item.usedPercent)"
|
||||||
|
:bar-class="getQuotaRemainingBarColor(item.usedPercent)"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div
|
||||||
|
v-if="item.resetSeconds !== null && item.remainingPercent < 100"
|
||||||
|
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||||
|
>
|
||||||
|
<template v-if="item.resetSeconds > 0">
|
||||||
|
{{ formatResetTime(item.resetSeconds) }}{{ legacyT('后重置') }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ legacyT('已重置') }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ProviderQuotaProgressRow>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getClaudeCodeResetCreditAvailableCount(key) !== null"
|
||||||
|
class="mt-3 border-t border-border/60 pt-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-[10px] leading-4 text-muted-foreground">
|
||||||
|
<span>{{ formatCodexResetCreditCountLabel(getClaudeCodeResetCreditAvailableCount(key)) }}</span>
|
||||||
|
<template v-if="getVisibleClaudeCodeResetCreditItems(key).length > 0">
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
<span>{{ legacyT('临近过期') }}</span>
|
||||||
|
<template
|
||||||
|
v-for="(item, itemIndex) in getVisibleClaudeCodeResetCreditItems(key)"
|
||||||
|
:key="`${item.displayKey}-${item.expiresAt}`"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:title="item.title"
|
||||||
|
class="tabular-nums"
|
||||||
|
>
|
||||||
|
{{ item.displayKey }} {{ formatCodexResetCreditExpiresAt(item.expiresAt) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="itemIndex < getVisibleClaudeCodeResetCreditItems(key).length - 1"
|
||||||
|
aria-hidden="true"
|
||||||
|
>·</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Gemini CLI 上游模型配额 -->
|
<!-- Gemini CLI 上游模型配额 -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.provider_type === 'gemini_cli' && hasGeminiCliQuotaDisplayData(key)"
|
v-if="provider.provider_type === 'gemini_cli' && hasGeminiCliQuotaDisplayData(key)"
|
||||||
@@ -1000,6 +1063,7 @@ import {
|
|||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { getI18nLocale, useI18n } from '@/i18n'
|
import { getI18nLocale, useI18n } from '@/i18n'
|
||||||
|
import type { MessageKey } from '@/i18n/messages'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -1918,7 +1982,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
|
|||||||
|
|
||||||
function getQuotaSnapshotForProvider(
|
function getQuotaSnapshotForProvider(
|
||||||
key: EndpointAPIKey,
|
key: EndpointAPIKey,
|
||||||
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok' | 'xai',
|
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'claude_code' | 'grok' | 'xai',
|
||||||
): QuotaStatusSnapshot | null {
|
): QuotaStatusSnapshot | null {
|
||||||
const quota = key.status_snapshot?.quota
|
const quota = key.status_snapshot?.quota
|
||||||
if (!quota) return null
|
if (!quota) return null
|
||||||
@@ -2795,6 +2859,24 @@ function shouldAutoRefreshGeminiCliQuota(): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldAutoRefreshClaudeCodeQuota(): boolean {
|
||||||
|
if (provider.value?.provider_type !== 'claude_code') return false
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
for (const { key } of allKeys.value) {
|
||||||
|
if (!key.is_active) continue
|
||||||
|
if (isTokenExpiringSoon(key, now)) return true
|
||||||
|
if (!hasClaudeCodeQuotaDisplayData(key)) return true
|
||||||
|
|
||||||
|
const updatedAt = getClaudeCodeQuotaUpdatedAt(key)
|
||||||
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAutoRefreshKiroQuota(): boolean {
|
function shouldAutoRefreshKiroQuota(): boolean {
|
||||||
if (provider.value?.provider_type !== 'kiro') return false
|
if (provider.value?.provider_type !== 'kiro') return false
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -2990,12 +3072,14 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
|
|||||||
if (refreshingQuota.value) return false
|
if (refreshingQuota.value) return false
|
||||||
|
|
||||||
const providerType = provider.value?.provider_type
|
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
|
let shouldRefresh = false
|
||||||
if (providerType === 'codex') {
|
if (providerType === 'codex') {
|
||||||
shouldRefresh = shouldAutoRefreshCodexQuota()
|
shouldRefresh = shouldAutoRefreshCodexQuota()
|
||||||
|
} else if (providerType === 'claude_code') {
|
||||||
|
shouldRefresh = shouldAutoRefreshClaudeCodeQuota()
|
||||||
} else if (providerType === 'gemini_cli') {
|
} else if (providerType === 'gemini_cli') {
|
||||||
shouldRefresh = shouldAutoRefreshGeminiCliQuota()
|
shouldRefresh = shouldAutoRefreshGeminiCliQuota()
|
||||||
} else if (providerType === 'antigravity') {
|
} else if (providerType === 'antigravity') {
|
||||||
@@ -3016,6 +3100,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
|
|||||||
let hadCachedQuota = false
|
let hadCachedQuota = false
|
||||||
if (providerType === 'codex') {
|
if (providerType === 'codex') {
|
||||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
|
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') {
|
} else if (providerType === 'gemini_cli') {
|
||||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGeminiCliQuotaDisplayData(key))
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGeminiCliQuotaDisplayData(key))
|
||||||
} else if (providerType === 'antigravity') {
|
} else if (providerType === 'antigravity') {
|
||||||
@@ -3564,6 +3650,90 @@ interface AntigravityQuotaItem {
|
|||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClaudeCodeQuotaItem {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
usedPercent: number
|
||||||
|
remainingPercent: number
|
||||||
|
resetSeconds: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_CODE_QUOTA_WINDOWS: 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 },
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface GeminiCliQuotaItem {
|
||||||
model: string
|
model: string
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ export function getVisibleCodexResetCreditItems(
|
|||||||
snapshot: QuotaResetCreditsSnapshot | null | undefined,
|
snapshot: QuotaResetCreditsSnapshot | null | undefined,
|
||||||
nowUnixSecs = Math.floor(Date.now() / 1000),
|
nowUnixSecs = Math.floor(Date.now() / 1000),
|
||||||
limit = 5,
|
limit = 5,
|
||||||
|
titleLabel = 'Codex 重置机会',
|
||||||
): CodexResetCreditDisplayItem[] {
|
): CodexResetCreditDisplayItem[] {
|
||||||
const credits = snapshot?.credits
|
const credits = snapshot?.credits
|
||||||
if (!snapshot || !Array.isArray(credits)) return []
|
if (!snapshot || !Array.isArray(credits)) return []
|
||||||
@@ -295,7 +296,7 @@ export function getVisibleCodexResetCreditItems(
|
|||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
displayKey,
|
displayKey,
|
||||||
title: `Codex 重置机会 ${displayKey}`,
|
title: `${titleLabel} ${displayKey}`,
|
||||||
} satisfies CodexResetCreditDisplayItem
|
} satisfies CodexResetCreditDisplayItem
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
export const legacyAdminEnglishMessages: Record<string, string> = {
|
export const legacyAdminEnglishMessages: Record<string, string> = {
|
||||||
|
'缺少 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',
|
||||||
|
'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',
|
'配置额度、流量限制、会员权益和组合套餐': 'Configure credit, usage limits, membership benefits, and bundled plans',
|
||||||
'新建套餐': 'Create plan',
|
'新建套餐': 'Create plan',
|
||||||
'套餐列表': 'Plans',
|
'套餐列表': 'Plans',
|
||||||
|
|||||||
@@ -1420,6 +1420,7 @@ export const legacyUiEnglishMessages: Record<string, string> = {
|
|||||||
'批量导入': 'Bulk import',
|
'批量导入': 'Bulk import',
|
||||||
'继续确认重置': 'Continue checking reset',
|
'继续确认重置': 'Continue checking reset',
|
||||||
'临近过期': 'Expiring soon',
|
'临近过期': 'Expiring soon',
|
||||||
|
'Claude 重置机会': 'Claude quota reset',
|
||||||
'暂无密钥配置': 'No keys configured',
|
'暂无密钥配置': 'No keys configured',
|
||||||
'暂无账号配置': 'No accounts configured',
|
'暂无账号配置': 'No accounts configured',
|
||||||
'点击上方"添加密钥"按钮创建第一个密钥': 'Add your first key to get started',
|
'点击上方"添加密钥"按钮创建第一个密钥': 'Add your first key to get started',
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { legacyGuideEnglishMessages } from './legacy-guide-messages'
|
|||||||
export const messages = {
|
export const messages = {
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
'common.loading': '加载中...',
|
'common.loading': '加载中...',
|
||||||
|
'poolQuota.claudeCode.window5h': '5H',
|
||||||
|
'poolQuota.claudeCode.weekly': '周',
|
||||||
|
'poolQuota.claudeCode.weeklySonnet': '周 Sonnet',
|
||||||
|
'poolQuota.claudeCode.weeklyFable': '周 Fable',
|
||||||
|
'poolQuota.claudeCode.unknownWindow': '额度',
|
||||||
'common.confirmRead': '确认已读',
|
'common.confirmRead': '确认已读',
|
||||||
'common.confirming': '确认中...',
|
'common.confirming': '确认中...',
|
||||||
'common.copy': '复制',
|
'common.copy': '复制',
|
||||||
@@ -491,6 +496,11 @@ export const messages = {
|
|||||||
},
|
},
|
||||||
'en-US': {
|
'en-US': {
|
||||||
'common.loading': 'Loading...',
|
'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.confirmRead': 'Confirm read',
|
||||||
'common.confirming': 'Confirming...',
|
'common.confirming': 'Confirming...',
|
||||||
'common.copy': 'Copy',
|
'common.copy': 'Copy',
|
||||||
@@ -3066,6 +3076,9 @@ 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)))],
|
[/^(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 => `Total available: ${match[1]}`],
|
||||||
[/^余额:(.+)$/u, match => `Balance: ${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')}s`],
|
||||||
[/^(.+) 分钟$/u, match => `${translateLegacyText(match[1], 'en-US')} min`],
|
[/^(.+) 分钟$/u, match => `${translateLegacyText(match[1], 'en-US')} min`],
|
||||||
[/^(.+) 小时$/u, match => `${translateLegacyText(match[1], 'en-US')} h`],
|
[/^(.+) 小时$/u, match => `${translateLegacyText(match[1], 'en-US')} h`],
|
||||||
|
|||||||
@@ -1071,6 +1071,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
|||||||
import { useRouteQuery } from '@/composables/useRouteQuery'
|
import { useRouteQuery } from '@/composables/useRouteQuery'
|
||||||
import { useBatchSelection } from '@/composables/useBatchSelection'
|
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import type { MessageKey } from '@/i18n/messages'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
getPoolOverview,
|
getPoolOverview,
|
||||||
@@ -1696,6 +1697,7 @@ watch(showAdaptiveHotPoolMetricsButton, (enabled) => {
|
|||||||
|
|
||||||
const showAccountQuotaColumn = computed(() => {
|
const showAccountQuotaColumn = computed(() => {
|
||||||
return selectedProviderType.value === 'codex'
|
return selectedProviderType.value === 'codex'
|
||||||
|
|| selectedProviderType.value === 'claude_code'
|
||||||
|| selectedProviderType.value === 'gemini_cli'
|
|| selectedProviderType.value === 'gemini_cli'
|
||||||
|| selectedProviderType.value === 'kiro'
|
|| selectedProviderType.value === 'kiro'
|
||||||
|| selectedProviderType.value === 'windsurf'
|
|| selectedProviderType.value === 'windsurf'
|
||||||
@@ -2146,6 +2148,7 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
|
|||||||
|
|
||||||
const quotaRefreshSupported = computed(() => {
|
const quotaRefreshSupported = computed(() => {
|
||||||
return selectedProviderType.value === 'codex'
|
return selectedProviderType.value === 'codex'
|
||||||
|
|| selectedProviderType.value === 'claude_code'
|
||||||
|| selectedProviderType.value === 'kiro'
|
|| selectedProviderType.value === 'kiro'
|
||||||
|| selectedProviderType.value === 'gemini_cli'
|
|| selectedProviderType.value === 'gemini_cli'
|
||||||
|| selectedProviderType.value === 'windsurf'
|
|| selectedProviderType.value === 'windsurf'
|
||||||
@@ -2271,7 +2274,19 @@ function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string |
|
|||||||
: readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
|
: 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 {
|
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))
|
const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key))
|
||||||
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
|
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
|
||||||
? null
|
? null
|
||||||
@@ -2279,7 +2294,11 @@ function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] {
|
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)}`)
|
.map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3902,6 +3921,33 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
|||||||
.filter((item): item is QuotaProgressItem => item != null)
|
.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') {
|
if (providerType === 'gemini_cli') {
|
||||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||||
if (windows.length === 0) return []
|
if (windows.length === 0) return []
|
||||||
|
|||||||
Reference in New Issue
Block a user