Codex 重置功能和显示修复

This commit is contained in:
ZheFox
2026-07-16 17:23:41 +08:00
parent 1333efdad5
commit 5cf38d1b35
5 changed files with 199 additions and 48 deletions
@@ -51,14 +51,6 @@ fn merge_codex_quota_metadata(
serde_json::Value::Object(merged) serde_json::Value::Object(merged)
} }
fn codex_reset_credits_available_count(metadata: &Map<String, Value>) -> Option<u64> {
metadata
.get("reset_credits")
.and_then(Value::as_object)
.and_then(|reset_credits| reset_credits.get("available_count"))
.and_then(aether_admin::provider::quota::coerce_json_u64)
}
fn truncate_codex_reset_credit_detail_error(message: impl Into<String>) -> String { fn truncate_codex_reset_credit_detail_error(message: impl Into<String>) -> String {
let message = message.into(); let message = message.into();
let mut sanitized = message.replace('\n', " "); let mut sanitized = message.replace('\n', " ");
@@ -85,11 +77,7 @@ fn merge_codex_reset_credit_detail_metadata(
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let has_usage_available_count = reset_credits.contains_key("available_count");
for (key, value) in detail_reset_credits { for (key, value) in detail_reset_credits {
if key == "available_count" && has_usage_available_count {
continue;
}
reset_credits.insert(key.clone(), value.clone()); reset_credits.insert(key.clone(), value.clone());
} }
codex_metadata.insert("reset_credits".to_string(), Value::Object(reset_credits)); codex_metadata.insert("reset_credits".to_string(), Value::Object(reset_credits));
@@ -124,11 +112,6 @@ async fn enrich_codex_reset_credit_details(
codex_metadata: &mut Map<String, Value>, codex_metadata: &mut Map<String, Value>,
now_unix_secs: u64, now_unix_secs: u64,
) -> Result<(), GatewayError> { ) -> Result<(), GatewayError> {
let available_count = codex_reset_credits_available_count(codex_metadata).unwrap_or(0);
if available_count == 0 {
return Ok(());
}
let request_spec = match build_codex_reset_credits_request_spec(transport, resolved_oauth_auth) let request_spec = match build_codex_reset_credits_request_spec(transport, resolved_oauth_auth)
{ {
Ok(request_spec) => request_spec, Ok(request_spec) => request_spec,
@@ -776,3 +759,37 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
"auto_removed_hard_banned": auto_removed_hard_banned_count, "auto_removed_hard_banned": auto_removed_hard_banned_count,
}))) })))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_reset_credit_detail_count_overrides_usage_count() {
let mut metadata = json!({
"reset_credits": {
"available_count": 0,
"detail_source": "wham_usage"
}
})
.as_object()
.cloned()
.expect("metadata object");
let detail = json!({
"reset_credits": {
"available_count": 2,
"detail_source": "wham_readonly"
}
});
merge_codex_reset_credit_detail_metadata(&mut metadata, &detail);
assert_eq!(
metadata
.get("reset_credits")
.and_then(Value::as_object)
.and_then(|credits| credits.get("available_count")),
Some(&json!(2u64))
);
}
}
+123 -23
View File
@@ -787,6 +787,10 @@ fn parse_codex_reset_credit_timestamp(value: Option<&serde_json::Value>) -> Opti
} }
fn codex_reset_credit_detail_items(value: &serde_json::Value) -> Option<&Vec<serde_json::Value>> { fn codex_reset_credit_detail_items(value: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
if let Some(items) = value.as_array() {
return Some(items);
}
first_json_value_by_paths( first_json_value_by_paths(
value, value,
&[ &[
@@ -799,6 +803,10 @@ fn codex_reset_credit_detail_items(value: &serde_json::Value) -> Option<&Vec<ser
&["rateLimitResetCredits", "data"], &["rateLimitResetCredits", "data"],
&["reset_credits", "credits"], &["reset_credits", "credits"],
&["resetCredits", "credits"], &["resetCredits", "credits"],
&["rate_limit_reset_credits"],
&["rateLimitResetCredits"],
&["reset_credits"],
&["resetCredits"],
], ],
) )
.and_then(serde_json::Value::as_array) .and_then(serde_json::Value::as_array)
@@ -833,10 +841,28 @@ fn codex_reset_credit_status(
.find_map(|key| coerce_json_string(object.get(*key))) .find_map(|key| coerce_json_string(object.get(*key)))
} }
fn codex_reset_credit_is_available(object: &serde_json::Map<String, serde_json::Value>) -> bool {
let reset_type = ["reset_type", "resetType"]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)));
if reset_type
.as_deref()
.is_some_and(|value| !value.trim().eq_ignore_ascii_case("codex_rate_limits"))
{
return false;
}
codex_reset_credit_status(object).is_none_or(|status| {
let status = status.trim();
status.eq_ignore_ascii_case("available") || status.eq_ignore_ascii_case("active")
})
}
fn parse_codex_reset_credit_detail_item(item: &serde_json::Value) -> Option<serde_json::Value> { fn parse_codex_reset_credit_detail_item(item: &serde_json::Value) -> Option<serde_json::Value> {
let object = item.as_object()?; let object = item.as_object()?;
let id = codex_reset_credit_id(object)?; if !codex_reset_credit_is_available(object) {
let display_key = codex_reset_credit_display_key(&id)?; return None;
}
let expires_at = parse_codex_reset_credit_timestamp( let expires_at = parse_codex_reset_credit_timestamp(
object object
.get("expires_at") .get("expires_at")
@@ -853,8 +879,12 @@ fn parse_codex_reset_credit_detail_item(item: &serde_json::Value) -> Option<serd
); );
let mut out = serde_json::Map::new(); let mut out = serde_json::Map::new();
out.insert("id".to_string(), json!(id)); if let Some(id) = codex_reset_credit_id(object) {
out.insert("display_key".to_string(), json!(display_key)); if let Some(display_key) = codex_reset_credit_display_key(&id) {
out.insert("display_key".to_string(), json!(display_key));
}
out.insert("id".to_string(), json!(id));
}
if let Some(status) = codex_reset_credit_status(object) { if let Some(status) = codex_reset_credit_status(object) {
out.insert("status".to_string(), json!(status)); out.insert("status".to_string(), json!(status));
} }
@@ -869,8 +899,35 @@ pub fn parse_codex_wham_reset_credits_detail_response(
value: &serde_json::Value, value: &serde_json::Value,
updated_at_unix_secs: u64, updated_at_unix_secs: u64,
) -> Option<serde_json::Value> { ) -> Option<serde_json::Value> {
value.as_object()?; let root = value.as_object();
let mut credits = codex_reset_credit_detail_items(value) let detail_items = codex_reset_credit_detail_items(value);
if root.is_none() && detail_items.is_none() {
return None;
}
let available_item_count = detail_items
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_object)
.filter(|item| codex_reset_credit_is_available(item))
.count();
let available_count = root
.and_then(codex_reset_credits_available_count)
.or_else(|| {
root.and_then(|root| {
[
"available_count",
"availableCount",
"available",
"remaining",
"count",
]
.iter()
.find_map(|key| root.get(*key).and_then(coerce_json_u64))
})
})
.or_else(|| detail_items.and_then(|_| u64::try_from(available_item_count).ok()));
let mut credits = detail_items
.into_iter() .into_iter()
.flatten() .flatten()
.filter_map(parse_codex_reset_credit_detail_item) .filter_map(parse_codex_reset_credit_detail_item)
@@ -881,10 +938,10 @@ pub fn parse_codex_wham_reset_credits_detail_response(
.unwrap_or(u64::MAX) .unwrap_or(u64::MAX)
}); });
let detail_status = if credits.is_empty() { let detail_status = if available_count.is_some_and(|count| count > 0) {
"empty"
} else {
"available" "available"
} else {
"empty"
}; };
let mut reset_credits = serde_json::Map::new(); let mut reset_credits = serde_json::Map::new();
reset_credits.insert("updated_at".to_string(), json!(updated_at_unix_secs)); reset_credits.insert("updated_at".to_string(), json!(updated_at_unix_secs));
@@ -892,20 +949,8 @@ pub fn parse_codex_wham_reset_credits_detail_response(
reset_credits.insert("detail_status".to_string(), json!(detail_status)); reset_credits.insert("detail_status".to_string(), json!(detail_status));
reset_credits.insert("credits".to_string(), serde_json::Value::Array(credits)); reset_credits.insert("credits".to_string(), serde_json::Value::Array(credits));
if let Some(root) = value.as_object() { if let Some(available_count) = available_count {
if let Some(available_count) = codex_reset_credits_available_count(root).or_else(|| { reset_credits.insert("available_count".to_string(), json!(available_count));
[
"available_count",
"availableCount",
"available",
"remaining",
"count",
]
.iter()
.find_map(|key| root.get(*key).and_then(coerce_json_u64))
}) {
reset_credits.insert("available_count".to_string(), json!(available_count));
}
} }
Some(json!({ "reset_credits": reset_credits })) Some(json!({ "reset_credits": reset_credits }))
@@ -2534,6 +2579,61 @@ mod tests {
); );
} }
#[test]
fn parses_codex_reset_credit_detail_without_explicit_count_or_ids() {
let parsed = parse_codex_wham_reset_credits_detail_response(
&json!({
"rate_limit_reset_credits": [
{
"resetType": "codex_rate_limits",
"status": "available",
"expiresAt": "2030-01-02T00:00:00Z"
},
{
"reset_type": "codex_rate_limits",
"status": "available"
},
{
"reset_type": "codex_rate_limits",
"status": "redeemed",
"expires_at": "2030-01-03T00:00:00Z"
}
]
}),
1_777_000_000,
)
.expect("detail array should parse");
assert_eq!(
parsed.pointer("/reset_credits/available_count"),
Some(&json!(2u64))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/0/expires_at"),
Some(&json!(1_893_542_400u64))
);
assert_eq!(parsed.pointer("/reset_credits/credits/0/id"), None);
}
#[test]
fn parses_codex_reset_credit_detail_from_top_level_array() {
let parsed = parse_codex_wham_reset_credits_detail_response(
&json!([
{
"status": "available",
"expires_at": "2030-01-04T00:00:00Z"
}
]),
1_777_000_000,
)
.expect("top-level detail array should parse");
assert_eq!(
parsed.pointer("/reset_credits/available_count"),
Some(&json!(1u64))
);
}
#[test] #[test]
fn normalizes_codex_reset_credit_consume_outcome() { fn normalizes_codex_reset_credit_consume_outcome() {
assert_eq!( assert_eq!(
@@ -279,7 +279,7 @@
</div> </div>
</div> </div>
<div <div
v-if="getCodexResetCreditsDisplay(key)" v-if="getCodexResetCreditAvailableCount(key) !== null"
class="mt-3 border-t border-border/60 pt-2" 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"> <div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-[10px] leading-4 text-muted-foreground">
@@ -1014,6 +1014,7 @@ import {
} from '@/utils/providerKeyStatus' } from '@/utils/providerKeyStatus'
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota' import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
import { import {
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount as formatCodexResetCreditCountLabel, formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
formatCodexResetCreditDays, formatCodexResetCreditDays,
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot, getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
@@ -1670,13 +1671,6 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
} }
} }
function createCodexResetCreditIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
throw new Error('浏览器不支持 crypto.randomUUID,无法生成安全幂等 ID')
}
function codexResetCreditOutcomeFeedback( function codexResetCreditOutcomeFeedback(
result: Awaited<ReturnType<typeof consumeCodexResetCredit>>, result: Awaited<ReturnType<typeof consumeCodexResetCredit>>,
): { tone: 'success' | 'warning'; message: string } { ): { tone: 'success' | 'warning'; message: string } {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount, formatCodexResetCreditCount,
formatCodexResetCreditDays, formatCodexResetCreditDays,
getCodexResetCreditAvailableCount, getCodexResetCreditAvailableCount,
@@ -108,4 +109,22 @@ describe('codex reset credit display helpers', () => {
expect(formatCodexResetCreditDays(1)).toBe('1天') expect(formatCodexResetCreditDays(1)).toBe('1天')
expect(formatCodexResetCreditDays(86_401)).toBe('2天') expect(formatCodexResetCreditDays(86_401)).toBe('2天')
}) })
it('generates a UUID v4 with secure random bytes when randomUUID is unavailable', () => {
const idempotencyKey = createCodexResetCreditIdempotencyKey({
getRandomValues(array) {
array.set(Array.from({ length: 16 }, (_, index) => index))
return array
},
})
expect(idempotencyKey).toBe('00010203-0405-4607-8809-0a0b0c0d0e0f')
})
it('prefers the browser randomUUID implementation when available', () => {
expect(createCodexResetCreditIdempotencyKey({
randomUUID: () => 'existing-random-uuid',
getRandomValues: array => array,
})).toBe('existing-random-uuid')
})
}) })
@@ -61,6 +61,27 @@ export function formatCodexResetCreditCount(count: number | null | undefined): s
return `共 ${count ?? 0} 次机会` return `共 ${count ?? 0} 次机会`
} }
interface CodexResetCreditCrypto {
randomUUID?: () => string
getRandomValues: (array: Uint8Array) => Uint8Array
}
export function createCodexResetCreditIdempotencyKey(
cryptoSource: CodexResetCreditCrypto | undefined = globalThis.crypto,
): string {
const randomUUID = cryptoSource?.randomUUID?.bind(cryptoSource)
if (randomUUID) return randomUUID()
if (!cryptoSource) {
throw new Error('浏览器不支持安全随机数,无法生成幂等 ID')
}
const bytes = cryptoSource.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function codexResetCreditRemainingSeconds( function codexResetCreditRemainingSeconds(
item: QuotaResetCreditSnapshot, item: QuotaResetCreditSnapshot,
snapshot: QuotaResetCreditsSnapshot, snapshot: QuotaResetCreditsSnapshot,