diff --git a/src/web-server/routes/bar-pool-account-quota-adapter.ts b/src/web-server/routes/bar-pool-account-quota-adapter.ts index 09e3eab0..76b5348d 100644 --- a/src/web-server/routes/bar-pool-account-quota-adapter.ts +++ b/src/web-server/routes/bar-pool-account-quota-adapter.ts @@ -29,6 +29,7 @@ import type { CodexQuotaResult, QuotaErrorMetadata, } from '../../cliproxy/quota/quota-types'; +import { shouldCacheQuotaResult } from './cliproxy-stats-routes/quota-helpers'; interface BindingWindow { name: string; @@ -81,9 +82,22 @@ function pickBindingWindow(windows: BindingWindow[]): BindingWindow | null { ); } +function pickEarliestReset(windows: BindingWindow[]): string | null { + return ( + windows + .map((window) => ({ + resetAt: window.resetAt, + timestamp: resetTimestamp(window.resetAt), + })) + .filter((reset) => Number.isFinite(reset.timestamp)) + .sort((left, right) => left.timestamp - right.timestamp)[0]?.resetAt ?? null + ); +} + function normalizeResult( result: ProviderQuotaResult, - bindingWindow: BindingWindow | null + bindingWindow: BindingWindow | null, + earliestReset: string | null ): QuotaResult { return { success: result.success, @@ -94,7 +108,7 @@ function normalizeResult( name: bindingWindow.name, displayName: bindingWindow.displayName, percentage: normalizeRemainingPercent(bindingWindow.remainingPercent), - resetTime: bindingWindow.resetAt, + resetTime: earliestReset, }, ] : [], @@ -116,16 +130,15 @@ function normalizeClaudeQuota(result: ClaudeQuotaResult): QuotaResult { 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 windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ + name: window.rateLimitType, + displayName: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + })); + const bindingWindow = pickBindingWindow(windows); - const normalized = normalizeResult(result, bindingWindow); + const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows)); rawQuotaByNormalizedResult.set(normalized, result); return normalized; } @@ -135,16 +148,15 @@ function normalizeCodexQuota(result: CodexQuotaResult): QuotaResult { 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 windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ + name: window.label, + displayName: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + })); + const bindingWindow = pickBindingWindow(windows); - const normalized = normalizeResult(result, bindingWindow); + const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows)); rawQuotaByNormalizedResult.set(normalized, result); return normalized; } @@ -182,7 +194,17 @@ export function setCachedBarPoolAccountQuota( typeof data === 'object' && data !== null ? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult) : undefined; - setCachedQuota(provider, accountId, raw ?? data); + const cacheValue = raw ?? data; + if ( + typeof cacheValue === 'object' && + cacheValue !== null && + 'success' in cacheValue && + typeof cacheValue.success === 'boolean' && + !shouldCacheQuotaResult(cacheValue as ProviderQuotaResult) + ) { + return; + } + setCachedQuota(provider, accountId, cacheValue); } export function invalidateCachedBarPoolAccountQuota( 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 index 01da3de3..1aa5dd29 100644 --- a/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts +++ b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts @@ -57,7 +57,7 @@ function codexResult(overrides: Partial = {}): CodexQuotaResul }, weekly: { label: 'Secondary', - remainingPercent: 64, + remainingPercent: 24, resetAfterSeconds: 259200, resetAt: '2026-07-21T13:00:00.000Z', }, @@ -81,7 +81,7 @@ function createDeps( } describe('Bar pool account quota adapter', () => { - it('maps the most restrictive Claude core window to percentage and reset', async () => { + it('maps Claude percentage from the most restrictive window and reset from the earliest', async () => { const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps()); const result = await fetchQuota('claude', 'claude@example.com'); @@ -92,13 +92,13 @@ describe('Bar pool account quota adapter', () => { name: 'seven_day', displayName: 'Weekly usage limit', percentage: 45, - resetTime: '2026-07-21T12:00:00.000Z', + resetTime: '2026-07-18T16:00:00.000Z', }, ]); expect(result.accountId).toBe('claude@example.com'); }); - it('maps the most restrictive Codex core window to percentage and reset', async () => { + it('maps Codex percentage from the most restrictive window and reset from the earliest', async () => { const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps()); const result = await fetchQuota('codex', 'codex@example.com'); @@ -106,9 +106,9 @@ describe('Bar pool account quota adapter', () => { expect(result.success).toBe(true); expect(result.models).toEqual([ { - name: 'Primary', - displayName: 'Primary', - percentage: 36, + name: 'Secondary', + displayName: 'Secondary', + percentage: 24, resetTime: '2026-07-18T14:00:00.000Z', }, ]); @@ -209,5 +209,4 @@ describe('Bar pool account quota adapter', () => { errorCode: 'quota_not_supported', }); }); - }); diff --git a/tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts b/tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts new file mode 100644 index 00000000..e6e474ed --- /dev/null +++ b/tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts @@ -0,0 +1,111 @@ +import { afterEach, 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 { + clearQuotaCache, + getCachedQuota, +} from '../../../src/cliproxy/quota/quota-response-cache'; +import { + createBarPoolAccountQuotaFetcher, + getCachedBarPoolAccountQuota, + setCachedBarPoolAccountQuota, +} from '../../../src/web-server/routes/bar-pool-account-quota-adapter'; + +function createFetcher( + claudeResult: ClaudeQuotaResult, + codexResult: CodexQuotaResult +): (provider: 'claude' | 'codex', accountId: string) => Promise { + return createBarPoolAccountQuotaFetcher({ + fetchLegacyAccountQuota: async () => { + throw new Error('Legacy fetcher should not be called'); + }, + fetchClaudeQuota: async () => claudeResult, + fetchCodexQuota: async () => codexResult, + }); +} + +function claudeFailure(overrides: Partial = {}): ClaudeQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: 1, + accountId: 'claude@example.com', + ...overrides, + }; +} + +function codexFailure(overrides: Partial = {}): CodexQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'pro', + lastUpdated: 2, + accountId: 'codex@example.com', + ...overrides, + }; +} + +afterEach(() => { + clearQuotaCache(); +}); + +describe('Bar pool account quota cache policy', () => { + it('does not cache a retryable Claude timeout', async () => { + const raw = claudeFailure({ + error: 'Claude OAuth usage request timeout', + errorCode: 'network_timeout', + retryable: true, + }); + const fetchQuota = createFetcher(raw, codexFailure()); + const result = await fetchQuota('claude', 'claude@example.com'); + + setCachedBarPoolAccountQuota('claude', 'claude@example.com', result); + + expect(getCachedQuota('claude', 'claude@example.com')).toBeNull(); + expect(getCachedBarPoolAccountQuota('claude', 'claude@example.com')).toBeNull(); + }); + + it('does not cache a Codex 429 failure', async () => { + const raw = codexFailure({ + error: 'Codex usage API rate limited', + errorCode: 'rate_limited', + httpStatus: 429, + }); + const fetchQuota = createFetcher(claudeFailure(), raw); + const result = await fetchQuota('codex', 'codex@example.com'); + + setCachedBarPoolAccountQuota('codex', 'codex@example.com', result); + + expect(getCachedQuota('codex', 'codex@example.com')).toBeNull(); + expect(getCachedBarPoolAccountQuota('codex', 'codex@example.com')).toBeNull(); + }); + + it('caches a stable Codex reauthentication failure in raw provider shape', async () => { + const raw = codexFailure({ + error: 'Token expired or invalid', + errorCode: 'reauth_required', + httpStatus: 401, + needsReauth: true, + retryable: false, + }); + const fetchQuota = createFetcher(claudeFailure(), raw); + const result = await fetchQuota('codex', 'codex@example.com'); + + setCachedBarPoolAccountQuota('codex', 'codex@example.com', result); + + const shared = getCachedQuota('codex', 'codex@example.com'); + expect(shared).toEqual(raw); + expect((shared as CodexQuotaResult & Partial)?.models).toBeUndefined(); + expect(getCachedBarPoolAccountQuota('codex', 'codex@example.com')).toMatchObject({ + success: false, + models: [], + needsReauth: true, + httpStatus: 401, + }); + }); +}); 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 index 9a169377..557dfeeb 100644 --- a/tests/unit/web-server/bar-pool-account-quota-cache.test.ts +++ b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts @@ -100,13 +100,13 @@ it('stores normalized Claude quota in the existing Bar cache', async () => { expect(fetchCount).toBe(1); expect(first[0]).toMatchObject({ quota_percentage: 45, - next_reset: '2026-07-21T12:00:00.000Z', + next_reset: '2026-07-18T16:00:00.000Z', quotaStatus: 'ok', cached: false, }); expect(second[0]).toMatchObject({ quota_percentage: 45, - next_reset: '2026-07-21T12:00:00.000Z', + next_reset: '2026-07-18T16:00:00.000Z', quotaStatus: 'ok', cached: true, });