mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
新增 Codex Spark 额度展示
解析 GPT-5.3-Codex-Spark 额度窗口,并在号池页面和提供商账号抽屉中展示 Spark 周额度与 5H 额度。Spark 额度仅用于展示,不影响普通 Codex 周额度和 5H 额度的调度与筛选逻辑。
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 // 积分余额
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
42
frontend/src/utils/__tests__/providerKeyQuota.spec.ts
Normal file
42
frontend/src/utils/__tests__/providerKeyQuota.spec.ts
Normal 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%')
|
||||
})
|
||||
})
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user