mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
fix(cliproxy): close remaining quota edge-case gaps
This commit is contained in:
1 parent
9371a72c16
commit
8c790f41ff
10 files changed
+482
-80
No files matched your search
@@ -64,6 +64,10 @@ function getClaudeWindowLabel(rateLimitType: string): string {
|
|||||||
return 'Opus limit';
|
return 'Opus limit';
|
||||||
case 'seven_day_sonnet':
|
case 'seven_day_sonnet':
|
||||||
return 'Sonnet limit';
|
return 'Sonnet limit';
|
||||||
|
case 'seven_day_oauth_apps':
|
||||||
|
return 'OAuth apps limit';
|
||||||
|
case 'seven_day_cowork':
|
||||||
|
return 'Cowork limit';
|
||||||
case 'overage':
|
case 'overage':
|
||||||
return 'Extra usage';
|
return 'Extra usage';
|
||||||
default:
|
default:
|
||||||
@@ -71,6 +75,10 @@ function getClaudeWindowLabel(rateLimitType: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampUnit(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUtilization(raw: Record<string, unknown>): {
|
function normalizeUtilization(raw: Record<string, unknown>): {
|
||||||
utilization: number | null;
|
utilization: number | null;
|
||||||
usedPercent: number;
|
usedPercent: number;
|
||||||
@@ -82,9 +90,10 @@ function normalizeUtilization(raw: Record<string, unknown>): {
|
|||||||
|
|
||||||
if (utilizationRaw !== null) {
|
if (utilizationRaw !== null) {
|
||||||
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100;
|
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100;
|
||||||
const usedPercent = clampPercent(ratio * 100);
|
const normalizedRatio = clampUnit(ratio);
|
||||||
|
const usedPercent = clampPercent(normalizedRatio * 100);
|
||||||
return {
|
return {
|
||||||
utilization: ratio,
|
utilization: normalizedRatio,
|
||||||
usedPercent,
|
usedPercent,
|
||||||
remainingPercent: clampPercent(100 - usedPercent),
|
remainingPercent: clampPercent(100 - usedPercent),
|
||||||
};
|
};
|
||||||
@@ -243,6 +252,14 @@ function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEEKLY_RATE_LIMIT_TYPES = new Set([
|
||||||
|
'seven_day',
|
||||||
|
'seven_day_opus',
|
||||||
|
'seven_day_sonnet',
|
||||||
|
'seven_day_oauth_apps',
|
||||||
|
'seven_day_cowork',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build explicit 5h + weekly usage summary from Claude policy windows.
|
* Build explicit 5h + weekly usage summary from Claude policy windows.
|
||||||
*/
|
*/
|
||||||
@@ -253,42 +270,38 @@ export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): Claud
|
|||||||
|
|
||||||
const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
|
const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
|
||||||
const weeklyCandidates = windows.filter((window) =>
|
const weeklyCandidates = windows.filter((window) =>
|
||||||
['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType)
|
WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType)
|
||||||
);
|
);
|
||||||
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
|
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
|
||||||
|
|
||||||
// Fallback: infer shortest/longest reset windows from non-overage limits.
|
if (fiveHourWindow && weeklyWindow) {
|
||||||
if (!fiveHourWindow || !weeklyWindow) {
|
|
||||||
const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage');
|
|
||||||
const withReset = nonOverage
|
|
||||||
.map((window) => ({
|
|
||||||
window,
|
|
||||||
resetMs: toEpochMs(window.resetAt),
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.resetMs !== null)
|
|
||||||
.sort((a, b) => (a.resetMs as number) - (b.resetMs as number));
|
|
||||||
|
|
||||||
const inferredFiveHour =
|
|
||||||
fiveHourWindow ||
|
|
||||||
(withReset.length > 0
|
|
||||||
? withReset[0].window
|
|
||||||
: nonOverage.length > 0
|
|
||||||
? pickMostRestrictiveWeekly(nonOverage)
|
|
||||||
: null);
|
|
||||||
const inferredWeekly =
|
|
||||||
weeklyWindow ||
|
|
||||||
(withReset.length > 1
|
|
||||||
? withReset[withReset.length - 1].window
|
|
||||||
: nonOverage.find((window) => window !== inferredFiveHour) || null);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fiveHour: mapCoreWindow(inferredFiveHour),
|
fiveHour: mapCoreWindow(fiveHourWindow),
|
||||||
weekly: mapCoreWindow(inferredWeekly),
|
weekly: mapCoreWindow(weeklyWindow),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: infer shortest/longest reset windows from non-overage limits.
|
||||||
|
const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage');
|
||||||
|
const withReset = nonOverage
|
||||||
|
.map((window) => ({
|
||||||
|
window,
|
||||||
|
resetMs: toEpochMs(window.resetAt),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.resetMs !== null)
|
||||||
|
.sort((a, b) => (a.resetMs as number) - (b.resetMs as number));
|
||||||
|
|
||||||
|
const inferredWeekly =
|
||||||
|
weeklyWindow ||
|
||||||
|
[...withReset].reverse().find((entry) => entry.window !== fiveHourWindow)?.window ||
|
||||||
|
pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== fiveHourWindow));
|
||||||
|
const inferredFiveHour =
|
||||||
|
fiveHourWindow ||
|
||||||
|
withReset.find((entry) => entry.window !== inferredWeekly)?.window ||
|
||||||
|
pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== inferredWeekly));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fiveHour: mapCoreWindow(fiveHourWindow),
|
fiveHour: mapCoreWindow(inferredFiveHour),
|
||||||
weekly: mapCoreWindow(weeklyWindow),
|
weekly: mapCoreWindow(inferredWeekly),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -520,7 +520,18 @@ function scheduleNextPoll(
|
|||||||
try {
|
try {
|
||||||
const quota = await fetchQuotaWithDedup(provider, accountId);
|
const quota = await fetchQuotaWithDedup(provider, accountId);
|
||||||
if (monitorStopped) return; // Re-check after async fetch
|
if (monitorStopped) return; // Re-check after async fetch
|
||||||
const avgQuota = calculateQuotaPercent(quota) ?? 100;
|
const avgQuota = calculateQuotaPercent(quota);
|
||||||
|
|
||||||
|
if (avgQuota === null) {
|
||||||
|
// Quota data unavailable: keep polling, but do not treat unknown as healthy/exhausted.
|
||||||
|
scheduleNextPoll(
|
||||||
|
provider,
|
||||||
|
accountId,
|
||||||
|
monitorConfig,
|
||||||
|
monitorConfig.normal_interval_seconds * 1000
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (avgQuota <= monitorConfig.exhaustion_threshold) {
|
if (avgQuota <= monitorConfig.exhaustion_threshold) {
|
||||||
// EXHAUSTED: cooldown + switch default + stop monitoring.
|
// EXHAUSTED: cooldown + switch default + stop monitoring.
|
||||||
|
|||||||
@@ -97,31 +97,33 @@ function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
|
|||||||
return canonicalProvider;
|
return canonicalProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProviderArg(args: string[]): {
|
export function parseProviderArg(args: string[]): {
|
||||||
provider: QuotaProviderFilter;
|
provider: QuotaProviderFilter;
|
||||||
remainingArgs: string[];
|
remainingArgs: string[];
|
||||||
|
invalid: boolean;
|
||||||
} {
|
} {
|
||||||
const extracted = extractOption(args, ['--provider']);
|
const extracted = extractOption(args, ['--provider']);
|
||||||
if (!extracted.found) {
|
if (!extracted.found) {
|
||||||
return { provider: 'all', remainingArgs: args };
|
return { provider: 'all', remainingArgs: args, invalid: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extracted.missingValue || !extracted.value) {
|
if (extracted.missingValue || !extracted.value) {
|
||||||
console.error(
|
console.error(
|
||||||
`Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
|
`Invalid provider value. --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
|
||||||
);
|
);
|
||||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = extracted.value.toLowerCase();
|
const value = extracted.value.toLowerCase();
|
||||||
const normalized = normalizeQuotaProvider(value);
|
const normalized = normalizeQuotaProvider(value);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`);
|
console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`);
|
||||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
provider: normalized,
|
provider: normalized,
|
||||||
remainingArgs: extracted.remainingArgs,
|
remainingArgs: extracted.remainingArgs,
|
||||||
|
invalid: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +165,11 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'quota') {
|
if (command === 'quota') {
|
||||||
const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1));
|
const { provider: providerFilter, invalid } = parseProviderArg(remainingArgs.slice(1));
|
||||||
|
if (invalid) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await handleQuotaStatus(verbose, providerFilter);
|
await handleQuotaStatus(verbose, providerFilter);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,42 @@ describe('Claude Quota Fetcher', () => {
|
|||||||
expect.arrayContaining(['five_hour', 'seven_day_opus'])
|
expect.arrayContaining(['five_hour', 'seven_day_opus'])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clamps utilization ratio into 0..1', () => {
|
||||||
|
const windows = buildClaudeQuotaWindows({
|
||||||
|
restrictions: [
|
||||||
|
{
|
||||||
|
rateLimitType: 'five_hour',
|
||||||
|
utilization: 150,
|
||||||
|
status: 'allowed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day',
|
||||||
|
utilization: -25,
|
||||||
|
status: 'allowed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(windows).toHaveLength(2);
|
||||||
|
expect(windows[0].utilization).toBe(1);
|
||||||
|
expect(windows[0].remainingPercent).toBe(0);
|
||||||
|
expect(windows[1].utilization).toBe(0);
|
||||||
|
expect(windows[1].remainingPercent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses direct single restriction payload shape', () => {
|
||||||
|
const windows = buildClaudeQuotaWindows({
|
||||||
|
rateLimitType: 'five_hour',
|
||||||
|
utilization: 0.6,
|
||||||
|
status: 'allowed',
|
||||||
|
resetsAt: '2026-02-28T10:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(windows).toHaveLength(1);
|
||||||
|
expect(windows[0].rateLimitType).toBe('five_hour');
|
||||||
|
expect(windows[0].remainingPercent).toBe(40);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildClaudeCoreUsageSummary', () => {
|
describe('buildClaudeCoreUsageSummary', () => {
|
||||||
@@ -159,6 +195,93 @@ describe('Claude Quota Fetcher', () => {
|
|||||||
expect(summary.weekly?.rateLimitType).toBe('seven_day_opus');
|
expect(summary.weekly?.rateLimitType).toBe('seven_day_opus');
|
||||||
expect(summary.weekly?.remainingPercent).toBe(15);
|
expect(summary.weekly?.remainingPercent).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('considers oauth/cowork weekly windows in core summary', () => {
|
||||||
|
const summary = buildClaudeCoreUsageSummary([
|
||||||
|
{
|
||||||
|
rateLimitType: 'five_hour',
|
||||||
|
label: 'Session limit',
|
||||||
|
status: 'allowed',
|
||||||
|
utilization: 0.35,
|
||||||
|
usedPercent: 35,
|
||||||
|
remainingPercent: 65,
|
||||||
|
resetAt: '2026-02-28T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day_oauth_apps',
|
||||||
|
label: 'OAuth apps limit',
|
||||||
|
status: 'allowed_warning',
|
||||||
|
utilization: 0.92,
|
||||||
|
usedPercent: 92,
|
||||||
|
remainingPercent: 8,
|
||||||
|
resetAt: '2026-03-06T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day_cowork',
|
||||||
|
label: 'Cowork limit',
|
||||||
|
status: 'allowed',
|
||||||
|
utilization: 0.4,
|
||||||
|
usedPercent: 40,
|
||||||
|
remainingPercent: 60,
|
||||||
|
resetAt: '2026-03-06T12:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary.fiveHour?.rateLimitType).toBe('five_hour');
|
||||||
|
expect(summary.weekly?.rateLimitType).toBe('seven_day_oauth_apps');
|
||||||
|
expect(summary.weekly?.remainingPercent).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate weekly window into fiveHour when only weekly exists', () => {
|
||||||
|
const summary = buildClaudeCoreUsageSummary([
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day',
|
||||||
|
label: 'Weekly limit',
|
||||||
|
status: 'allowed',
|
||||||
|
utilization: 0.45,
|
||||||
|
usedPercent: 45,
|
||||||
|
remainingPercent: 55,
|
||||||
|
resetAt: '2026-03-06T10:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary.fiveHour).toBeNull();
|
||||||
|
expect(summary.weekly?.rateLimitType).toBe('seven_day');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses earliest reset as tie-breaker for equal weekly remaining quota', () => {
|
||||||
|
const summary = buildClaudeCoreUsageSummary([
|
||||||
|
{
|
||||||
|
rateLimitType: 'five_hour',
|
||||||
|
label: 'Session limit',
|
||||||
|
status: 'allowed',
|
||||||
|
utilization: 0.2,
|
||||||
|
usedPercent: 20,
|
||||||
|
remainingPercent: 80,
|
||||||
|
resetAt: '2026-02-28T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day_opus',
|
||||||
|
label: 'Opus limit',
|
||||||
|
status: 'allowed',
|
||||||
|
utilization: 0.6,
|
||||||
|
usedPercent: 60,
|
||||||
|
remainingPercent: 40,
|
||||||
|
resetAt: '2026-03-06T12:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day_sonnet',
|
||||||
|
label: 'Sonnet limit',
|
||||||
|
status: 'allowed',
|
||||||
|
utilization: 0.6,
|
||||||
|
usedPercent: 60,
|
||||||
|
remainingPercent: 40,
|
||||||
|
resetAt: '2026-03-06T10:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary.weekly?.rateLimitType).toBe('seven_day_sonnet');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchClaudeQuota', () => {
|
describe('fetchClaudeQuota', () => {
|
||||||
@@ -253,5 +376,142 @@ describe('Claude Quota Fetcher', () => {
|
|||||||
expect(result.error).toContain('Auth file not found');
|
expect(result.error).toContain('Auth file not found');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(0);
|
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries once on transient 500 then succeeds', async () => {
|
||||||
|
createClaudeAccount('claude-retry@example.com', {
|
||||||
|
access_token: 'retry-token',
|
||||||
|
expired: '2099-01-01T00:00:00.000Z',
|
||||||
|
type: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
global.fetch = mock(() => {
|
||||||
|
attempt += 1;
|
||||||
|
if (attempt === 1) {
|
||||||
|
return Promise.resolve(new Response('', { status: 500 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
restrictions: [
|
||||||
|
{
|
||||||
|
rateLimitType: 'five_hour',
|
||||||
|
utilization: 0.4,
|
||||||
|
resetsAt: '2026-03-01T01:00:00Z',
|
||||||
|
status: 'allowed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchClaudeQuota('claude-retry@example.com');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(attempt).toBe(2);
|
||||||
|
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries once after AbortError and succeeds', async () => {
|
||||||
|
createClaudeAccount('claude-timeout@example.com', {
|
||||||
|
access_token: 'timeout-token',
|
||||||
|
expired: '2099-01-01T00:00:00.000Z',
|
||||||
|
type: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
global.fetch = mock(() => {
|
||||||
|
attempt += 1;
|
||||||
|
if (attempt === 1) {
|
||||||
|
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||||
|
return Promise.reject(abortError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
restrictions: [
|
||||||
|
{
|
||||||
|
rateLimitType: 'seven_day',
|
||||||
|
utilization: 0.3,
|
||||||
|
resetsAt: '2026-03-07T01:00:00Z',
|
||||||
|
status: 'allowed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchClaudeQuota('claude-timeout@example.com');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(attempt).toBe(2);
|
||||||
|
expect(result.coreUsage?.weekly?.remainingPercent).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to alternate auth file when preferred file is invalid JSON', async () => {
|
||||||
|
const accountId = 'claude-fallback@example.com';
|
||||||
|
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||||
|
const authDir = path.join(cliproxyDir, 'auth');
|
||||||
|
const sanitized = sanitizeEmail(accountId);
|
||||||
|
|
||||||
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(authDir, `claude-${sanitized}.json`), '{invalid');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(authDir, `anthropic-${sanitized}.json`),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
access_token: 'valid-anthropic-token',
|
||||||
|
expired: '2099-01-01T00:00:00.000Z',
|
||||||
|
type: 'anthropic',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(cliproxyDir, 'accounts.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
providers: {
|
||||||
|
claude: {
|
||||||
|
default: accountId,
|
||||||
|
accounts: {
|
||||||
|
[accountId]: {
|
||||||
|
email: accountId,
|
||||||
|
tokenFile: `anthropic-${sanitized}.json`,
|
||||||
|
createdAt: '2026-02-20T00:00:00.000Z',
|
||||||
|
lastUsedAt: '2026-02-20T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
global.fetch = mock((_url: string, options?: RequestInit) => {
|
||||||
|
expect(options?.headers).toMatchObject({
|
||||||
|
Authorization: 'Bearer valid-anthropic-token',
|
||||||
|
});
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ restrictions: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchClaudeQuota(accountId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||||
|
import { parseProviderArg } from '../../../src/commands/cliproxy';
|
||||||
|
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseProviderArg', () => {
|
||||||
|
it('defaults to all when --provider is not specified', () => {
|
||||||
|
const result = parseProviderArg(['--verbose']);
|
||||||
|
|
||||||
|
expect(result.provider).toBe('all');
|
||||||
|
expect(result.invalid).toBe(false);
|
||||||
|
expect(result.remainingArgs).toEqual(['--verbose']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts canonical providers', () => {
|
||||||
|
const result = parseProviderArg(['--provider', 'claude']);
|
||||||
|
|
||||||
|
expect(result.provider).toBe('claude');
|
||||||
|
expect(result.invalid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts external aliases', () => {
|
||||||
|
const result = parseProviderArg(['--provider', 'anthropic']);
|
||||||
|
|
||||||
|
expect(result.provider).toBe('claude');
|
||||||
|
expect(result.invalid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks invalid when provider value is unsupported', () => {
|
||||||
|
const errorSpy = mock(() => {});
|
||||||
|
console.error = errorSpy as typeof console.error;
|
||||||
|
|
||||||
|
const result = parseProviderArg(['--provider', 'nope']);
|
||||||
|
|
||||||
|
expect(result.provider).toBe('all');
|
||||||
|
expect(result.invalid).toBe(true);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks invalid when --provider value is missing', () => {
|
||||||
|
const errorSpy = mock(() => {});
|
||||||
|
console.error = errorSpy as typeof console.error;
|
||||||
|
|
||||||
|
const result = parseProviderArg(['--provider']);
|
||||||
|
|
||||||
|
expect(result.provider).toBe('all');
|
||||||
|
expect(result.invalid).toBe(true);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,13 @@ import { cleanEmail } from './utils';
|
|||||||
import { AccountCardStats } from './account-card-stats';
|
import { AccountCardStats } from './account-card-stats';
|
||||||
|
|
||||||
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
||||||
|
const QUOTA_PROVIDER_ALIASES = [
|
||||||
|
'antigravity',
|
||||||
|
'anthropic',
|
||||||
|
'gemini-cli',
|
||||||
|
'copilot',
|
||||||
|
'github-copilot',
|
||||||
|
];
|
||||||
|
|
||||||
interface AccountCardProps {
|
interface AccountCardProps {
|
||||||
account: AccountData;
|
account: AccountData;
|
||||||
@@ -92,11 +99,14 @@ export function AccountCard({
|
|||||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||||
|
|
||||||
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
|
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
|
||||||
const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes(
|
const normalizedProvider = account.provider.toLowerCase();
|
||||||
account.provider as QuotaSupportedProvider
|
const isCliproxyProvider =
|
||||||
);
|
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
|
||||||
|
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
|
||||||
|
const isCodexProvider = normalizedProvider === 'codex';
|
||||||
|
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||||
account.provider,
|
normalizedProvider,
|
||||||
account.id,
|
account.id,
|
||||||
isCliproxyProvider
|
isCliproxyProvider
|
||||||
);
|
);
|
||||||
@@ -105,7 +115,7 @@ export function AccountCard({
|
|||||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||||
const resetTime = getProviderResetTime(account.provider, quota);
|
const resetTime = getProviderResetTime(account.provider, quota);
|
||||||
const codexBreakdown =
|
const codexBreakdown =
|
||||||
account.provider === 'codex' && quota && isCodexQuotaResult(quota)
|
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||||
? getCodexQuotaBreakdown(quota.windows)
|
? getCodexQuotaBreakdown(quota.windows)
|
||||||
: null;
|
: null;
|
||||||
const codexQuotaRows = [
|
const codexQuotaRows = [
|
||||||
@@ -113,7 +123,7 @@ export function AccountCard({
|
|||||||
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
||||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
].filter((row): row is { label: string; value: number } => row.value !== null);
|
||||||
const claudeQuotaRows =
|
const claudeQuotaRows =
|
||||||
account.provider === 'claude' && quota && isClaudeQuotaResult(quota)
|
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: '5h',
|
label: '5h',
|
||||||
@@ -140,13 +150,13 @@ export function AccountCard({
|
|||||||
},
|
},
|
||||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
].filter((row): row is { label: string; value: number } => row.value !== null)
|
||||||
: [];
|
: [];
|
||||||
const compactQuotaRows =
|
const compactQuotaRows = isCodexProvider
|
||||||
account.provider === 'codex'
|
? codexQuotaRows
|
||||||
? codexQuotaRows
|
: isClaudeProvider
|
||||||
: account.provider === 'claude'
|
? claudeQuotaRows
|
||||||
? claudeQuotaRows
|
: [];
|
||||||
: [];
|
|
||||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||||
|
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||||
|
|
||||||
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
||||||
const showTierBadge =
|
const showTierBadge =
|
||||||
@@ -258,7 +268,7 @@ export function AccountCard({
|
|||||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||||
<span>Quota...</span>
|
<span>Quota...</span>
|
||||||
</div>
|
</div>
|
||||||
) : minQuota !== null ? (
|
) : minQuotaValue !== null ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -270,9 +280,9 @@ export function AccountCard({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] font-mono font-bold',
|
'text-[10px] font-mono font-bold',
|
||||||
minQuota > 50
|
minQuotaValue > 50
|
||||||
? 'text-emerald-600 dark:text-emerald-400'
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
: minQuota > 20
|
: minQuotaValue > 20
|
||||||
? 'text-amber-500'
|
? 'text-amber-500'
|
||||||
: 'text-red-500'
|
: 'text-red-500'
|
||||||
)}
|
)}
|
||||||
@@ -293,13 +303,13 @@ export function AccountCard({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full transition-all',
|
'h-full rounded-full transition-all',
|
||||||
minQuota > 50
|
minQuotaValue > 50
|
||||||
? 'bg-emerald-500'
|
? 'bg-emerald-500'
|
||||||
: minQuota > 20
|
: minQuotaValue > 20
|
||||||
? 'bg-amber-500'
|
? 'bg-amber-500'
|
||||||
: 'bg-red-500'
|
: 'bg-red-500'
|
||||||
)}
|
)}
|
||||||
style={{ width: `${minQuota}%` }}
|
style={{ width: `${minQuotaValue}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,6 +319,8 @@ export function AccountCard({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
) : quota?.success ? (
|
||||||
|
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div>
|
||||||
) : quota?.needsReauth ? (
|
) : quota?.needsReauth ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -106,12 +106,16 @@ export function AccountItem({
|
|||||||
selected,
|
selected,
|
||||||
onSelectChange,
|
onSelectChange,
|
||||||
}: AccountItemProps) {
|
}: AccountItemProps) {
|
||||||
|
const normalizedProvider = account.provider.toLowerCase();
|
||||||
|
const isCodexProvider = normalizedProvider === 'codex';
|
||||||
|
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||||
|
|
||||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
||||||
const { data: stats } = useCliproxyStats(showQuota);
|
const { data: stats } = useCliproxyStats(showQuota);
|
||||||
|
|
||||||
// Fetch quota for all provider accounts
|
// Fetch quota for all provider accounts
|
||||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||||
account.provider,
|
normalizedProvider,
|
||||||
account.id,
|
account.id,
|
||||||
showQuota
|
showQuota
|
||||||
);
|
);
|
||||||
@@ -124,7 +128,7 @@ export function AccountItem({
|
|||||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||||
const nextReset = getProviderResetTime(account.provider, quota);
|
const nextReset = getProviderResetTime(account.provider, quota);
|
||||||
const codexBreakdown =
|
const codexBreakdown =
|
||||||
account.provider === 'codex' && quota && isCodexQuotaResult(quota)
|
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||||
? getCodexQuotaBreakdown(quota.windows)
|
? getCodexQuotaBreakdown(quota.windows)
|
||||||
: null;
|
: null;
|
||||||
const codexQuotaRows = [
|
const codexQuotaRows = [
|
||||||
@@ -132,7 +136,7 @@ export function AccountItem({
|
|||||||
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
||||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
].filter((row): row is { label: string; value: number } => row.value !== null);
|
||||||
const claudeQuotaRows =
|
const claudeQuotaRows =
|
||||||
account.provider === 'claude' && quota && isClaudeQuotaResult(quota)
|
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: '5h',
|
label: '5h',
|
||||||
@@ -159,13 +163,13 @@ export function AccountItem({
|
|||||||
},
|
},
|
||||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
].filter((row): row is { label: string; value: number } => row.value !== null)
|
||||||
: [];
|
: [];
|
||||||
const dualWindowQuotaRows =
|
const dualWindowQuotaRows = isCodexProvider
|
||||||
account.provider === 'codex'
|
? codexQuotaRows
|
||||||
? codexQuotaRows
|
: isClaudeProvider
|
||||||
: account.provider === 'claude'
|
? claudeQuotaRows
|
||||||
? claudeQuotaRows
|
: [];
|
||||||
: [];
|
|
||||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||||
|
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -359,7 +363,7 @@ export function AccountItem({
|
|||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
<span>Loading quota...</span>
|
<span>Loading quota...</span>
|
||||||
</div>
|
</div>
|
||||||
) : minQuota !== null ? (
|
) : minQuotaValue !== null ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Status indicator based on runtime usage, not file state */}
|
{/* Status indicator based on runtime usage, not file state */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
@@ -409,9 +413,9 @@ export function AccountItem({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress
|
<Progress
|
||||||
value={Math.max(0, Math.min(100, minQuota))}
|
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||||
className="h-2 flex-1"
|
className="h-2 flex-1"
|
||||||
indicatorClassName={getQuotaColor(minQuota)}
|
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium w-10 text-right">
|
<span className="text-xs font-medium w-10 text-right">
|
||||||
{minQuotaLabel}%
|
{minQuotaLabel}%
|
||||||
@@ -425,6 +429,16 @@ export function AccountItem({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
) : quota?.success ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-3 h-3" />
|
||||||
|
No limits
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
) : quota?.needsReauth ? (
|
) : quota?.needsReauth ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ export function ModelConfigTab({
|
|||||||
privacyMode,
|
privacyMode,
|
||||||
isRemoteMode,
|
isRemoteMode,
|
||||||
}: ModelConfigTabProps) {
|
}: ModelConfigTabProps) {
|
||||||
|
const normalizedProvider = provider.toLowerCase();
|
||||||
|
const showQuota =
|
||||||
|
(QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
|
||||||
|
['anthropic', 'antigravity', 'gemini-cli', 'copilot', 'github-copilot'].includes(
|
||||||
|
normalizedProvider
|
||||||
|
)) &&
|
||||||
|
!isRemoteMode;
|
||||||
|
|
||||||
// Kiro-specific: no-incognito setting (defaults to true = normal browser)
|
// Kiro-specific: no-incognito setting (defaults to true = normal browser)
|
||||||
const isKiro = provider === 'kiro';
|
const isKiro = provider === 'kiro';
|
||||||
const [kiroNoIncognito, setKiroNoIncognito] = useState(true);
|
const [kiroNoIncognito, setKiroNoIncognito] = useState(true);
|
||||||
@@ -177,9 +185,7 @@ export function ModelConfigTab({
|
|||||||
isBulkPausing={isBulkPausing}
|
isBulkPausing={isBulkPausing}
|
||||||
isBulkResuming={isBulkResuming}
|
isBulkResuming={isBulkResuming}
|
||||||
privacyMode={privacyMode}
|
privacyMode={privacyMode}
|
||||||
showQuota={
|
showQuota={showQuota}
|
||||||
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode
|
|
||||||
}
|
|
||||||
isKiro={isKiro}
|
isKiro={isKiro}
|
||||||
kiroNoIncognito={kiroNoIncognito}
|
kiroNoIncognito={kiroNoIncognito}
|
||||||
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
||||||
|
|||||||
@@ -216,6 +216,26 @@ export type {
|
|||||||
/** Providers with quota API support */
|
/** Providers with quota API support */
|
||||||
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const;
|
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const;
|
||||||
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
|
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
|
||||||
|
const QUOTA_PROVIDER_ALIAS_MAP: Readonly<Record<string, QuotaSupportedProvider>> = {
|
||||||
|
antigravity: 'agy',
|
||||||
|
anthropic: 'claude',
|
||||||
|
'gemini-cli': 'gemini',
|
||||||
|
copilot: 'ghcp',
|
||||||
|
'github-copilot': 'ghcp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeQuotaProvider(provider: string): QuotaSupportedProvider | null {
|
||||||
|
const normalized = provider.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((QUOTA_SUPPORTED_PROVIDERS as readonly string[]).includes(normalized)) {
|
||||||
|
return normalized as QuotaSupportedProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QUOTA_PROVIDER_ALIAS_MAP[normalized] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch account quota from generic API route
|
* Fetch account quota from generic API route
|
||||||
@@ -317,7 +337,12 @@ async function fetchQuotaByProvider(
|
|||||||
provider: string,
|
provider: string,
|
||||||
accountId: string
|
accountId: string
|
||||||
): Promise<UnifiedQuotaResult> {
|
): Promise<UnifiedQuotaResult> {
|
||||||
switch (provider) {
|
const canonicalProvider = normalizeQuotaProvider(provider);
|
||||||
|
if (!canonicalProvider) {
|
||||||
|
return fetchAccountQuota(provider, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (canonicalProvider) {
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return fetchCodexQuotaApi(accountId);
|
return fetchCodexQuotaApi(accountId);
|
||||||
case 'claude':
|
case 'claude':
|
||||||
@@ -336,13 +361,12 @@ async function fetchQuotaByProvider(
|
|||||||
* Supports agy, codex, claude, gemini, and ghcp providers
|
* Supports agy, codex, claude, gemini, and ghcp providers
|
||||||
*/
|
*/
|
||||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||||
|
const canonicalProvider = normalizeQuotaProvider(provider);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['account-quota', provider, accountId],
|
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
|
||||||
queryFn: () => fetchQuotaByProvider(provider, accountId),
|
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
|
||||||
enabled:
|
enabled: enabled && !!canonicalProvider && !!accountId,
|
||||||
enabled &&
|
|
||||||
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) &&
|
|
||||||
!!accountId,
|
|
||||||
staleTime: 60000, // Match refetchInterval to prevent early refetching
|
staleTime: 60000, // Match refetchInterval to prevent early refetching
|
||||||
refetchInterval: 60000, // Refresh every 1 minute
|
refetchInterval: 60000, // Refresh every 1 minute
|
||||||
refetchOnWindowFocus: false, // Don't refetch on tab switch
|
refetchOnWindowFocus: false, // Don't refetch on tab switch
|
||||||
|
|||||||
+4
-2
@@ -670,8 +670,9 @@ export function getProviderMinQuota(
|
|||||||
quota: UnifiedQuotaResult | null | undefined
|
quota: UnifiedQuotaResult | null | undefined
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!quota?.success) return null;
|
if (!quota?.success) return null;
|
||||||
|
const normalizedProvider = provider.trim().toLowerCase();
|
||||||
|
|
||||||
switch (provider) {
|
switch (normalizedProvider) {
|
||||||
case 'agy':
|
case 'agy':
|
||||||
if (isAgyQuotaResult(quota)) {
|
if (isAgyQuotaResult(quota)) {
|
||||||
return getMinClaudeQuota(quota.models);
|
return getMinClaudeQuota(quota.models);
|
||||||
@@ -713,8 +714,9 @@ export function getProviderResetTime(
|
|||||||
quota: UnifiedQuotaResult | null | undefined
|
quota: UnifiedQuotaResult | null | undefined
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!quota?.success) return null;
|
if (!quota?.success) return null;
|
||||||
|
const normalizedProvider = provider.trim().toLowerCase();
|
||||||
|
|
||||||
switch (provider) {
|
switch (normalizedProvider) {
|
||||||
case 'agy':
|
case 'agy':
|
||||||
if (isAgyQuotaResult(quota)) {
|
if (isAgyQuotaResult(quota)) {
|
||||||
return getClaudeResetTime(quota.models);
|
return getClaudeResetTime(quota.models);
|
||||||
|
|||||||
Reference in new issue
Block a user