mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
Merge pull request #871 from dalamudx/feat/claude-code-usage-quota
feat(claude-code): 支持查询 Claude Code 账号 5H/周额度并在号池和提供商详情展示
This commit is contained in:
@@ -404,6 +404,200 @@ 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;
|
||||
}
|
||||
// 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(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
@@ -7704,3 +7898,163 @@ mod tests {
|
||||
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 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,28 @@ 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!(
|
||||
service.quota_refresh_unsupported_message("claude_code"),
|
||||
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口"
|
||||
claude_spec.url,
|
||||
"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!(
|
||||
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 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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user