fix(codex): fence concurrent quota updates

This commit is contained in:
elky
2026-08-14 09:28:07 +08:00
parent f3a12c1008
commit 5b0c763086
65 changed files with 13009 additions and 738 deletions
@@ -292,7 +292,7 @@
</div>
</div>
<div
v-if="getCodexResetCreditAvailableCount(key) !== null"
v-if="getCodexResetCreditAvailableCount(key) !== null || hasPendingCodexResetCredit(key)"
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">
@@ -303,7 +303,9 @@
:disabled="consumingCodexResetCreditKeyId === key.id"
@click="handleConsumeCodexResetCredit(key)"
>
{{ consumingCodexResetCreditKeyId === key.id ? legacyT('重置中...') : legacyT('点击以进行重置') }}
{{ consumingCodexResetCreditKeyId === key.id
? legacyT('重置中...')
: legacyT(hasPendingCodexResetCredit(key) ? '继续确认重置' : '点击以进行重置') }}
</button>
<span
v-else
@@ -1044,12 +1046,16 @@ import {
} from '@/utils/providerKeyStatus'
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
import {
clearPendingCodexResetCreditIdempotencyKeyForOutcome,
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
formatCodexResetCreditExpiresAt,
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
getCodexResetCreditReservationIdempotencyKey,
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
mergeCodexQuotaDisplays,
readPendingCodexResetCreditIdempotencyKey,
rememberPendingCodexResetCreditIdempotencyKey,
} from './codex-reset-credit-display'
// 扩展端点类型,包含密钥列表
@@ -1741,16 +1747,36 @@ function codexResetCreditOutcomeFeedback(
}
}
function codexResetCreditActiveIdempotencyKeyFromError(error: unknown): string | null {
if (typeof error !== 'object' || error === null || !('response' in error)) return null
const response = (error as { response?: { data?: unknown } }).response
if (typeof response?.data !== 'object' || response.data === null) return null
const activeKey = (response.data as Record<string, unknown>).active_idempotency_key
return typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : null
}
function codexResetCreditCredentialChangedFromError(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('response' in error)) return false
const response = (error as { response?: { data?: unknown } }).response
if (typeof response?.data !== 'object' || response.data === null) return false
return (response.data as Record<string, unknown>).outcome === 'credential_changed'
}
async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
if (!canConsumeCodexResetCredit(key)) return
const credentialGeneration = getCodexCredentialGeneration(key)
if (credentialGeneration === undefined) return
const pendingIdempotencyKey = getPendingCodexResetCreditIdempotencyKey(key)
const earliest = getVisibleCodexResetCreditItems(key)[0]
const detailMessage = earliest
? `\n当前最早过期项:${earliest.displayKey},${formatCodexResetCreditExpiresAt(earliest.expiresAt)} 过期。`
: ''
const confirmed = await confirm({
title: legacyT('确认使用 Codex 重置机会'),
message: `${legacyT('将消耗 1 次 Codex 重置机会。操作完成后会重新刷新账号配额状态。')}${detailMessage}`,
message: pendingIdempotencyKey
? legacyT('将继续确认上次尚未完成的 Codex 重置请求。')
: `${legacyT('将消耗 1 次 Codex 重置机会。操作完成后会重新刷新账号配额状态。')}${detailMessage}`,
confirmText: legacyT('确认重置'),
cancelText: legacyT('取消'),
variant: 'warning',
@@ -1759,10 +1785,15 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
consumingCodexResetCreditKeyId.value = key.id
try {
const idempotencyKey = createCodexResetCreditIdempotencyKey()
const idempotencyKey = pendingIdempotencyKey
|| readPendingCodexResetCreditIdempotencyKey(key.id, credentialGeneration)
|| createCodexResetCreditIdempotencyKey()
rememberPendingCodexResetCreditIdempotencyKey(key.id, idempotencyKey, credentialGeneration)
const result = await consumeCodexResetCredit(key.id, {
idempotency_key: idempotencyKey,
expected_credential_generation: credentialGeneration,
})
clearPendingCodexResetCreditIdempotencyKeyForOutcome(key.id, result.outcome)
applyQuotaResults([{
key_id: result.key_id,
status: result.refresh_status === 'success' ? 'success' : result.status,
@@ -1781,6 +1812,16 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
}
emit('refresh')
} catch (err: unknown) {
const activeIdempotencyKey = codexResetCreditActiveIdempotencyKeyFromError(err)
if (codexResetCreditCredentialChangedFromError(err)) {
clearPendingCodexResetCreditIdempotencyKey(key.id)
} else if (activeIdempotencyKey) {
rememberPendingCodexResetCreditIdempotencyKey(
key.id,
activeIdempotencyKey,
credentialGeneration,
)
}
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh')
@@ -1909,6 +1950,9 @@ function getCodexQuotaDisplayFromMetadata(metadata: CodexUpstreamMetadata | null
if (!metadata) return null
const display: CodexUpstreamMetadata = {}
if (metadata.credential_generation?.trim()) {
display.credential_generation = metadata.credential_generation.trim()
}
if (metadata.plan_type) display.plan_type = metadata.plan_type
const numberFields: (keyof CodexUpstreamMetadata)[] = [
@@ -1938,6 +1982,9 @@ function getCodexQuotaDisplayFromMetadata(metadata: CodexUpstreamMetadata | null
numberFields.forEach(field => copyCodexNumberField(display, metadata, field))
if (metadata.has_credits !== undefined) display.has_credits = metadata.has_credits
if (metadata.reset_credits) display.reset_credits = metadata.reset_credits
if (metadata.account_quota_reset_reservation) {
display.account_quota_reset_reservation = metadata.account_quota_reset_reservation
}
return Object.keys(display).length > 0 ? display : null
}
@@ -2025,6 +2072,7 @@ function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|| codex.spark_primary_used_percent !== undefined
|| codex.spark_secondary_used_percent !== undefined
|| codexDisplayHasResetCredits(codex)
|| hasPendingCodexResetCredit(key)
)
}
@@ -2052,10 +2100,29 @@ function getVisibleCodexResetCreditItems(key: EndpointAPIKey) {
return getVisibleCodexResetCreditItemsFromSnapshot(getCodexResetCreditsDisplay(key))
}
function getPendingCodexResetCreditIdempotencyKey(key: EndpointAPIKey): string | null {
const serverReservation = getCodexResetCreditReservationIdempotencyKey(getCodexQuotaDisplay(key))
if (serverReservation) return serverReservation
const credentialGeneration = getCodexCredentialGeneration(key)
return credentialGeneration === undefined
? null
: readPendingCodexResetCreditIdempotencyKey(key.id, credentialGeneration)
}
function getCodexCredentialGeneration(key: EndpointAPIKey): string | null | undefined {
const codex = key.upstream_metadata?.codex
if (!codex || typeof codex !== 'object') return undefined
return codex.credential_generation?.trim() || null
}
function hasPendingCodexResetCredit(key: EndpointAPIKey): boolean {
return getPendingCodexResetCreditIdempotencyKey(key) !== null
}
function canConsumeCodexResetCredit(key: EndpointAPIKey): boolean {
return provider.value?.provider_type === 'codex'
&& getCodexResetCreditAvailableCount(key) !== null
&& (getCodexResetCreditAvailableCount(key) ?? 0) > 0
&& getCodexCredentialGeneration(key) !== undefined
&& (hasPendingCodexResetCredit(key) || (getCodexResetCreditAvailableCount(key) ?? 0) > 0)
&& !consumingCodexResetCreditKeyId.value
}
@@ -1,12 +1,18 @@
import { describe, expect, it } from 'vitest'
import {
clearPendingCodexResetCreditIdempotencyKey,
clearPendingCodexResetCreditIdempotencyKeyForOutcome,
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount,
formatCodexResetCreditExpiresAt,
getCodexResetCreditAvailableCount,
getCodexResetCreditReservationIdempotencyKey,
getVisibleCodexResetCreditItems,
isCodexResetCreditTerminalOutcome,
mergeCodexQuotaDisplays,
readPendingCodexResetCreditIdempotencyKey,
rememberPendingCodexResetCreditIdempotencyKey,
} from '@/features/providers/components/codex-reset-credit-display'
import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types'
@@ -59,6 +65,19 @@ describe('codex reset credit display helpers', () => {
expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_000)).toEqual([])
})
it('recovers the active idempotency key from a persisted server reservation', () => {
expect(getCodexResetCreditReservationIdempotencyKey({
reset_credits: { available_count: 0 },
account_quota_reset_reservation: {
idempotency_key: ' server-active-attempt ',
generation: 3,
},
})).toBe('server-active-attempt')
expect(getCodexResetCreditReservationIdempotencyKey({
account_quota_reset_reservation: { idempotency_key: ' ' },
})).toBeNull()
})
it('sorts available detail items by remaining time and labels visible items with short ordinal keys', () => {
const snapshot: QuotaResetCreditsSnapshot = {
available_count: 7,
@@ -139,4 +158,164 @@ describe('codex reset credit display helpers', () => {
getRandomValues: array => array,
})).toBe('existing-random-uuid')
})
it('keeps an unresolved reset idempotency key until a terminal response clears it', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
rememberPendingCodexResetCreditIdempotencyKey(
'key-1',
'reset-attempt-1',
'credential-v1',
storage,
)
expect(readPendingCodexResetCreditIdempotencyKey('key-1', 'credential-v1', storage))
.toBe('reset-attempt-1')
expect(readPendingCodexResetCreditIdempotencyKey('key-2', 'credential-v1', storage)).toBeNull()
clearPendingCodexResetCreditIdempotencyKey('key-1', storage)
expect(readPendingCodexResetCreditIdempotencyKey('key-1', 'credential-v1', storage)).toBeNull()
})
it('clears pending idempotency keys for compatibility history replays', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
rememberPendingCodexResetCreditIdempotencyKey(
'key-1',
'reset-attempt-1',
'credential-v1',
storage,
)
expect(isCodexResetCreditTerminalOutcome('historical_replay')).toBe(true)
expect(clearPendingCodexResetCreditIdempotencyKeyForOutcome(
'key-1',
'historical_replay',
storage,
)).toBe(true)
expect(readPendingCodexResetCreditIdempotencyKey('key-1', 'credential-v1', storage)).toBeNull()
rememberPendingCodexResetCreditIdempotencyKey(
'key-1',
'reset-attempt-2',
'credential-v1',
storage,
)
expect(clearPendingCodexResetCreditIdempotencyKeyForOutcome('key-1', 'unknown', storage))
.toBe(false)
expect(readPendingCodexResetCreditIdempotencyKey('key-1', 'credential-v1', storage))
.toBe('reset-attempt-2')
expect(isCodexResetCreditTerminalOutcome('unknown')).toBe(false)
expect(isCodexResetCreditTerminalOutcome('error')).toBe(false)
})
it('keeps the active idempotency key in memory when session storage is unavailable', () => {
const unavailableStorage = {
getItem: () => { throw new Error('storage unavailable') },
setItem: () => { throw new Error('storage unavailable') },
removeItem: () => { throw new Error('storage unavailable') },
}
rememberPendingCodexResetCreditIdempotencyKey(
'key-without-storage',
'reset-attempt-original',
'credential-v1',
unavailableStorage,
)
expect(readPendingCodexResetCreditIdempotencyKey(
'key-without-storage',
'credential-v1',
unavailableStorage,
))
.toBe('reset-attempt-original')
rememberPendingCodexResetCreditIdempotencyKey(
'key-without-storage',
'reset-attempt-from-conflict',
'credential-v1',
unavailableStorage,
)
expect(readPendingCodexResetCreditIdempotencyKey(
'key-without-storage',
'credential-v1',
unavailableStorage,
))
.toBe('reset-attempt-from-conflict')
clearPendingCodexResetCreditIdempotencyKey('key-without-storage', unavailableStorage)
expect(readPendingCodexResetCreditIdempotencyKey(
'key-without-storage',
'credential-v1',
unavailableStorage,
))
.toBeNull()
})
it('does not resurrect a cleared key when session storage removal fails', () => {
const values = new Map<string, string>()
const partiallyUnavailableStorage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: () => { throw new Error('storage removal unavailable') },
}
rememberPendingCodexResetCreditIdempotencyKey(
'key-with-stale-storage',
'terminal-attempt',
'credential-v1',
partiallyUnavailableStorage,
)
clearPendingCodexResetCreditIdempotencyKey(
'key-with-stale-storage',
partiallyUnavailableStorage,
)
expect(readPendingCodexResetCreditIdempotencyKey(
'key-with-stale-storage',
'credential-v1',
partiallyUnavailableStorage,
)).toBeNull()
})
it('drops a pending attempt when the credential generation changes', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
rememberPendingCodexResetCreditIdempotencyKey(
'key-rotated',
'attempt-for-account-a',
'credential-a',
storage,
)
expect(readPendingCodexResetCreditIdempotencyKey('key-rotated', 'credential-b', storage))
.toBeNull()
expect([...values.values()]).toEqual([])
})
it('never replays a legacy v1 pending value without a credential generation', () => {
const values = new Map<string, string>([
['aether:codex-reset-credit-pending:v1:key-legacy', 'legacy-attempt'],
])
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
expect(readPendingCodexResetCreditIdempotencyKey('key-legacy', 'credential-current', storage))
.toBeNull()
expect([...values.values()]).toEqual([])
})
})
@@ -66,6 +66,84 @@ interface CodexResetCreditCrypto {
getRandomValues: (array: Uint8Array) => Uint8Array
}
interface CodexResetCreditPendingStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
const CODEX_RESET_CREDIT_PENDING_STORAGE_PREFIX = 'aether:codex-reset-credit-pending:v2:'
const CODEX_RESET_CREDIT_LEGACY_PENDING_STORAGE_PREFIX = 'aether:codex-reset-credit-pending:v1:'
interface CodexResetCreditPendingAttempt {
idempotencyKey: string
credentialGeneration: string | null
}
const pendingCodexResetCreditAttempts = new Map<string, CodexResetCreditPendingAttempt | null>()
const CODEX_RESET_CREDIT_TERMINAL_OUTCOMES = new Set([
'reset',
'already_redeemed',
'nothing_to_reset',
'no_credit',
'historical_replay',
])
function codexResetCreditPendingStorageKey(keyId: string): string {
return `${CODEX_RESET_CREDIT_PENDING_STORAGE_PREFIX}${keyId.trim()}`
}
function codexResetCreditLegacyPendingStorageKey(keyId: string): string {
return `${CODEX_RESET_CREDIT_LEGACY_PENDING_STORAGE_PREFIX}${keyId.trim()}`
}
function normalizeCodexCredentialGeneration(value: string | null | undefined): string | null {
const normalized = value?.trim()
return normalized ? normalized : null
}
function parseCodexResetCreditPendingAttempt(value: string): CodexResetCreditPendingAttempt | null {
try {
const parsed = JSON.parse(value) as unknown
if (typeof parsed !== 'object' || parsed === null) return null
const record = parsed as Record<string, unknown>
const idempotencyKey = typeof record.idempotencyKey === 'string'
? record.idempotencyKey.trim()
: ''
const generation = record.credentialGeneration
if (!idempotencyKey || idempotencyKey.length > 256) return null
if (generation !== null && typeof generation !== 'string') return null
const credentialGeneration = normalizeCodexCredentialGeneration(generation)
if (typeof generation === 'string' && credentialGeneration === null) return null
return { idempotencyKey, credentialGeneration }
} catch {
return null
}
}
function resolveCodexResetCreditPendingStorage(
storage: CodexResetCreditPendingStorage | undefined,
): CodexResetCreditPendingStorage | undefined {
if (storage) return storage
try {
return globalThis.sessionStorage
} catch {
return undefined
}
}
export function isCodexResetCreditTerminalOutcome(outcome: string): boolean {
return CODEX_RESET_CREDIT_TERMINAL_OUTCOMES.has(outcome)
}
export function getCodexResetCreditReservationIdempotencyKey(
display: CodexUpstreamMetadata | null | undefined,
): string | null {
const value = display?.account_quota_reset_reservation?.idempotency_key?.trim()
return value && value.length <= 256 ? value : null
}
export function createCodexResetCreditIdempotencyKey(
cryptoSource: CodexResetCreditCrypto | undefined = globalThis.crypto,
): string {
@@ -82,6 +160,91 @@ export function createCodexResetCreditIdempotencyKey(
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
export function readPendingCodexResetCreditIdempotencyKey(
keyId: string,
expectedCredentialGeneration: string | null,
storage?: CodexResetCreditPendingStorage,
): string | null {
const storageKey = codexResetCreditPendingStorageKey(keyId)
const expectedGeneration = normalizeCodexCredentialGeneration(expectedCredentialGeneration)
const resolvedStorage = resolveCodexResetCreditPendingStorage(storage)
try {
resolvedStorage?.removeItem(codexResetCreditLegacyPendingStorageKey(keyId))
} catch {
// Legacy values are never read, so failed cleanup cannot replay them.
}
if (pendingCodexResetCreditAttempts.has(storageKey)) {
const attempt = pendingCodexResetCreditAttempts.get(storageKey)
if (attempt?.credentialGeneration === expectedGeneration) return attempt.idempotencyKey
pendingCodexResetCreditAttempts.set(storageKey, null)
try {
resolvedStorage?.removeItem(storageKey)
} catch {
// The in-memory tombstone still prevents a stale generation from replaying.
}
return null
}
try {
const value = resolvedStorage?.getItem(storageKey)?.trim()
const attempt = value ? parseCodexResetCreditPendingAttempt(value) : null
if (attempt?.credentialGeneration === expectedGeneration) {
pendingCodexResetCreditAttempts.set(storageKey, attempt)
return attempt.idempotencyKey
}
pendingCodexResetCreditAttempts.set(storageKey, null)
if (value) resolvedStorage?.removeItem(storageKey)
} catch {
// Fall through to the in-memory copy when browser storage is unavailable.
}
return null
}
export function rememberPendingCodexResetCreditIdempotencyKey(
keyId: string,
idempotencyKey: string,
credentialGeneration: string | null,
storage?: CodexResetCreditPendingStorage,
): void {
const normalized = idempotencyKey.trim()
if (!normalized || normalized.length > 256) return
const storageKey = codexResetCreditPendingStorageKey(keyId)
const attempt: CodexResetCreditPendingAttempt = {
idempotencyKey: normalized,
credentialGeneration: normalizeCodexCredentialGeneration(credentialGeneration),
}
pendingCodexResetCreditAttempts.set(storageKey, attempt)
try {
resolveCodexResetCreditPendingStorage(storage)?.setItem(storageKey, JSON.stringify(attempt))
} catch {
// The in-memory copy still permits retrying the same request in this page session.
}
}
export function clearPendingCodexResetCreditIdempotencyKey(
keyId: string,
storage?: CodexResetCreditPendingStorage,
): void {
const storageKey = codexResetCreditPendingStorageKey(keyId)
pendingCodexResetCreditAttempts.set(storageKey, null)
try {
const resolvedStorage = resolveCodexResetCreditPendingStorage(storage)
resolvedStorage?.removeItem(storageKey)
resolvedStorage?.removeItem(codexResetCreditLegacyPendingStorageKey(keyId))
} catch {
// Ignore unavailable browser storage after a terminal server response.
}
}
export function clearPendingCodexResetCreditIdempotencyKeyForOutcome(
keyId: string,
outcome: string,
storage?: CodexResetCreditPendingStorage,
): boolean {
if (!isCodexResetCreditTerminalOutcome(outcome)) return false
clearPendingCodexResetCreditIdempotencyKey(keyId, storage)
return true
}
function codexResetCreditRemainingSeconds(
item: QuotaResetCreditSnapshot,
snapshot: QuotaResetCreditsSnapshot,