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
+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))