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)
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if let Some(display_key) = codex_reset_credit_display_key(&id) {
|
||||||
out.insert("display_key".to_string(), json!(display_key));
|
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,21 +949,9 @@ 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(|| {
|
|
||||||
[
|
|
||||||
"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));
|
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 } {
|
||||||
|
|||||||
+19
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user