mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
Codex 重置功能和显示修复
This commit is contained in:
@@ -51,14 +51,6 @@ fn merge_codex_quota_metadata(
|
||||
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 {
|
||||
let message = message.into();
|
||||
let mut sanitized = message.replace('\n', " ");
|
||||
@@ -85,11 +77,7 @@ fn merge_codex_reset_credit_detail_metadata(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let has_usage_available_count = reset_credits.contains_key("available_count");
|
||||
for (key, value) in detail_reset_credits {
|
||||
if key == "available_count" && has_usage_available_count {
|
||||
continue;
|
||||
}
|
||||
reset_credits.insert(key.clone(), value.clone());
|
||||
}
|
||||
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>,
|
||||
now_unix_secs: u64,
|
||||
) -> 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)
|
||||
{
|
||||
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,
|
||||
})))
|
||||
}
|
||||
|
||||
#[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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
if let Some(items) = value.as_array() {
|
||||
return Some(items);
|
||||
}
|
||||
|
||||
first_json_value_by_paths(
|
||||
value,
|
||||
&[
|
||||
@@ -799,6 +803,10 @@ fn codex_reset_credit_detail_items(value: &serde_json::Value) -> Option<&Vec<ser
|
||||
&["rateLimitResetCredits", "data"],
|
||||
&["reset_credits", "credits"],
|
||||
&["resetCredits", "credits"],
|
||||
&["rate_limit_reset_credits"],
|
||||
&["rateLimitResetCredits"],
|
||||
&["reset_credits"],
|
||||
&["resetCredits"],
|
||||
],
|
||||
)
|
||||
.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)))
|
||||
}
|
||||
|
||||
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> {
|
||||
let object = item.as_object()?;
|
||||
let id = codex_reset_credit_id(object)?;
|
||||
let display_key = codex_reset_credit_display_key(&id)?;
|
||||
if !codex_reset_credit_is_available(object) {
|
||||
return None;
|
||||
}
|
||||
let expires_at = parse_codex_reset_credit_timestamp(
|
||||
object
|
||||
.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();
|
||||
out.insert("id".to_string(), json!(id));
|
||||
out.insert("display_key".to_string(), json!(display_key));
|
||||
if let Some(id) = codex_reset_credit_id(object) {
|
||||
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) {
|
||||
out.insert("status".to_string(), json!(status));
|
||||
}
|
||||
@@ -869,8 +899,35 @@ pub fn parse_codex_wham_reset_credits_detail_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
value.as_object()?;
|
||||
let mut credits = codex_reset_credit_detail_items(value)
|
||||
let root = value.as_object();
|
||||
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()
|
||||
.flatten()
|
||||
.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)
|
||||
});
|
||||
|
||||
let detail_status = if credits.is_empty() {
|
||||
"empty"
|
||||
} else {
|
||||
let detail_status = if available_count.is_some_and(|count| count > 0) {
|
||||
"available"
|
||||
} else {
|
||||
"empty"
|
||||
};
|
||||
let mut reset_credits = serde_json::Map::new();
|
||||
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("credits".to_string(), serde_json::Value::Array(credits));
|
||||
|
||||
if let Some(root) = value.as_object() {
|
||||
if let Some(available_count) = codex_reset_credits_available_count(root).or_else(|| {
|
||||
[
|
||||
"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));
|
||||
}
|
||||
if let Some(available_count) = available_count {
|
||||
reset_credits.insert("available_count".to_string(), json!(available_count));
|
||||
}
|
||||
|
||||
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]
|
||||
fn normalizes_codex_reset_credit_consume_outcome() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getCodexResetCreditsDisplay(key)"
|
||||
v-if="getCodexResetCreditAvailableCount(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">
|
||||
@@ -1014,6 +1014,7 @@ import {
|
||||
} from '@/utils/providerKeyStatus'
|
||||
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
|
||||
import {
|
||||
createCodexResetCreditIdempotencyKey,
|
||||
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
|
||||
formatCodexResetCreditDays,
|
||||
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(
|
||||
result: Awaited<ReturnType<typeof consumeCodexResetCredit>>,
|
||||
): { tone: 'success' | 'warning'; message: string } {
|
||||
|
||||
+19
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createCodexResetCreditIdempotencyKey,
|
||||
formatCodexResetCreditCount,
|
||||
formatCodexResetCreditDays,
|
||||
getCodexResetCreditAvailableCount,
|
||||
@@ -108,4 +109,22 @@ describe('codex reset credit display helpers', () => {
|
||||
expect(formatCodexResetCreditDays(1)).toBe('1天')
|
||||
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} 次机会`
|
||||
}
|
||||
|
||||
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(
|
||||
item: QuotaResetCreditSnapshot,
|
||||
snapshot: QuotaResetCreditsSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user