diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index c9038926e..6a1ff5a74 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -2391,6 +2391,10 @@ fn build_claude_code_quota_status_snapshot( "updated_at": observed_at_unix_secs, "reset_at": reset_at, "reset_seconds": reset_seconds, + "reset_credits": build_codex_reset_credits_status_snapshot( + &metadata, + observed_at_unix_secs, + ), "windows": windows, })) } @@ -3850,7 +3854,17 @@ mod tests { "seven_day_used_percent": 40.0, "seven_day_reset_at": 1_800_400_000u64, "seven_day_sonnet_used_percent": 10.0, - "seven_day_sonnet_reset_at": 1_800_400_000u64 + "seven_day_sonnet_reset_at": 1_800_400_000u64, + "reset_credits": { + "available_count": 2, + "updated_at": 1_800_000_000u64, + "detail_source": "claude_oauth_usage", + "credits": [{ + "display_key": "Key-1", + "status": "available", + "expires_at": 1_800_144_000u64 + }] + } } })); @@ -3875,6 +3889,11 @@ mod tests { assert_eq!(windows[1]["used_ratio"], json!(0.4)); assert_eq!(windows[2]["code"], json!("weekly_sonnet")); assert_eq!(windows[2]["scope"], json!("model")); + assert_eq!(quota["reset_credits"]["available_count"], json!(2)); + assert_eq!( + quota["reset_credits"]["credits"][0]["remaining_seconds"], + json!(144_000u64) + ); } #[test] diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index 43d0c65f6..3778bdccd 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -456,6 +456,9 @@ pub fn parse_claude_code_oauth_usage_response( bucket.insert(format!("{prefix}_reset_at"), serde_json::json!(reset_at)); } } + if let Some(reset_credits) = parse_claude_code_reset_credits(root, updated_at_unix_secs) { + bucket.insert("reset_credits".to_string(), reset_credits); + } if bucket.is_empty() { return None; } @@ -466,6 +469,73 @@ pub fn parse_claude_code_oauth_usage_response( Some(serde_json::Value::Object(bucket)) } +/// Projects the `cedar_ember` block (returned with `?cedar_ember=1`) into the same +/// `reset_credits` shape codex uses. Upstream grant/organization ids are never copied; each +/// usable grant becomes one entry, and `available_count` sums their remaining resets. +fn parse_claude_code_reset_credits( + root: &serde_json::Map, + now_unix_secs: u64, +) -> Option { + let grants = root + .get("cedar_ember") + .and_then(serde_json::Value::as_object)? + .get("grants") + .and_then(serde_json::Value::as_array)?; + let parse_time = |grant: &serde_json::Map, field: &str| { + grant + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|text| chrono::DateTime::parse_from_rfc3339(text.trim()).ok()) + .and_then(|time| u64::try_from(time.timestamp()).ok()) + }; + let mut available_count = 0u64; + let mut credits = Vec::new(); + for grant in grants.iter().filter_map(serde_json::Value::as_object) { + let resets_left = grant + .get("resets_left") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let has_clears = grant + .get("clears") + .and_then(serde_json::Value::as_array) + .is_some_and(|clears| !clears.is_empty()); + let paused = grant + .get("paused") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let starts_at = parse_time(grant, "starts_at"); + let expires_at = parse_time(grant, "ends_at"); + if resets_left == 0 + || !has_clears + || paused + || starts_at.is_some_and(|value| value > now_unix_secs) + || expires_at.is_some_and(|value| value <= now_unix_secs) + { + continue; + } + available_count += resets_left; + let Some(expires_at) = expires_at else { + continue; + }; + credits.push(serde_json::json!({ + "display_key": format!("Key-{}", credits.len() + 1), + "status": "available", + "expires_at": expires_at, + "remaining_seconds": expires_at - now_unix_secs, + })); + } + if available_count == 0 { + return None; + } + Some(serde_json::json!({ + "available_count": available_count, + "updated_at": now_unix_secs, + "detail_source": "claude_oauth_usage", + "detail_status": "ok", + "credits": credits, + })) +} + /// Parses the `anthropic-ratelimit-unified-*` response headers into a partial `claude_code` /// metadata bucket (same field names as [`parse_claude_code_oauth_usage_response`]). /// @@ -7832,6 +7902,48 @@ mod claude_code_quota_tests { use serde_json::json; use std::collections::BTreeMap; + #[test] + fn parses_cedar_ember_grants_into_reset_credits() { + let parsed = parse_claude_code_oauth_usage_response( + &json!({ + "five_hour": {"utilization": 65.0, "resets_at": "2027-01-15T08:00:00Z"}, + "cedar_ember": { + "eligible": true, + "grants": [ + {"id": "launch", "resets_left": 2, "clears": ["five_hour"], + "ends_at": "2027-01-20T00:00:00Z"}, + {"id": "later", "resets_left": 1, "clears": ["five_hour"], + "starts_at": "2027-02-01T00:00:00Z"}, + {"id": "paused", "resets_left": 1, "clears": ["five_hour"], "paused": true}, + {"id": "spent", "resets_left": 0, "clears": ["five_hour"]}, + {"id": "expired", "resets_left": 1, "clears": ["five_hour"], + "ends_at": "2027-01-01T00:00:00Z"} + ] + } + }), + 1_800_000_000, + ) + .expect("usage should parse"); + + let credits = &parsed["reset_credits"]; + assert_eq!(credits["available_count"], json!(2)); + assert_eq!(credits["credits"].as_array().map(Vec::len), Some(1)); + assert_eq!(credits["credits"][0]["display_key"], json!("Key-1")); + assert_eq!(credits["credits"][0]["expires_at"], json!(1_800_403_200u64)); + assert!(credits["credits"][0].get("id").is_none()); + assert!(!credits.to_string().contains("launch")); + } + + #[test] + fn cedar_ember_null_or_empty_omits_reset_credits() { + let parsed = parse_claude_code_oauth_usage_response( + &json!({"five_hour": {"utilization": 1.0}, "cedar_ember": null}), + 1, + ) + .expect("usage should parse"); + assert!(parsed.get("reset_credits").is_none()); + } + #[test] fn parses_unified_ratelimit_headers_into_percent_and_reset() { let headers = BTreeMap::from([ diff --git a/crates/aether-provider/pool/src/lib.rs b/crates/aether-provider/pool/src/lib.rs index 64c6e7048..6b2c59adf 100644 --- a/crates/aether-provider/pool/src/lib.rs +++ b/crates/aether-provider/pool/src/lib.rs @@ -125,7 +125,10 @@ mod tests { ("authorization".to_string(), "Bearer access".to_string()), ); assert_eq!(claude_spec.method, "GET"); - assert_eq!(claude_spec.url, "https://api.anthropic.com/api/oauth/usage"); + assert_eq!( + claude_spec.url, + "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1" + ); assert_eq!( claude_spec .headers diff --git a/crates/aether-provider/pool/src/providers/claude_code.rs b/crates/aether-provider/pool/src/providers/claude_code.rs index 84f1e92f5..27bac2186 100644 --- a/crates/aether-provider/pool/src/providers/claude_code.rs +++ b/crates/aether-provider/pool/src/providers/claude_code.rs @@ -8,7 +8,8 @@ use crate::provider::{ }; use crate::quota_refresh::ProviderPoolQuotaRequestSpec; -pub const CLAUDE_CODE_OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +pub const CLAUDE_CODE_OAUTH_USAGE_URL: &str = + "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1"; pub const CLAUDE_CODE_OAUTH_BETA: &str = "oauth-2025-04-20"; pub const CLAUDE_CODE_USAGE_USER_AGENT: &str = "claude-code/2.1.284"; @@ -43,7 +44,7 @@ impl ProviderPoolAdapter for ClaudeCodeProviderPoolAdapter { } /// Builds the `GET /api/oauth/usage` request that reports the account's 5h / 7d -/// utilization windows. The URL is fixed (the origin allowlist only accepts +/// utilization windows (and, via `cedar_ember=1`, its quota reset credits). The URL is fixed (the origin allowlist only accepts /// `api.anthropic.com`), independent of the inference endpoint's base URL. pub fn build_claude_code_pool_quota_request( key_id: &str, diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index 46abebf12..ee6f53fd2 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -539,6 +539,7 @@ export interface ClaudeCodeUpstreamMetadata { seven_day_sonnet_reset_at?: number seven_day_fable_used_percent?: number seven_day_fable_reset_at?: number + reset_credits?: QuotaResetCreditsSnapshot } export interface UpstreamMetadata { diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 851779a53..f91490c39 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -441,6 +441,33 @@ +
+
+ {{ formatCodexResetCreditCountLabel(getClaudeCodeResetCreditAvailableCount(key)) }} + +
+
0 ? updatedAt : undefined } +function getClaudeCodeResetCreditsDisplay(key: EndpointAPIKey): QuotaResetCreditsSnapshot | null { + return getQuotaSnapshotForProvider(key, 'claude_code')?.reset_credits + ?? key.upstream_metadata?.claude_code?.reset_credits + ?? null +} + +function getClaudeCodeResetCreditAvailableCount(key: EndpointAPIKey): number | null { + return getCodexResetCreditAvailableCountFromSnapshot(getClaudeCodeResetCreditsDisplay(key)) +} + +function getVisibleClaudeCodeResetCreditItems(key: EndpointAPIKey) { + return getVisibleCodexResetCreditItemsFromSnapshot( + getClaudeCodeResetCreditsDisplay(key), + undefined, + 5, + legacyT('Claude 重置机会'), + ) +} + function hasClaudeCodeQuotaDisplayData(key: EndpointAPIKey): boolean { return getClaudeCodeQuotaItems(key).length > 0 } diff --git a/frontend/src/features/providers/components/codex-reset-credit-display.ts b/frontend/src/features/providers/components/codex-reset-credit-display.ts index 62a2f99f9..abecb066c 100644 --- a/frontend/src/features/providers/components/codex-reset-credit-display.ts +++ b/frontend/src/features/providers/components/codex-reset-credit-display.ts @@ -272,6 +272,7 @@ export function getVisibleCodexResetCreditItems( snapshot: QuotaResetCreditsSnapshot | null | undefined, nowUnixSecs = Math.floor(Date.now() / 1000), limit = 5, + titleLabel = 'Codex 重置机会', ): CodexResetCreditDisplayItem[] { const credits = snapshot?.credits if (!snapshot || !Array.isArray(credits)) return [] @@ -295,7 +296,7 @@ export function getVisibleCodexResetCreditItems( return { ...item, displayKey, - title: `Codex 重置机会 ${displayKey}`, + title: `${titleLabel} ${displayKey}`, } satisfies CodexResetCreditDisplayItem }) } diff --git a/frontend/src/i18n/legacy-admin-messages.ts b/frontend/src/i18n/legacy-admin-messages.ts index 4b7cdff04..b914deb08 100644 --- a/frontend/src/i18n/legacy-admin-messages.ts +++ b/frontend/src/i18n/legacy-admin-messages.ts @@ -1,4 +1,7 @@ export const legacyAdminEnglishMessages: Record = { + '缺少 OAuth 认证信息,请先授权/刷新 Token': 'Missing OAuth credentials; please authorize or refresh the token first', + 'Key 状态写入失败': 'Failed to save key status', + '找不到有效的 claude:messages 端点': 'No active claude:messages endpoint found', '响应中未包含配额信息': 'The response contains no quota information', '响应中未包含额度窗口': 'The response contains no quota windows', 'oauth/usage 请求执行失败': 'oauth/usage request failed', diff --git a/frontend/src/i18n/legacy-ui-messages.ts b/frontend/src/i18n/legacy-ui-messages.ts index 5c2c616c7..fcfde9655 100644 --- a/frontend/src/i18n/legacy-ui-messages.ts +++ b/frontend/src/i18n/legacy-ui-messages.ts @@ -1420,6 +1420,7 @@ export const legacyUiEnglishMessages: Record = { '批量导入': 'Bulk import', '继续确认重置': 'Continue checking reset', '临近过期': 'Expiring soon', + 'Claude 重置机会': 'Claude quota reset', '暂无密钥配置': 'No keys configured', '暂无账号配置': 'No accounts configured', '点击上方"添加密钥"按钮创建第一个密钥': 'Add your first key to get started', diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 657da96d5..f4d664510 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -3077,6 +3077,8 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string] [/^总可用:(.+)$/u, match => `Total available: ${match[1]}`], [/^余额:(.+)$/u, match => `Balance: ${match[1]}`], [/^oauth\/usage 返回状态码 (\d+)$/u, match => `oauth/usage returned status ${match[1]}`], + [/^共 (\d+) 次机会$/u, match => `${match[1]} ${match[1] === '1' ? 'chance' : 'chances'} in total`], + [/^已处理 (\d+) 个 Key$/u, match => `Processed ${match[1]} ${match[1] === '1' ? 'key' : 'keys'}`], [/^(.+) 秒$/u, match => `${translateLegacyText(match[1], 'en-US')}s`], [/^(.+) 分钟$/u, match => `${translateLegacyText(match[1], 'en-US')} min`], [/^(.+) 小时$/u, match => `${translateLegacyText(match[1], 'en-US')} h`], diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue index 5a063db2a..6cf862b85 100644 --- a/frontend/src/views/admin/PoolManagement.vue +++ b/frontend/src/views/admin/PoolManagement.vue @@ -2274,7 +2274,19 @@ function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string | : readPendingCodexResetCreditIdempotencyKey(key.key_id, generation) } +function getClaudeCodeResetCredits(key: PoolKeyDetail) { + if (getQuotaSnapshotProviderType(key) !== 'claude_code') return null + return key.status_snapshot?.quota?.reset_credits + ?? key.upstream_metadata?.claude_code?.reset_credits + ?? null +} + function getCodexResetCreditCountText(key: PoolKeyDetail): string | null { + const claudeCredits = getClaudeCodeResetCredits(key) + if (claudeCredits) { + const claudeCount = getCodexResetCreditAvailableCount(claudeCredits) + return claudeCount === null ? null : formatCodexResetCreditCount(claudeCount) + } const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key)) return count === null && !getPendingCodexResetCreditIdempotencyKey(key) ? null @@ -2282,7 +2294,11 @@ function getCodexResetCreditCountText(key: PoolKeyDetail): string | null { } function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] { - return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3) + return getVisibleCodexResetCreditItems( + getClaudeCodeResetCredits(key) ?? getCodexResetCredits(key), + undefined, + 3, + ) .map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`) }