新增 Codex Spark 额度展示

解析 GPT-5.3-Codex-Spark 额度窗口,并在号池页面和提供商账号抽屉中展示 Spark 周额度与 5H 额度。Spark 额度仅用于展示,不影响普通 Codex 周额度和 5H 额度的调度与筛选逻辑。
This commit is contained in:
Alice
2026-05-09 10:44:45 +08:00
parent fa22384f24
commit 757c264e3e
9 changed files with 394 additions and 19 deletions

View File

@@ -635,9 +635,9 @@ fn preserve_quota_window_usage_state(current_status_snapshot: Option<&Value>, qu
}
fn codex_default_window_minutes(code: &str) -> Option<u64> {
if code.eq_ignore_ascii_case("5h") {
if code.eq_ignore_ascii_case("5h") || code.eq_ignore_ascii_case("spark_5h") {
Some(300)
} else if code.eq_ignore_ascii_case("weekly") {
} else if code.eq_ignore_ascii_case("weekly") || code.eq_ignore_ascii_case("spark_weekly") {
Some(10_080)
} else {
None
@@ -735,6 +735,20 @@ fn build_codex_quota_status_snapshot(
let windows = [
codex_quota_window_snapshot(metadata, "primary", "weekly", "", observed_at_unix_secs),
codex_quota_window_snapshot(metadata, "secondary", "5h", "5H", observed_at_unix_secs),
codex_quota_window_snapshot(
metadata,
"spark_primary",
"spark_5h",
"Spark 5H",
observed_at_unix_secs,
),
codex_quota_window_snapshot(
metadata,
"spark_secondary",
"spark_weekly",
"Spark 周",
observed_at_unix_secs,
),
]
.into_iter()
.flatten()
@@ -750,21 +764,32 @@ fn build_codex_quota_status_snapshot(
return None;
}
let usage_ratio = windows
let primary_windows = windows
.iter()
.filter(|window| {
window
.get("code")
.and_then(Value::as_str)
.is_some_and(|code| code.eq_ignore_ascii_case("weekly") || code.eq_ignore_ascii_case("5h"))
})
.cloned()
.collect::<Vec<_>>();
let usage_ratio = primary_windows
.iter()
.filter_map(Value::as_object)
.filter_map(|window| window.get("used_ratio"))
.filter_map(Value::as_f64)
.max_by(f64::total_cmp);
let reset_seconds = windows
let reset_seconds = primary_windows
.iter()
.filter_map(Value::as_object)
.filter_map(|window| window.get("reset_seconds"))
.filter_map(admin_provider_quota_pure::coerce_json_u64)
.min();
let reset_at = quota_windows_min_reset_at(&windows);
let exhausted_by_credits =
windows.is_empty() && credits_unlimited != Some(true) && credits_has_credits == Some(false);
let reset_at = quota_windows_min_reset_at(&primary_windows);
let exhausted_by_credits = primary_windows.is_empty()
&& credits_unlimited != Some(true)
&& credits_has_credits == Some(false);
let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
let exhausted = exhausted_by_credits || exhausted_by_window;
@@ -1926,6 +1951,48 @@ mod tests {
);
}
#[test]
fn provider_key_status_snapshot_payload_backfills_codex_spark_windows() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"codex": {
"updated_at": 1_775_553_285u64,
"plan_type": "plus",
"primary_used_percent": 55.0,
"primary_reset_at": 1_900_000_000u64,
"secondary_used_percent": 12.5,
"secondary_reset_at": 1_900_500_000u64,
"spark_primary_used_percent": 40.0,
"spark_primary_reset_at": 1_900_100_000u64,
"spark_primary_window_minutes": 300u64,
"spark_secondary_used_percent": 5.0,
"spark_secondary_reset_at": 1_900_600_000u64,
"spark_secondary_window_minutes": 10_080u64
}
}));
let payload = provider_key_status_snapshot_payload(&key, "codex");
let windows = payload["quota"]["windows"]
.as_array()
.expect("quota windows should exist");
let spark_5h = windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("spark_5h")))
.expect("Spark 5H window should exist");
let spark_weekly = windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("spark_weekly")))
.expect("Spark weekly window should exist");
assert_eq!(windows.len(), 4);
assert_eq!(spark_5h.get("label"), Some(&json!("Spark 5H")));
assert_eq!(spark_5h.get("remaining_ratio"), Some(&json!(0.6)));
assert_eq!(spark_weekly.get("label"), Some(&json!("Spark 周")));
assert_eq!(spark_weekly.get("remaining_ratio"), Some(&json!(0.95)));
}
#[test]
fn provider_key_status_snapshot_payload_keeps_codex_free_window_quota_available() {
let mut key = sample_catalog_key();

View File

@@ -9,6 +9,7 @@ const OAUTH_ACCOUNT_BLOCK_PREFIX: &str = "[ACCOUNT_BLOCK] ";
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
const CODEX_SPARK_LIMIT_NAME: &str = "GPT-5.3-Codex-Spark";
pub fn provider_auto_remove_banned_keys(config: Option<&serde_json::Value>) -> bool {
config
@@ -214,6 +215,29 @@ fn codex_write_window(
if let Some(value) = source.get("window_minutes").and_then(coerce_json_u64) {
target.insert(format!("{target_prefix}_window_minutes"), json!(value));
}
if let Some(value) = source
.get("limit_window_seconds")
.and_then(coerce_json_u64)
.map(|seconds| seconds / 60)
{
target.insert(format!("{target_prefix}_window_minutes"), json!(value));
}
}
fn codex_find_spark_rate_limit(
root: &serde_json::Map<String, serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
root.get("additional_rate_limits")
.and_then(serde_json::Value::as_array)?
.iter()
.filter_map(serde_json::Value::as_object)
.find(|item| {
item.get("limit_name")
.and_then(serde_json::Value::as_str)
.is_some_and(|name| name.trim() == CODEX_SPARK_LIMIT_NAME)
})?
.get("rate_limit")
.and_then(serde_json::Value::as_object)
}
pub fn parse_codex_wham_usage_response(
@@ -256,6 +280,21 @@ pub fn parse_codex_wham_usage_response(
codex_write_window(&mut result, &primary_window, "primary");
}
if let Some(spark_rate_limit) = codex_find_spark_rate_limit(root) {
if let Some(primary_window) = spark_rate_limit
.get("primary_window")
.and_then(serde_json::Value::as_object)
{
codex_write_window(&mut result, primary_window, "spark_primary");
}
if let Some(secondary_window) = spark_rate_limit
.get("secondary_window")
.and_then(serde_json::Value::as_object)
{
codex_write_window(&mut result, secondary_window, "spark_secondary");
}
}
if let Some(credits) = root.get("credits").and_then(serde_json::Value::as_object) {
if let Some(value) = credits.get("has_credits").and_then(coerce_json_bool) {
result.insert("has_credits".to_string(), json!(value));
@@ -874,8 +913,9 @@ pub fn parse_chatgpt_web_conversation_init_response(
mod tests {
use super::{
codex_build_invalid_state, codex_runtime_invalid_reason,
parse_chatgpt_web_conversation_init_response, OAUTH_ACCOUNT_BLOCK_PREFIX,
OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
parse_chatgpt_web_conversation_init_response, parse_codex_wham_usage_response,
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
OAUTH_REQUEST_FAILED_PREFIX,
};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::json;
@@ -970,6 +1010,54 @@ mod tests {
);
}
#[test]
fn parses_codex_spark_quota_from_additional_rate_limits() {
let parsed = parse_codex_wham_usage_response(
&json!({
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 25.0,
"reset_after_seconds": 604800,
"reset_at": 1_900_000_000u64
},
"secondary_window": {
"used_percent": 10.0,
"reset_after_seconds": 18000,
"reset_at": 1_800_000_000u64
}
},
"additional_rate_limits": [{
"limit_name": "GPT-5.3-Codex-Spark",
"metered_feature": "codex_bengalfox",
"rate_limit": {
"primary_window": {
"used_percent": 40.0,
"limit_window_seconds": 18000,
"reset_after_seconds": 9000,
"reset_at": 1_780_000_000u64
},
"secondary_window": {
"used_percent": 5.0,
"limit_window_seconds": 604800,
"reset_after_seconds": 300000,
"reset_at": 1_790_000_000u64
}
}
}]
}),
1_777_000_000,
)
.expect("codex wham usage should parse");
assert_eq!(parsed.get("primary_used_percent"), Some(&json!(10.0)));
assert_eq!(parsed.get("secondary_used_percent"), Some(&json!(25.0)));
assert_eq!(parsed.get("spark_primary_used_percent"), Some(&json!(40.0)));
assert_eq!(parsed.get("spark_primary_window_minutes"), Some(&json!(300u64)));
assert_eq!(parsed.get("spark_secondary_used_percent"), Some(&json!(5.0)));
assert_eq!(parsed.get("spark_secondary_window_minutes"), Some(&json!(10_080u64)));
}
#[test]
fn parses_chatgpt_web_image_quota_from_conversation_init() {
let parsed = parse_chatgpt_web_conversation_init_response(

View File

@@ -315,6 +315,16 @@ export interface CodexUpstreamMetadata {
secondary_reset_after_seconds?: number // 5H限额重置剩余秒数兼容字段
secondary_reset_at?: number // 5H限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 5H限额窗口大小分钟
spark_primary_used_percent?: number // Spark 5H限额窗口使用百分比
spark_primary_reset_seconds?: number // Spark 5H限额重置剩余秒数
spark_primary_reset_after_seconds?: number // Spark 5H限额重置剩余秒数兼容字段
spark_primary_reset_at?: number // Spark 5H限额重置时间Unix 时间戳)
spark_primary_window_minutes?: number // Spark 5H限额窗口大小分钟
spark_secondary_used_percent?: number // Spark 周限额窗口使用百分比
spark_secondary_reset_seconds?: number // Spark 周限额重置剩余秒数
spark_secondary_reset_after_seconds?: number // Spark 周限额重置剩余秒数(兼容字段)
spark_secondary_reset_at?: number // Spark 周限额重置时间Unix 时间戳)
spark_secondary_window_minutes?: number // Spark 周限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
}

View File

@@ -29,10 +29,12 @@ describe('quota selectors', () => {
it('detects only depleted weekly segments', () => {
expect(hasNoWeeklyLimit('周剩余 0.0%5天后重置 | 5H剩余 93.0%2小时后重置')).toBe(true)
expect(hasNoWeeklyLimit('周剩余 45.0%5天后重置 | 5H剩余 0.0%2小时后重置')).toBe(false)
expect(hasNoWeeklyLimit('周剩余 45.0% | Spark周剩余 0.0%')).toBe(false)
})
it('detects only depleted 5h segments', () => {
expect(hasNoFiveHourLimit('周剩余 45.0%5天后重置 | 5H剩余 0.0%2小时后重置')).toBe(true)
expect(hasNoFiveHourLimit('周剩余 0.0%5天后重置 | 5H剩余 93.0%2小时后重置')).toBe(false)
expect(hasNoFiveHourLimit('5H剩余 93.0% | Spark5H剩余 0.0%')).toBe(false)
})
})

View File

@@ -38,14 +38,20 @@ export function isDepletedQuotaSegment(segment: string): boolean {
return false
}
function isSparkQuotaSegment(segment: string): boolean {
return /spark/.test(segment)
}
export function hasNoFiveHourLimit(accountQuota: string | null | undefined): boolean {
return getQuotaSegments(accountQuota)
.filter((segment) => !isSparkQuotaSegment(segment))
.filter((segment) => /5h|5小时/.test(segment))
.some(isDepletedQuotaSegment)
}
export function hasNoWeeklyLimit(accountQuota: string | null | undefined): boolean {
return getQuotaSegments(accountQuota)
.filter((segment) => !isSparkQuotaSegment(segment))
.filter((segment) => /周|weekly|week/.test(segment))
.some(isDepletedQuotaSegment)
}

View File

@@ -576,7 +576,7 @@
</span>
</div>
</div>
<!-- 限额并排显示Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
<!-- 普通 Codex 限额并排显示Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
<div
class="grid gap-3"
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
@@ -653,6 +653,89 @@
</div>
</div>
</div>
<!-- Spark 限额独立一行展示,避免与普通 Codex 周/5H 混淆 -->
<div
v-if="hasCodexSparkQuotaDisplayData(key)"
class="mt-3 border-t border-border/60 pt-2"
>
<div class="mb-1 text-[10px] text-muted-foreground">GPT-5.3 Codex Spark</div>
<div class="grid gap-3 grid-cols-2">
<div v-if="getCodexQuotaDisplay(key)?.spark_secondary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">Spark 周</span>
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)">
{{ (100 - (getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0), 0)}%` }"
/>
</div>
<div
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
class="text-[9px] mt-0.5 tabular-nums"
:class="getResetCountdownClass(
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
)"
>
<template v-if="getCodexQuotaDisplay(key)?.spark_secondary_reset_at || getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds">
{{ getResetCountdownText(
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
) }}
</template>
<template v-else>
已重置
</template>
</div>
</div>
<div v-if="getCodexQuotaDisplay(key)?.spark_primary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">Spark 5H</span>
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)">
{{ (100 - (getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0), 0)}%` }"
/>
</div>
<div
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
class="text-[9px] mt-0.5 tabular-nums"
:class="getResetCountdownClass(
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_primary_used_percent
)"
>
<template v-if="getCodexQuotaDisplay(key)?.spark_primary_reset_at || getCodexQuotaDisplay(key)?.spark_primary_reset_seconds">
{{ getResetCountdownText(
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
getCodexQuotaDisplay(key)?.updated_at,
getCodexQuotaDisplay(key)?.spark_primary_used_percent
) }}
</template>
<template v-else>
已重置
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
<div
@@ -1997,6 +2080,28 @@ function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null
display.secondary_window_minutes = secondaryWindow.window_minutes
}
const sparkPrimaryWindow = getQuotaWindow(quota, 'spark_5h')
const sparkPrimaryUsedPercent = getQuotaWindowUsedPercent(sparkPrimaryWindow)
if (sparkPrimaryUsedPercent !== undefined) display.spark_primary_used_percent = sparkPrimaryUsedPercent
const sparkPrimaryResetAt = getQuotaWindowResetAt(sparkPrimaryWindow)
if (sparkPrimaryResetAt !== undefined) display.spark_primary_reset_at = sparkPrimaryResetAt
const sparkPrimaryResetSeconds = getQuotaWindowResetSeconds(sparkPrimaryWindow)
if (sparkPrimaryResetSeconds !== undefined) display.spark_primary_reset_seconds = sparkPrimaryResetSeconds
if (typeof sparkPrimaryWindow?.window_minutes === 'number') {
display.spark_primary_window_minutes = sparkPrimaryWindow.window_minutes
}
const sparkSecondaryWindow = getQuotaWindow(quota, 'spark_weekly')
const sparkSecondaryUsedPercent = getQuotaWindowUsedPercent(sparkSecondaryWindow)
if (sparkSecondaryUsedPercent !== undefined) display.spark_secondary_used_percent = sparkSecondaryUsedPercent
const sparkSecondaryResetAt = getQuotaWindowResetAt(sparkSecondaryWindow)
if (sparkSecondaryResetAt !== undefined) display.spark_secondary_reset_at = sparkSecondaryResetAt
const sparkSecondaryResetSeconds = getQuotaWindowResetSeconds(sparkSecondaryWindow)
if (sparkSecondaryResetSeconds !== undefined) display.spark_secondary_reset_seconds = sparkSecondaryResetSeconds
if (typeof sparkSecondaryWindow?.window_minutes === 'number') {
display.spark_secondary_window_minutes = sparkSecondaryWindow.window_minutes
}
return Object.keys(display).length > 0 ? display : null
}
@@ -2005,6 +2110,16 @@ function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
return !!codex && (
codex.primary_used_percent !== undefined
|| codex.secondary_used_percent !== undefined
|| codex.spark_primary_used_percent !== undefined
|| codex.spark_secondary_used_percent !== undefined
)
}
function hasCodexSparkQuotaDisplayData(key: EndpointAPIKey): boolean {
const codex = getCodexQuotaDisplay(key)
return !!codex && (
codex.spark_primary_used_percent !== undefined
|| codex.spark_secondary_used_percent !== undefined
)
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { getQuotaDisplayText } from '../providerKeyQuota'
describe('providerKeyQuota', () => {
it('includes Codex Spark quota windows in display text', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: {
code: 'valid',
},
account: {
code: 'ok',
blocked: false,
},
quota: {
provider_type: 'codex',
code: 'ok',
exhausted: false,
windows: [
{
code: 'weekly',
remaining_ratio: 0.9,
},
{
code: '5h',
remaining_ratio: 0.8,
},
{
code: 'spark_5h',
remaining_ratio: 0.6,
},
{
code: 'spark_weekly',
remaining_ratio: 0.95,
},
],
},
},
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
})
})

