fix: replace native dialogs and improve quota reset handling
- Use consistent in-app dialogs for confirmation actions - Keep quota refreshes free of prompts and automatic paid probes - Add a separate confirmation flow for xAI availability tests - Require explicit approval for Codex resets and prevent duplicate requests - Show reset outcomes and require a quota refresh before retrying failures - Cover confirmation and reset flows with fully mocked tests
This commit is contained in:
1 parent
adb708c32a
commit
911047d009
18 files changed
+571
-186
No files matched your search
@@ -4,41 +4,69 @@ import {
|
||||
getQuotaCacheSnapshot,
|
||||
updateQuotaCache,
|
||||
} from './quotaCache';
|
||||
import { consumeCodexResetCredit, idleQuota, quotaKey, type AuthFile, type QuotaState } from './quotaService';
|
||||
import { readBoolean } from './managementApi';
|
||||
import {
|
||||
consumeCodexResetCredit, idleQuota, probeXaiAvailability, providerForFile, quotaKey,
|
||||
type AuthFile, type QuotaState,
|
||||
} from './quotaService';
|
||||
|
||||
/** Reserve the credential before opening the asynchronous native confirmation dialog. */
|
||||
export async function resetCodexQuotaWithConfirmation(
|
||||
const pendingActions = new Set<string>();
|
||||
type QuotaActionOutcome = 'cancelled' | 'success' | 'refresh-error' | 'error';
|
||||
|
||||
export const canResetCodexQuota = (file: AuthFile, quota: QuotaState): boolean =>
|
||||
providerForFile(file) === 'codex'
|
||||
&& !readBoolean(file, 'disabled')
|
||||
&& quota.status === 'success'
|
||||
&& (quota.resetCredits ?? 0) > 0
|
||||
&& quota.resetCreditsApplicable !== 0
|
||||
&& !(quota.actionResult?.action === 'reset' && quota.actionResult.status !== 'success');
|
||||
|
||||
async function runConfirmedQuotaAction(
|
||||
file: AuthFile,
|
||||
confirmReset: () => Promise<boolean>,
|
||||
): Promise<void> {
|
||||
action: 'reset' | 'probe',
|
||||
confirmAction: () => Promise<boolean>,
|
||||
execute: (file: AuthFile) => Promise<QuotaState>,
|
||||
): Promise<QuotaActionOutcome> {
|
||||
const key = quotaKey(file);
|
||||
const previous = getQuotaCacheSnapshot()[key] ?? idleQuota();
|
||||
if (previous.status === 'loading') return;
|
||||
const original = getQuotaCacheSnapshot()[key];
|
||||
const previous = original ?? idleQuota();
|
||||
if (pendingActions.has(key) || previous.status === 'loading' || readBoolean(file, 'disabled')) return 'cancelled';
|
||||
const generation = captureQuotaCacheGeneration();
|
||||
const pending: QuotaState = { ...previous, status: 'loading' };
|
||||
updateQuotaCache((current) => ({ ...current, [key]: pending }));
|
||||
|
||||
const isCurrent = () => captureQuotaCacheGeneration() === generation
|
||||
&& getQuotaCacheSnapshot()[key] === pending;
|
||||
pendingActions.add(key);
|
||||
let pending: QuotaState | undefined;
|
||||
const commit = (quota: QuotaState) => {
|
||||
commitQuotaCacheIfCurrent(generation, () => {
|
||||
updateQuotaCache((current) => current[key] === pending ? { ...current, [key]: quota } : current);
|
||||
});
|
||||
};
|
||||
|
||||
let started = false;
|
||||
try {
|
||||
// Never treat a pending promise, dismissal, or unexpected result as consent.
|
||||
if (await confirmReset() !== true || !isCurrent()) return;
|
||||
started = true;
|
||||
commit(await consumeCodexResetCredit(file));
|
||||
if (await confirmAction() !== true || captureQuotaCacheGeneration() !== generation
|
||||
|| getQuotaCacheSnapshot()[key] !== original) return 'cancelled';
|
||||
pending = { ...previous, status: 'loading', pendingAction: action, actionResult: undefined };
|
||||
updateQuotaCache((current) => ({ ...current, [key]: pending! }));
|
||||
const result = await execute(file);
|
||||
if (result.status === 'error') {
|
||||
const status = action === 'reset' ? 'refresh-error' : 'error';
|
||||
commit({ ...previous, actionResult: { action, status, error: result.error } });
|
||||
return status;
|
||||
}
|
||||
commit({ ...result, actionResult: { action, status: 'success' } });
|
||||
return 'success';
|
||||
} catch (error) {
|
||||
if (started) commit({
|
||||
status: 'error', rows: [], error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
if (!pending) throw error;
|
||||
commit({ ...previous, actionResult: { action, status: 'error', error: error instanceof Error ? error.message : String(error) } });
|
||||
return 'error';
|
||||
} finally {
|
||||
// Cancellation and dialog failures preserve the displayed quota and release the lock.
|
||||
if (!started) commit(previous);
|
||||
pendingActions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCodexQuotaWithConfirmation(file: AuthFile, confirmReset: () => Promise<boolean>): Promise<QuotaActionOutcome> {
|
||||
if (!canResetCodexQuota(file, getQuotaCacheSnapshot()[quotaKey(file)] ?? idleQuota())) return Promise.resolve('cancelled');
|
||||
return runConfirmedQuotaAction(file, 'reset', confirmReset, consumeCodexResetCredit);
|
||||
}
|
||||
|
||||
export function probeXaiWithConfirmation(file: AuthFile, confirmProbe: () => Promise<boolean>): Promise<QuotaActionOutcome> {
|
||||
if (providerForFile(file) !== 'xai') return Promise.resolve('cancelled');
|
||||
return runConfirmedQuotaAction(file, 'probe', confirmProbe, probeXaiAvailability);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export type QuotaState = {
|
||||
subscriptionActiveUntil?: string;
|
||||
serverTimeOffsetMs?: number;
|
||||
fetchedAt?: number;
|
||||
pendingAction?: 'reset' | 'probe';
|
||||
actionResult?: { action: 'reset' | 'probe'; status: 'success' | 'refresh-error' | 'error'; error?: string };
|
||||
};
|
||||
|
||||
export const idleQuota = (): QuotaState => ({ status: 'idle', rows: [] });
|
||||
@@ -464,6 +466,11 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
}
|
||||
|
||||
if (provider === 'xai') {
|
||||
if (value.mode === 'paid-info') return [{
|
||||
label: quotaText('quota.service.xaiPaidAccount'),
|
||||
remainingPercent: null,
|
||||
detail: quotaText('quota.service.xaiPaidQuotaUnavailable'),
|
||||
}];
|
||||
if (value.mode === 'paid-health') return [{
|
||||
label: quotaText('quota.service.xaiPaidHealth'),
|
||||
remainingPercent: null,
|
||||
@@ -654,13 +661,7 @@ const requestQuotaPayload = async (
|
||||
return parseBody(response.body ?? response.bodyText);
|
||||
};
|
||||
|
||||
export type QuotaLoadOptions = {
|
||||
// Paid health checks send a real (potentially billable) completion. Never do this silently.
|
||||
confirmXaiPaidProbe?: () => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
const callXaiPaidHealth = async (authIndex: string, options: QuotaLoadOptions): Promise<unknown> => {
|
||||
if (!await options.confirmXaiPaidProbe?.()) throw new Error(quotaText('quota.service.error.xaiProbeRequired'));
|
||||
const callXaiPaidHealth = async (authIndex: string): Promise<unknown> => {
|
||||
const header = { Authorization: 'Bearer $TOKEN$', accept: 'application/json' };
|
||||
const [profile, chat] = await Promise.allSettled([
|
||||
requestQuotaPayload(authIndex, 'https://api.x.ai/v1/me', header, 'GET', undefined, 15_000),
|
||||
@@ -674,10 +675,10 @@ const callXaiPaidHealth = async (authIndex: string, options: QuotaLoadOptions):
|
||||
return { mode: 'paid-health', plan_type: 'Paid', profile: profile.status === 'fulfilled' ? profile.value : null };
|
||||
};
|
||||
|
||||
const callXaiQuota = async (file: AuthFile, options: QuotaLoadOptions): Promise<unknown> => {
|
||||
const callXaiQuota = async (file: AuthFile): Promise<unknown> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
if (isPaidXaiFile(file)) return callXaiPaidHealth(authIndex, options);
|
||||
if (isPaidXaiFile(file)) return { mode: 'paid-info', plan_type: 'Paid' };
|
||||
const header = { ...headersByProvider.xai };
|
||||
const userId = await resolveXaiUserId(file);
|
||||
if (userId) header['x-userid'] = userId;
|
||||
@@ -692,12 +693,7 @@ const callXaiQuota = async (file: AuthFile, options: QuotaLoadOptions): Promise<
|
||||
if (quotaRowsFor('xai', payload).length > 0) return payload;
|
||||
const billingError = weekly.status === 'rejected' && monthly.status === 'rejected'
|
||||
? weekly.reason : new Error(quotaText('quota.service.error.unrecognized'));
|
||||
try {
|
||||
return await callXaiPaidHealth(authIndex, options);
|
||||
} catch {
|
||||
// Keep the useful billing error if the alternative account mode also fails or is declined.
|
||||
throw billingError;
|
||||
}
|
||||
throw billingError;
|
||||
};
|
||||
|
||||
const booleanValue = (value: unknown): boolean | null => {
|
||||
@@ -851,7 +847,7 @@ const callCodexResetCredits = async (
|
||||
return codexResetCreditDetailsFor(payload);
|
||||
};
|
||||
|
||||
async function loadQuotaSnapshot(file: AuthFile, options: QuotaLoadOptions = {}): Promise<QuotaState> {
|
||||
async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
|
||||
const provider = providerForFile(file);
|
||||
if (!provider) {
|
||||
return {
|
||||
@@ -866,7 +862,7 @@ async function loadQuotaSnapshot(file: AuthFile, options: QuotaLoadOptions = {})
|
||||
const codexAccountId = codexMetadata?.accountId || '';
|
||||
const responseClock: { serverTimeOffsetMs?: number } = {};
|
||||
const payloadPromise = provider === 'xai'
|
||||
? callXaiQuota(file, options)
|
||||
? callXaiQuota(file)
|
||||
: callUpstreamQuota(file, provider, codexAccountId, responseClock);
|
||||
const planPromise = provider === 'claude'
|
||||
? loadClaudePlan(file)
|
||||
@@ -953,35 +949,49 @@ async function consumeCodexResetCreditSnapshot(file: AuthFile): Promise<QuotaSta
|
||||
return loadQuotaSnapshot(file);
|
||||
}
|
||||
|
||||
// Shared by both pages: deduplicate refreshes and serialize reset mutations per credential.
|
||||
const quotaRequests = new Map<string, Promise<QuotaState>>();
|
||||
const resetRequests = new Map<string, Promise<QuotaState>>();
|
||||
const quotaMutationRequests = new Map<string, Promise<QuotaState>>();
|
||||
|
||||
export function loadQuota(file: AuthFile, options: QuotaLoadOptions = {}): Promise<QuotaState> {
|
||||
export function loadQuota(file: AuthFile): Promise<QuotaState> {
|
||||
const key = quotaKey(file);
|
||||
const reset = resetRequests.get(key);
|
||||
const reset = quotaMutationRequests.get(key);
|
||||
if (reset) return reset.catch((error): QuotaState => ({
|
||||
status: 'error', rows: [], error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
const existing = quotaRequests.get(key);
|
||||
if (existing) return existing;
|
||||
const request = loadQuotaSnapshot(file, options).finally(() => {
|
||||
const request = loadQuotaSnapshot(file).finally(() => {
|
||||
if (quotaRequests.get(key) === request) quotaRequests.delete(key);
|
||||
});
|
||||
quotaRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
function runQuotaMutation(file: AuthFile, mutate: () => Promise<QuotaState>): Promise<QuotaState> {
|
||||
const key = quotaKey(file);
|
||||
const existing = resetRequests.get(key);
|
||||
const existing = quotaMutationRequests.get(key);
|
||||
if (existing) return existing;
|
||||
const request = (async () => {
|
||||
await quotaRequests.get(key);
|
||||
return consumeCodexResetCreditSnapshot(file);
|
||||
return mutate();
|
||||
})().finally(() => {
|
||||
if (resetRequests.get(key) === request) resetRequests.delete(key);
|
||||
if (quotaMutationRequests.get(key) === request) quotaMutationRequests.delete(key);
|
||||
});
|
||||
resetRequests.set(key, request);
|
||||
quotaMutationRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
return runQuotaMutation(file, () => consumeCodexResetCreditSnapshot(file));
|
||||
}
|
||||
|
||||
export function probeXaiAvailability(file: AuthFile): Promise<QuotaState> {
|
||||
return runQuotaMutation(file, async () => {
|
||||
if (providerForFile(file) !== 'xai') throw new Error(quotaText('quota.service.error.xaiProbeOnly'));
|
||||
if (booleanValue(file.disabled) === true) throw new Error(quotaText('quota.fileDisabled'));
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
const payload = await callXaiPaidHealth(authIndex);
|
||||
return { status: 'success', rows: quotaRowsFor('xai', payload), plan: 'Paid', fetchedAt: Date.now() };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user