mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
feat(codex): implement cooldown reset logic and add related tests
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user