Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 35 additions & 23 deletions src/features/monitoring/accountQuotaProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,43 @@ describe('fetchAccountQuotaEntry', () => {
expect(entry.windows[0].usageLabel).toContain('codex_quota.window_usage');
});

it('returns an error entry when Codex fetch fails', async () => {
requestCodexUsageRawMock.mockResolvedValue({ result: { statusCode: 500 }, payload: null });
it('maps Codex monthly windows for account quota cards', async () => {
requestCodexUsageRawMock.mockResolvedValue({
result: { statusCode: 200 },
payload: {
plan_type: 'free',
rate_limit: {
primary_window: {
used_percent: 5,
limit_window_seconds: 2_592_000,
reset_after_seconds: 2_592_000,
},
},
},
});

const entry = await fetchAccountQuotaEntry(
baseTarget({ provider: 'codex' }),
baseTarget({ provider: 'codex', accountId: 'acc-monthly' }),
undefined,
t
);

expect(entry.subtitle).toBe('codex_quota.plan_free');
expect(entry.windows).toMatchObject([
{
id: 'monthly',
label: 'codex_quota.monthly_window',
remainingPercent: 95,
usageLabel: 'codex_quota.window_usage:{"percent":"5"}',
},
]);
});

it('returns an error entry when Codex fetch fails', async () => {
requestCodexUsageRawMock.mockResolvedValue({ result: { statusCode: 500 }, payload: null });

const entry = await fetchAccountQuotaEntry(baseTarget({ provider: 'codex' }), undefined, t);

expect(entry.error).toBe('HTTP 500');
expect(entry.errorStatus).toBe(500);
expect(entry.windows).toEqual([]);
Expand Down Expand Up @@ -142,11 +170,7 @@ describe('fetchAccountQuotaEntry', () => {
});

const file = baseFile({ type: 'claude' });
const entry = await fetchAccountQuotaEntry(
baseTarget({ provider: 'claude' }),
file,
t
);
const entry = await fetchAccountQuotaEntry(baseTarget({ provider: 'claude' }), file, t);

expect(entry.error).toBeUndefined();
expect(entry.subtitle).toContain('claude_quota.plan_max');
Expand Down Expand Up @@ -199,11 +223,7 @@ describe('fetchAccountQuotaEntry', () => {
});

const file = baseFile({ type: 'gemini-cli', account: 'demo (proj-x)' });
const entry = await fetchAccountQuotaEntry(
baseTarget({ provider: 'gemini-cli' }),
file,
t
);
const entry = await fetchAccountQuotaEntry(baseTarget({ provider: 'gemini-cli' }), file, t);

expect(entry.error).toBeUndefined();
expect(entry.subtitle).toBeNull();
Expand Down Expand Up @@ -306,22 +326,14 @@ describe('fetchAccountQuotaEntry', () => {
});

it('returns error entry when xAI auth file is missing', async () => {
const entry = await fetchAccountQuotaEntry(
baseTarget({ provider: 'xai' }),
undefined,
t
);
const entry = await fetchAccountQuotaEntry(baseTarget({ provider: 'xai' }), undefined, t);

expect(entry.error).toBe('xai_quota.missing_auth_index');
expect(entry.windows).toEqual([]);
});

it('returns error entry when a non-Codex provider receives no file', async () => {
const entry = await fetchAccountQuotaEntry(
baseTarget({ provider: 'claude' }),
undefined,
t
);
const entry = await fetchAccountQuotaEntry(baseTarget({ provider: 'claude' }), undefined, t);

expect(entry.error).toBe('claude_quota.missing_auth_index');
expect(entry.windows).toEqual([]);
Expand Down
39 changes: 11 additions & 28 deletions src/features/monitoring/accountQuotaProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,22 +92,14 @@ const buildCodexAccountQuotaWindows = (
const clampedUsed =
window.usedPercent === null ? null : Math.max(0, Math.min(100, window.usedPercent));
const remainingPercent = clampedUsed === null ? null : Math.max(0, 100 - clampedUsed);
let usageLabel: string | null = null;

if (
window.limitWindowSeconds !== null &&
window.limitWindowSeconds > 0 &&
clampedUsed !== null
) {
const totalHours = window.limitWindowSeconds / 3600;
const usedHours = (totalHours * clampedUsed) / 100;
const formatHours = (value: number) =>
Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
usageLabel = t('codex_quota.window_usage', {
used: formatHours(usedHours),
total: formatHours(totalHours),
});
}
const usageLabel =
clampedUsed === null
? null
: t('codex_quota.window_usage', {
percent: Number.isInteger(clampedUsed)
? clampedUsed.toFixed(0)
: clampedUsed.toFixed(1),
});

return {
id: window.id,
Expand Down Expand Up @@ -147,9 +139,7 @@ const geminiBucketToAccountWindow = (
t: TFunction
): AccountQuotaWindow => {
const clamped =
bucket.remainingFraction === null
? null
: Math.max(0, Math.min(1, bucket.remainingFraction));
bucket.remainingFraction === null ? null : Math.max(0, Math.min(1, bucket.remainingFraction));
const remainingPercent = clamped === null ? null : clamped * 100;
const usageLabel =
bucket.remainingAmount === null || bucket.remainingAmount === undefined
Expand All @@ -167,11 +157,7 @@ const geminiBucketToAccountWindow = (
const kimiRowToAccountWindow = (row: KimiQuotaRow, t: TFunction): AccountQuotaWindow => {
const { limit, used } = row;
const remainingPercent =
limit > 0
? Math.max(0, Math.min(100, ((limit - used) / limit) * 100))
: used > 0
? 0
: null;
limit > 0 ? Math.max(0, Math.min(100, ((limit - used) / limit) * 100)) : used > 0 ? 0 : null;
const label = row.labelKey
? t(row.labelKey, (row.labelParams ?? {}) as Record<string, string | number>)
: (row.label ?? '');
Expand Down Expand Up @@ -214,10 +200,7 @@ const xaiBillingToAccountWindow = (
};
};

const formatXaiPayAsYouGoSubtitle = (
billing: XaiBillingSummary,
t: TFunction
): string | null => {
const formatXaiPayAsYouGoSubtitle = (billing: XaiBillingSummary, t: TFunction): string | null => {
const onDemandCap = billing.onDemandCapCents ?? 0;
const label = t('xai_quota.pay_as_you_go_label');
const value =
Expand Down
142 changes: 139 additions & 3 deletions src/features/monitoring/codexInspection.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthFileItem } from '@/types';
import { authFilesApi } from '@/services/api/authFiles';

const requestCodexUsageRawMock = vi.hoisted(() => vi.fn());

vi.mock('@/services/api/codexQuota', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/api/codexQuota')>();
return {
...actual,
requestCodexUsageRaw: requestCodexUsageRawMock,
};
});

import {
CODEX_INSPECTION_LAST_RUN_STORAGE_KEY,
CODEX_INSPECTION_SETTINGS_STORAGE_KEY,
createCodexInspectionSession,
createCodexInspectionConnectionFingerprint,
executeCodexInspectionActions,
hydrateCodexInspectionLastRun,
Expand Down Expand Up @@ -103,6 +115,7 @@ const createRunResult = (): CodexInspectionRunResult => {
};

afterEach(() => {
requestCodexUsageRawMock.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
Expand All @@ -111,7 +124,10 @@ describe('Codex inspection settings', () => {
it('migrates legacy auto execute settings to auto disable', () => {
const storage = createStorage();
vi.stubGlobal('localStorage', storage);
storage.setItem(CODEX_INSPECTION_SETTINGS_STORAGE_KEY, JSON.stringify({ autoExecuteActions: true }));
storage.setItem(
CODEX_INSPECTION_SETTINGS_STORAGE_KEY,
JSON.stringify({ autoExecuteActions: true })
);

expect(loadCodexInspectionConfigurableSettings(null).autoActionMode).toBe('disable');
});
Expand All @@ -123,7 +139,9 @@ describe('resolveCodexInspectionAutoActionItems', () => {
const enableItem = createResultItem('enable');

it('does nothing when automatic mode is none', () => {
expect(resolveCodexInspectionAutoActionItems('none', [deleteItem, disableItem, enableItem])).toEqual([]);
expect(
resolveCodexInspectionAutoActionItems('none', [deleteItem, disableItem, enableItem])
).toEqual([]);
});

it('turns delete suggestions into disable actions in auto disable mode', () => {
Expand Down Expand Up @@ -188,6 +206,121 @@ describe('executeCodexInspectionActions', () => {
});
});

describe('createCodexInspectionSession', () => {
it('keeps enabled accounts when only short-term Codex quota is exhausted', async () => {
vi.spyOn(authFilesApi, 'list').mockResolvedValue({
files: [
{
name: 'monthly.json',
type: 'codex',
authIndex: '9',
account: 'monthly@example.com',
accountId: 'account-monthly',
} as AuthFileItem,
],
});
requestCodexUsageRawMock.mockResolvedValue({
result: {
statusCode: 200,
hasStatusCode: true,
header: {},
bodyText: '',
body: null,
},
payload: {
rate_limit: {
allowed: true,
primary_window: {
used_percent: 100,
limit_window_seconds: 18_000,
},
secondary_window: {
used_percent: 5,
limit_window_seconds: 2_592_000,
},
},
},
});

const session = createCodexInspectionSession({
config: null,
apiBase: 'https://cpa.example.test',
managementKey: 'management-token',
settings: {
targetType: 'codex',
workers: 1,
timeout: 1000,
usedPercentThreshold: 100,
},
});

const result = await session.start();

expect(result.results).toMatchObject([
{
action: 'keep',
actionReason: '5 小时额度达到阈值,但月额度仍可用,暂不禁用账号',
usedPercent: 5,
isQuota: false,
},
]);
});

it('disables enabled accounts when monthly Codex quota reaches the threshold', async () => {
vi.spyOn(authFilesApi, 'list').mockResolvedValue({
files: [
{
name: 'monthly-full.json',
type: 'codex',
authIndex: '10',
account: 'monthly-full@example.com',
} as AuthFileItem,
],
});
requestCodexUsageRawMock.mockResolvedValue({
result: {
statusCode: 200,
hasStatusCode: true,
header: {},
bodyText: '',
body: null,
},
payload: {
rate_limit: {
allowed: true,
primary_window: {
used_percent: 100,
limit_window_seconds: 2_592_000,
},
},
},
});

const session = createCodexInspectionSession({
config: null,
apiBase: 'https://cpa.example.test',
managementKey: 'management-token',
settings: {
targetType: 'codex',
workers: 1,
timeout: 1000,
usedPercentThreshold: 100,
},
});

const result = await session.start();

expect(result.results).toMatchObject([
{
action: 'disable',
actionReason: '月额度达到阈值,建议禁用账号',
usedPercent: 100,
isQuota: true,
},
]);
});
});

describe('Codex inspection last-run cache', () => {
it('creates stable connection fingerprints without storing raw inputs', () => {
const fingerprint = createCodexInspectionConnectionFingerprint(
Expand All @@ -196,7 +329,10 @@ describe('Codex inspection last-run cache', () => {
);

expect(fingerprint).toBe(
createCodexInspectionConnectionFingerprint('https://cpa.example.test', 'management-secret-token')
createCodexInspectionConnectionFingerprint(
'https://cpa.example.test',
'management-secret-token'
)
);
expect(fingerprint).not.toContain('management-secret-token');
expect(fingerprint).not.toContain('cpa.example.test');
Expand Down
Loading
Loading