mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
fix(codex): fence concurrent quota updates
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+179
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user