View File

@@ -96,7 +96,12 @@ function formatQuotaValue(value: number | null | undefined): string {
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
const parts: string[] = []
for (const [label, code] of [['周', 'weekly'], ['5H', '5h']] as const) {
for (const [label, code] of [
['周', 'weekly'],
['5H', '5h'],
['Spark5H', 'spark_5h'],
['Spark周', 'spark_weekly'],
] as const) {
const remainingPercent = getQuotaWindowRemainingPercent(getQuotaWindow(quota, code))
if (remainingPercent == null) continue
parts.push(`${label}剩余 ${formatPercent(remainingPercent)}`)

View File

@@ -549,7 +549,7 @@
class="max-w-[208px] space-y-2"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-${idx}`"
class="flex flex-col gap-1 min-w-[140px] max-w-[208px]"
>
@@ -2064,6 +2064,27 @@ function refreshOverviewInBackground(): void {
void loadOverview()
}
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
const successfulResults = Array.isArray(result.results)
? result.results.filter((item) => item.status === 'success' && item.quota_snapshot)
: []
if (successfulResults.length === 0) return
const quotaByKeyId = new Map(successfulResults.map((item) => [item.key_id, item.quota_snapshot!]))
keyPage.value.keys = keyPage.value.keys.map((key) => {
const quotaSnapshot = quotaByKeyId.get(key.key_id)
if (!quotaSnapshot) return key
return {
...key,
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
status_snapshot: {
...(key.status_snapshot ?? {}),
quota: quotaSnapshot,
},
}
})
}
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
const value = Number(raw ?? 0)
if (!Number.isFinite(value) || value <= 0) return null
@@ -2131,6 +2152,7 @@ async function refreshCurrentPageQuotaInBackground(
refreshingCurrentPageQuota.value = true
try {
const result = await refreshProviderQuota(providerId, quotaStats.eligibleIds)
applyQuotaRefreshResultToCurrentPage(result)
const successCount = Number(result.success || 0)
const failedCount = Number(result.failed || 0)
const skippedCount = Math.max(quotaStats.total - quotaStats.eligibleIds.length, 0)
@@ -3118,6 +3140,8 @@ function getAccountAlertTitle(key: PoolKeyDetail): string {
function normalizeQuotaLabel(label: string): string {
const normalized = label.trim()
if (!normalized) return '额度'
if (/spark\s*5h/i.test(normalized) || normalized.includes('Spark5H')) return 'Spark5H'
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
if (normalized.includes('5H')) return '5H'
if (normalized.includes('周')) return '周'
if (normalized.includes('最低剩余')) return '最低'
@@ -3128,13 +3152,15 @@ function normalizeQuotaLabel(label: string): string {
function getQuotaProgressLabel(label: string): string {
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
if (label === '剩余') return '剩余'
return label
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (item.label !== '5H' && item.label !== '周') return null
if (!['5H', '周', 'Spark5H', 'Spark周'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -3180,9 +3206,11 @@ function getQuotaFallbackText(key: PoolKeyDetail): string | null {
function getQuotaLabelOrder(label: string): number {
if (label === '5H') return 0
if (label === '周') return 1
if (label === '剩余') return 2
if (label === '最低') return 3
if (label === '生图') return 4
if (label === 'Spark5H') return 2
if (label === 'Spark周') return 3
if (label === '剩余') return 4
if (label === '最低') return 5
if (label === '生图') return 6
return 10
}
@@ -3296,7 +3324,12 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
if (providerType === 'codex') {
const items: QuotaProgressItem[] = []
for (const [label, code] of [['5H', '5h'], ['周', 'weekly']] as const) {
for (const [label, code] of [
['5H', '5h'],
['周', 'weekly'],
['Spark5H', 'spark_5h'],
['Spark周', 'spark_weekly'],
] as const) {
const window = getQuotaSnapshotWindow(quota, code)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) continue
@@ -3405,10 +3438,17 @@ function resolveCodexQuotaCountdown(
key: PoolKeyDetail,
label: string
): Pick<QuotaProgressItem, 'resetAtSeconds' | 'resetSeconds' | 'updatedAtSeconds'> | null {
if (label !== '5H' && label !== '周') return null
const codexWindowCodeByLabel: Record<string, string> = {
'5H': '5h',
'周': 'weekly',
Spark5H: 'spark_5h',
Spark周: 'spark_weekly',
}
const windowCode = codexWindowCodeByLabel[label]
if (!windowCode) return null
const codexSnapshot = getCodexQuotaSnapshot(key)
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, label === '周' ? 'weekly' : '5h')
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, windowCode)
if (!snapshotWindow) return null
const resetAtSeconds = normalizeUnixSeconds(snapshotWindow.reset_at ?? null)