diff --git a/src/web-server/routes/bar-pool-account-quota-adapter.ts b/src/web-server/routes/bar-pool-account-quota-adapter.ts new file mode 100644 index 00000000..09e3eab0 --- /dev/null +++ b/src/web-server/routes/bar-pool-account-quota-adapter.ts @@ -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(); + +export interface BarPoolAccountQuotaFetcherDeps { + fetchLegacyAccountQuota: (provider: CLIProxyProvider, accountId: string) => Promise; + fetchClaudeQuota: (accountId: string) => Promise; + fetchCodexQuota: (accountId: string) => Promise; +} + +function normalizeRemainingPercent(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function isPresent(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( + provider: CLIProxyProvider | string, + accountId: string +): T | null { + const cached = getCachedQuota(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( + 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 { + 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, +}); diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index 3acc5fb9..f68328fc 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -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(), diff --git a/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts new file mode 100644 index 00000000..01da3de3 --- /dev/null +++ b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts @@ -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 { + return { + success: true, + models: [{ name: 'gemini-pro', percentage: 81, resetTime: null }], + lastUpdated: 1, + ...overrides, + }; +} + +function claudeResult(overrides: Partial = {}): 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 { + 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 { + 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', + }); + }); + +}); diff --git a/tests/unit/web-server/bar-pool-account-quota-cache.test.ts b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts new file mode 100644 index 00000000..9a169377 --- /dev/null +++ b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts @@ -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((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>; + const second = (await (await fetch(url)).json()) as Array>; + + 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('claude', 'claude@example.com'); + expect(sharedCache?.windows).toEqual([]); + expect((sharedCache as ClaudeQuotaResult & Partial)?.models).toBeUndefined(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + clearQuotaCache(); + } +});