feat(codex): implement cooldown reset logic and add related tests

This commit is contained in:
Supra4E8C
2026-10-07 23:28:48 +08:00
parent 66cafd727c
commit 18ca877f83
7 changed files with 127 additions and 3 deletions
+23 -3
View File
@@ -14,7 +14,9 @@ import type {
CodexQuotaWindow,
CodexUsagePayload,
} from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { guardConfigConnection } from '@/services/api/configValue';
import { isRecord } from '@/utils/helpers';
import {
CODEX_RATE_LIMIT_RESET_CREDITS_URL,
CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL,
@@ -490,7 +492,7 @@ const createCodexRedeemRequestId = (): string => {
const consumeCodexRateLimitResetCredit = async (
file: AuthFileItem,
t: TFunction
): Promise<void> => {
): Promise<string> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
@@ -512,10 +514,28 @@ const consumeCodexRateLimitResetCredit = async (
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const code = isRecord(result.body) ? result.body.code : undefined;
if (code !== 'reset' && code !== 'already_redeemed') {
throw new Error(t('codex_quota.reset_not_confirmed'));
}
return authIndex;
};
const resetCodexQuota = async (file: AuthFileItem, t: TFunction): Promise<CodexQuotaData> => {
await consumeCodexRateLimitResetCredit(file, t);
const assertConnection = guardConfigConnection();
const authIndex = await consumeCodexRateLimitResetCredit(file, t);
try {
// Never clear a different connection's cooldown after awaiting redemption.
assertConnection();
const result = await authFilesApi.resetCooldown(authIndex);
assertConnection();
if (result.status !== 'ok' || result.auth_index !== authIndex) {
throw new Error('Invalid cooldown reset response');
}
} catch {
// Redemption already succeeded: direct the operator to the existing clear action.
throw new Error(t('codex_quota.reset_cooldown_failed'));
}
return fetchCodexQuota(file, t);
};
+2
View File
@@ -535,6 +535,8 @@
"reset_confirm_title": "Reset Codex quota",
"reset_confirm_message": "This will consume 1 manual reset to reset the Codex quota for \"{{name}}\". Continue?",
"reset_confirm_button": "Reset quota",
"reset_not_confirmed": "Quota reset was not confirmed. Refresh the quota before trying again.",
"reset_cooldown_failed": "Quota was reset, but the gateway cooldown could not be cleared. On the original connection, use Clear cooldown on the credential page; do not redeem another reset.",
"reset_success": "Reset Codex quota for \"{{name}}\"",
"reset_failed": "Failed to reset Codex quota for \"{{name}}\": {{message}}",
"plan_plus": "Plus",
+2
View File
@@ -538,6 +538,8 @@
"reset_confirm_title": "Сброс квоты Codex",
"reset_confirm_message": "Для сброса квоты Codex для «{{name}}» будет использован 1 ручной сброс. Продолжить?",
"reset_confirm_button": "Сбросить квоту",
"reset_not_confirmed": "Сброс квоты не подтверждён. Обновите квоту перед повторной попыткой.",
"reset_cooldown_failed": "Квота сброшена, но период ожидания шлюза не снят. В исходном подключении используйте очистку периода ожидания на странице учётных данных; не расходуйте ещё один сброс.",
"reset_success": "Квота Codex для «{{name}}» сброшена",
"reset_failed": "Не удалось сбросить квоту Codex для «{{name}}»: {{message}}",
"plan_plus": "Plus",
+2
View File
@@ -533,6 +533,8 @@
"reset_confirm_title": "Đặt lại quota Codex",
"reset_confirm_message": "Thao tác này sẽ dùng 1 lượt đặt lại thủ công để đặt lại quota Codex cho \"{{name}}\". Tiếp tục?",
"reset_confirm_button": "Đặt lại quota",
"reset_not_confirmed": "Chưa xác nhận đặt lại quota thành công. Hãy làm mới quota trước khi thử lại.",
"reset_cooldown_failed": "Quota đã được đặt lại nhưng không thể xóa thời gian chờ của gateway. Trên kết nối ban đầu, hãy dùng chức năng xóa thời gian chờ ở trang tệp xác thực; không sử dụng thêm lượt đặt lại.",
"reset_success": "Đã đặt lại quota Codex cho \"{{name}}\"",
"reset_failed": "Không thể đặt lại quota Codex cho \"{{name}}\": {{message}}",
"plan_plus": "Plus",
+3
View File
@@ -533,6 +533,9 @@
"reset_confirm_title": "重置 Codex 额度",
"reset_confirm_message": "将消耗 1 次主动重置次数来重置 \"{{name}}\" 的 Codex 额度。是否继续?",
"reset_confirm_button": "确认重置",
"reset_not_confirmed": "未确认额度重置成功,请刷新额度后再决定是否重试。",
"reset_cooldown_failed": "额度已重置,但代理冷却清除失败。请在原连接的认证文件页面使用“清除冷却”,不要再次消耗重置卡。",
"reset_success": "已重置 \"{{name}}\" 的 Codex 额度",
"reset_failed": "重置 \"{{name}}\" 的 Codex 额度失败:{{message}}",
"plan_plus": "Plus",
+3
View File
@@ -533,6 +533,9 @@
"reset_confirm_title": "重置 Codex 配額",
"reset_confirm_message": "將消耗 1 次主動重置次數來重置「{{name}}」的 Codex 配額。是否繼續?",
"reset_confirm_button": "確認重置",
"reset_not_confirmed": "尚未確認額度重置成功,請重新整理額度後再決定是否重試。",
"reset_cooldown_failed": "額度已重置,但代理冷卻清除失敗。請在原連線的認證檔案頁面使用「清除冷卻」,不要再次消耗重置卡。",
"reset_success": "已重置「{{name}}」的 Codex 配額",
"reset_failed": "重置「{{name}}」的 Codex 配額失敗:{{message}}",
"plan_plus": "Plus",
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
import type { TFunction } from 'i18next';
import { CODEX_CONFIG } from '@/features/quota/providers/codex/data';
import { apiCallApi, authFilesApi, apiClient } from '@/services/api';
import { CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL } from '@/utils/quota';
const t = ((key: string) => key) as TFunction;
const file = { name: 'codex-test.json', auth_index: 'test-index', type: 'codex' };
const response = (body: unknown, statusCode = 200) => ({
statusCode,
body,
bodyText: JSON.stringify(body),
header: {},
});
const reset = () => CODEX_CONFIG.resetQuota!(file, t);
const mocks: Array<{ mockRestore(): void }> = [];
afterEach(() => {
for (const mock of mocks.splice(0)) mock.mockRestore();
});
function setup(body: unknown, statusCode = 200) {
const calls: string[] = [];
const request = spyOn(apiCallApi, 'request').mockImplementation(async (payload) => {
if (payload.url === CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL) {
calls.push('consume');
return response(body, statusCode);
}
calls.push('read');
return response({});
});
const clear = spyOn(authFilesApi, 'resetCooldown').mockImplementation(async () => {
calls.push('clear');
return { status: 'ok', auth_index: 'test-index', models: [] };
});
mocks.push(request, clear);
return { calls, request, clear };
}
describe('Codex reset gateway cooldown', () => {
test.each(['reset', 'already_redeemed'])(
'clears only after confirmed %s, before reads',
async (code) => {
const { calls, clear } = setup({ code, windows_reset: 0 });
await reset();
expect(calls.slice(0, 3)).toEqual(['consume', 'clear', 'read']);
expect(clear).toHaveBeenCalledTimes(1);
expect(clear).toHaveBeenCalledWith('test-index');
}
);
test.each(['no_credit', 'nothing_to_reset', 'unknown'])('does not clear for %s', async (code) => {
const { calls } = setup({ code });
await expect(reset()).rejects.toThrow('codex_quota.reset_not_confirmed');
expect(calls).toEqual(['consume']);
});
test('does not clear on HTTP failure', async () => {
const { calls } = setup({ code: 'reset' }, 503);
await expect(reset()).rejects.toThrow();
expect(calls).toEqual(['consume']);
});
test('does not clear on transport failure', async () => {
const { request, clear } = setup({});
request.mockRejectedValue(new Error('timeout'));
await expect(reset()).rejects.toThrow('timeout');
expect(clear).not.toHaveBeenCalled();
});
test('directs cooldown failures to manual clearing, without another consume', async () => {
const { request, clear } = setup({ code: 'reset' });
clear.mockRejectedValue(new Error('offline'));
await expect(reset()).rejects.toThrow('codex_quota.reset_cooldown_failed');
expect(request).toHaveBeenCalledTimes(1);
});
test('rejects mismatched cooldown acknowledgements', async () => {
const { clear } = setup({ code: 'reset' });
clear.mockResolvedValue({ status: 'ok', auth_index: 'other-index', models: [] });
await expect(reset()).rejects.toThrow('codex_quota.reset_cooldown_failed');
});
test('does not clear after connection changes during redemption', async () => {
const { clear } = setup({ code: 'reset' });
const revision = spyOn(apiClient, 'getConnectionRevision')
.mockReturnValueOnce(1)
.mockReturnValue(2);
mocks.push(revision);
await expect(reset()).rejects.toThrow('codex_quota.reset_cooldown_failed');
expect(clear).not.toHaveBeenCalled();
});
});