fix(bar): show Claude and Codex pool quota

This commit is contained in:
Tam Nhu Tran committed 2026-07-18 17:13:40 -04:00
1 parent 1b67c7840b
commit f77ed33cc7
4 files changed
+555 -9

No files matched your search

@@ -0,0 +1,213 @@
/**
* Bar-only adapter for CLIProxy pool-account quota.
*
* Bar's cache and row builder consume the legacy Antigravity QuotaResult shape.
* Claude and Codex expose richer window-based results, so this adapter dispatches
* to their single-account fetchers and collapses the binding core window into
* the existing percentage/reset fields. Other providers retain the legacy
* fetcher's behavior, including Antigravity and quota_not_supported results.
*/
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
import {
getCachedQuota,
invalidateQuotaCache,
setCachedQuota,
} from '../../cliproxy/quota/quota-response-cache';
import {
buildClaudeCoreUsageSummary,
fetchClaudeQuota,
} from '../../cliproxy/quota/quota-fetcher-claude';
import {
buildCodexCoreUsageSummary,
fetchCodexQuota,
} from '../../cliproxy/quota/quota-fetcher-codex';
import type {
ClaudeQuotaResult,
CodexQuotaResult,
QuotaErrorMetadata,
} from '../../cliproxy/quota/quota-types';
interface BindingWindow {
name: string;
displayName: string;
remainingPercent: number;
resetAt: string | null;
}
interface ProviderQuotaResult extends QuotaErrorMetadata {
success: boolean;
lastUpdated: number;
error?: string;
accountId?: string;
needsReauth?: boolean;
}
type RawWindowQuotaResult = ClaudeQuotaResult | CodexQuotaResult;
const rawQuotaByNormalizedResult = new WeakMap<QuotaResult, RawWindowQuotaResult>();
export interface BarPoolAccountQuotaFetcherDeps {
fetchLegacyAccountQuota: (provider: CLIProxyProvider, accountId: string) => Promise<QuotaResult>;
fetchClaudeQuota: (accountId: string) => Promise<ClaudeQuotaResult>;
fetchCodexQuota: (accountId: string) => Promise<CodexQuotaResult>;
}
function normalizeRemainingPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function isPresent<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function resetTimestamp(resetAt: string | null): number {
if (!resetAt) return Number.POSITIVE_INFINITY;
const timestamp = Date.parse(resetAt);
return Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY;
}
function pickBindingWindow(windows: BindingWindow[]): BindingWindow | null {
return (
windows
.filter((window) => Number.isFinite(window.remainingPercent))
.sort(
(left, right) =>
left.remainingPercent - right.remainingPercent ||
resetTimestamp(left.resetAt) - resetTimestamp(right.resetAt)
)[0] ?? null
);
}
function normalizeResult(
result: ProviderQuotaResult,
bindingWindow: BindingWindow | null
): QuotaResult {
return {
success: result.success,
models:
result.success && bindingWindow
? [
{
name: bindingWindow.name,
displayName: bindingWindow.displayName,
percentage: normalizeRemainingPercent(bindingWindow.remainingPercent),
resetTime: bindingWindow.resetAt,
},
]
: [],
lastUpdated: result.lastUpdated,
httpStatus: result.httpStatus,
errorCode: result.errorCode,
errorDetail: result.errorDetail,
isForbidden: result.isForbidden,
error: result.error,
actionHint: result.actionHint,
retryable: result.retryable,
needsReauth: result.needsReauth,
accountId: result.accountId,
};
}
function normalizeClaudeQuota(result: ClaudeQuotaResult): QuotaResult {
const coreUsage =
result.coreUsage?.fiveHour || result.coreUsage?.weekly
? result.coreUsage
: buildClaudeCoreUsageSummary(result.windows);
const bindingWindow = pickBindingWindow(
[coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
name: window.rateLimitType,
displayName: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
}))
);
const normalized = normalizeResult(result, bindingWindow);
rawQuotaByNormalizedResult.set(normalized, result);
return normalized;
}
function normalizeCodexQuota(result: CodexQuotaResult): QuotaResult {
const coreUsage =
result.coreUsage?.fiveHour || result.coreUsage?.weekly
? result.coreUsage
: buildCodexCoreUsageSummary(result.windows);
const bindingWindow = pickBindingWindow(
[coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
name: window.label,
displayName: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
}))
);
const normalized = normalizeResult(result, bindingWindow);
rawQuotaByNormalizedResult.set(normalized, result);
return normalized;
}
function isWindowQuotaResult(value: unknown): value is RawWindowQuotaResult {
return (
typeof value === 'object' &&
value !== null &&
'windows' in value &&
Array.isArray((value as { windows?: unknown }).windows)
);
}
export function getCachedBarPoolAccountQuota<T>(
provider: CLIProxyProvider | string,
accountId: string
): T | null {
const cached = getCachedQuota<unknown>(provider, accountId);
if (!cached) return null;
if (provider === 'claude' && isWindowQuotaResult(cached)) {
return normalizeClaudeQuota(cached as ClaudeQuotaResult) as T;
}
if (provider === 'codex' && isWindowQuotaResult(cached)) {
return normalizeCodexQuota(cached as CodexQuotaResult) as T;
}
return cached as T;
}
export function setCachedBarPoolAccountQuota<T>(
provider: CLIProxyProvider | string,
accountId: string,
data: T
): void {
const raw =
typeof data === 'object' && data !== null
? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult)
: undefined;
setCachedQuota(provider, accountId, raw ?? data);
}
export function invalidateCachedBarPoolAccountQuota(
provider: CLIProxyProvider | string,
accountId: string
): void {
invalidateQuotaCache(provider, accountId);
}
export function createBarPoolAccountQuotaFetcher(
deps: BarPoolAccountQuotaFetcherDeps
): (provider: CLIProxyProvider, accountId: string) => Promise<QuotaResult> {
return async (provider, accountId) => {
if (provider === 'claude') {
return normalizeClaudeQuota(await deps.fetchClaudeQuota(accountId));
}
if (provider === 'codex') {
return normalizeCodexQuota(await deps.fetchCodexQuota(accountId));
}
return deps.fetchLegacyAccountQuota(provider, accountId);
};
}
export const fetchBarPoolAccountQuota = createBarPoolAccountQuotaFetcher({
fetchLegacyAccountQuota: fetchAccountQuota,
fetchClaudeQuota,
fetchCodexQuota,
});
+9 -9
View File
@@ -631,11 +631,11 @@ export function createBarRouter(deps: BarRouterDeps): Router {
import { getAllAccountsSummary } from '../../cliproxy/accounts/query';
import {
getCachedQuota,
setCachedQuota,
invalidateQuotaCache,
} from '../../cliproxy/quota/quota-response-cache';
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
fetchBarPoolAccountQuota,
getCachedBarPoolAccountQuota,
invalidateCachedBarPoolAccountQuota,
setCachedBarPoolAccountQuota,
} from './bar-pool-account-quota-adapter';
import { getTodayCostByAccount } from '../usage/data-aggregator';
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
@@ -644,10 +644,10 @@ import { getNativeAccountRows, getCachedNativeAccountRows } from '../usage/nativ
/** Production bar router — wired to real dependencies */
const barRouter: Router = createBarRouter({
getAllAccountsSummary,
getCachedQuota,
setCachedQuota,
invalidateQuotaCache,
fetchAccountQuota,
getCachedQuota: getCachedBarPoolAccountQuota,
setCachedQuota: setCachedBarPoolAccountQuota,
invalidateQuotaCache: invalidateCachedBarPoolAccountQuota,
fetchAccountQuota: fetchBarPoolAccountQuota,
getTodayCostByAccount,
loadCliproxyDetails: loadCliproxySnapshotDetails,
loadDailyUsage: () => getCachedDailyData(),
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'bun:test';
import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher';
import type {
ClaudeQuotaResult,
CodexQuotaResult,
} from '../../../src/cliproxy/quota/quota-types';
import {
createBarPoolAccountQuotaFetcher,
type BarPoolAccountQuotaFetcherDeps,
} from '../../../src/web-server/routes/bar-pool-account-quota-adapter';
function agyResult(overrides: Partial<QuotaResult> = {}): QuotaResult {
return {
success: true,
models: [{ name: 'gemini-pro', percentage: 81, resetTime: null }],
lastUpdated: 1,
...overrides,
};
}
function claudeResult(overrides: Partial<ClaudeQuotaResult> = {}): ClaudeQuotaResult {
return {
success: true,
windows: [],
coreUsage: {
fiveHour: {
rateLimitType: 'five_hour',
label: '5h usage limit',
remainingPercent: 72,
resetAt: '2026-07-18T16:00:00.000Z',
status: 'allowed',
},
weekly: {
rateLimitType: 'seven_day',
label: 'Weekly usage limit',
remainingPercent: 45,
resetAt: '2026-07-21T12:00:00.000Z',
status: 'allowed_warning',
},
},
lastUpdated: 2,
accountId: 'claude@example.com',
...overrides,
};
}
function codexResult(overrides: Partial<CodexQuotaResult> = {}): CodexQuotaResult {
return {
success: true,
windows: [],
coreUsage: {
fiveHour: {
label: 'Primary',
remainingPercent: 36,
resetAfterSeconds: 3600,
resetAt: '2026-07-18T14:00:00.000Z',
},
weekly: {
label: 'Secondary',
remainingPercent: 64,
resetAfterSeconds: 259200,
resetAt: '2026-07-21T13:00:00.000Z',
},
},
planType: 'pro',
lastUpdated: 3,
accountId: 'codex@example.com',
...overrides,
};
}
function createDeps(
overrides: Partial<BarPoolAccountQuotaFetcherDeps> = {}
): BarPoolAccountQuotaFetcherDeps {
return {
fetchLegacyAccountQuota: async () => agyResult(),
fetchClaudeQuota: async () => claudeResult(),
fetchCodexQuota: async () => codexResult(),
...overrides,
};
}
describe('Bar pool account quota adapter', () => {
it('maps the most restrictive Claude core window to percentage and reset', async () => {
const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps());
const result = await fetchQuota('claude', 'claude@example.com');
expect(result.success).toBe(true);
expect(result.models).toEqual([
{
name: 'seven_day',
displayName: 'Weekly usage limit',
percentage: 45,
resetTime: '2026-07-21T12:00:00.000Z',
},
]);
expect(result.accountId).toBe('claude@example.com');
});
it('maps the most restrictive Codex core window to percentage and reset', async () => {
const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps());
const result = await fetchQuota('codex', 'codex@example.com');
expect(result.success).toBe(true);
expect(result.models).toEqual([
{
name: 'Primary',
displayName: 'Primary',
percentage: 36,
resetTime: '2026-07-18T14:00:00.000Z',
},
]);
expect(result.accountId).toBe('codex@example.com');
});
it('preserves Claude transient failure metadata', async () => {
const fetchQuota = createBarPoolAccountQuotaFetcher(
createDeps({
fetchClaudeQuota: async () =>
claudeResult({
success: false,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
error: 'Claude OAuth usage request timeout',
errorCode: 'network_timeout',
retryable: true,
}),
})
);
const result = await fetchQuota('claude', 'claude@example.com');
expect(result).toMatchObject({
success: false,
models: [],
error: 'Claude OAuth usage request timeout',
errorCode: 'network_timeout',
retryable: true,
});
});
it('preserves Codex reauthentication state', async () => {
const fetchQuota = createBarPoolAccountQuotaFetcher(
createDeps({
fetchCodexQuota: async () =>
codexResult({
success: false,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
error: 'Token expired or invalid',
errorCode: 'reauth_required',
httpStatus: 401,
needsReauth: true,
retryable: false,
}),
})
);
const result = await fetchQuota('codex', 'codex@example.com');
expect(result).toMatchObject({
success: false,
models: [],
errorCode: 'reauth_required',
httpStatus: 401,
needsReauth: true,
retryable: false,
});
});
it('delegates Antigravity unchanged to the legacy fetcher', async () => {
const expected = agyResult();
const calls: Array<[string, string]> = [];
const fetchQuota = createBarPoolAccountQuotaFetcher(
createDeps({
fetchLegacyAccountQuota: async (provider, accountId) => {
calls.push([provider, accountId]);
return expected;
},
})
);
const result = await fetchQuota('agy', 'agy@example.com');
expect(result).toBe(expected);
expect(calls).toEqual([['agy', 'agy@example.com']]);
});
it('preserves legacy unsupported-provider semantics', async () => {
const fetchQuota = createBarPoolAccountQuotaFetcher(
createDeps({
fetchLegacyAccountQuota: async (provider) =>
agyResult({
success: false,
models: [],
error: `Quota not supported for provider: ${provider}`,
errorCode: 'quota_not_supported',
}),
})
);
const result = await fetchQuota('kiro', 'kiro-account');
expect(result).toMatchObject({
success: false,
models: [],
errorCode: 'quota_not_supported',
});
});
});
@@ -0,0 +1,120 @@
import { expect, it } from 'bun:test';
import express from 'express';
import type { Server } from 'node:http';
import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher';
import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types';
import {
clearQuotaCache,
getCachedQuota,
} from '../../../src/cliproxy/quota/quota-response-cache';
import {
createBarPoolAccountQuotaFetcher,
getCachedBarPoolAccountQuota,
invalidateCachedBarPoolAccountQuota,
setCachedBarPoolAccountQuota,
} from '../../../src/web-server/routes/bar-pool-account-quota-adapter';
import {
createBarRouter,
resetForceFreshDebounce,
} from '../../../src/web-server/routes/bar-routes';
it('stores normalized Claude quota in the existing Bar cache', async () => {
clearQuotaCache();
let fetchCount = 0;
const fetchQuota = createBarPoolAccountQuotaFetcher({
fetchLegacyAccountQuota: async () => {
throw new Error('Legacy fetcher should not be called');
},
fetchClaudeQuota: async () => {
fetchCount += 1;
return {
success: true,
windows: [],
coreUsage: {
fiveHour: {
rateLimitType: 'five_hour',
label: '5h usage limit',
remainingPercent: 72,
resetAt: '2026-07-18T16:00:00.000Z',
status: 'allowed',
},
weekly: {
rateLimitType: 'seven_day',
label: 'Weekly usage limit',
remainingPercent: 45,
resetAt: '2026-07-21T12:00:00.000Z',
status: 'allowed_warning',
},
},
lastUpdated: 2,
accountId: 'claude@example.com',
};
},
fetchCodexQuota: async () => {
throw new Error('Codex fetcher should not be called');
},
});
const app = express();
app.use(
'/api/bar',
createBarRouter({
getAllAccountsSummary: () =>
({
claude: [
{
id: 'claude@example.com',
provider: 'claude',
nickname: 'Claude pool',
paused: false,
isDefault: true,
tokenFile: 'claude-claude_example_com.json',
createdAt: '2026-07-18T00:00:00.000Z',
},
],
}) as never,
getCachedQuota: getCachedBarPoolAccountQuota,
setCachedQuota: setCachedBarPoolAccountQuota,
invalidateQuotaCache: invalidateCachedBarPoolAccountQuota,
fetchAccountQuota: fetchQuota,
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
})
);
resetForceFreshDebounce();
const server = await new Promise<Server>((resolve, reject) => {
const instance = app.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
try {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('No server address');
const url = `http://127.0.0.1:${address.port}/api/bar/summary`;
const first = (await (await fetch(url)).json()) as Array<Record<string, unknown>>;
const second = (await (await fetch(url)).json()) as Array<Record<string, unknown>>;
expect(fetchCount).toBe(1);
expect(first[0]).toMatchObject({
quota_percentage: 45,
next_reset: '2026-07-21T12:00:00.000Z',
quotaStatus: 'ok',
cached: false,
});
expect(second[0]).toMatchObject({
quota_percentage: 45,
next_reset: '2026-07-21T12:00:00.000Z',
quotaStatus: 'ok',
cached: true,
});
const sharedCache = getCachedQuota<ClaudeQuotaResult>('claude', 'claude@example.com');
expect(sharedCache?.windows).toEqual([]);
expect((sharedCache as ClaudeQuotaResult & Partial<QuotaResult>)?.models).toBeUndefined();
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
clearQuotaCache();
}
});