feat(claude-code): 只读展示重置机会(cedar_ember)并补齐额度文案国际化

This commit is contained in:
dalamudx
2026-09-29 23:55:27 +08:00
parent 125cd40aa5
commit b49f5c0fd7
11 changed files with 211 additions and 6 deletions
@@ -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]
+112
View File
@@ -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<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`]).
///
@@ -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([
+4 -1
View File
@@ -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
@@ -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,
@@ -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 {
@@ -441,6 +441,33 @@
</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 上游模型配额 -->
<div
@@ -3646,6 +3673,25 @@ function getClaudeCodeQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
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
}
@@ -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
})
}
@@ -1,4 +1,7 @@
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',
+1
View File
@@ -1420,6 +1420,7 @@ export const legacyUiEnglishMessages: Record<string, string> = {
'批量导入': 'Bulk import',
'继续确认重置': 'Continue checking reset',
'临近过期': 'Expiring soon',
'Claude 重置机会': 'Claude quota reset',
'暂无密钥配置': 'No keys configured',
'暂无账号配置': 'No accounts configured',
'点击上方"添加密钥"按钮创建第一个密钥': 'Add your first key to get started',
+2
View File
@@ -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`],
+17 -1
View File
@@ -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)}`)
}