mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
fix(bar): show Claude and Codex pool quota
This commit is contained in:
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,
|
||||||
|
});
|
||||||
@@ -631,11 +631,11 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
|||||||
|
|
||||||
import { getAllAccountsSummary } from '../../cliproxy/accounts/query';
|
import { getAllAccountsSummary } from '../../cliproxy/accounts/query';
|
||||||
import {
|
import {
|
||||||
getCachedQuota,
|
fetchBarPoolAccountQuota,
|
||||||
setCachedQuota,
|
getCachedBarPoolAccountQuota,
|
||||||
invalidateQuotaCache,
|
invalidateCachedBarPoolAccountQuota,
|
||||||
} from '../../cliproxy/quota/quota-response-cache';
|
setCachedBarPoolAccountQuota,
|
||||||
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
|
} from './bar-pool-account-quota-adapter';
|
||||||
import { getTodayCostByAccount } from '../usage/data-aggregator';
|
import { getTodayCostByAccount } from '../usage/data-aggregator';
|
||||||
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
|
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
|
||||||
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
|
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
|
||||||
@@ -644,10 +644,10 @@ import { getNativeAccountRows, getCachedNativeAccountRows } from '../usage/nativ
|
|||||||
/** Production bar router — wired to real dependencies */
|
/** Production bar router — wired to real dependencies */
|
||||||
const barRouter: Router = createBarRouter({
|
const barRouter: Router = createBarRouter({
|
||||||
getAllAccountsSummary,
|
getAllAccountsSummary,
|
||||||
getCachedQuota,
|
getCachedQuota: getCachedBarPoolAccountQuota,
|
||||||
setCachedQuota,
|
setCachedQuota: setCachedBarPoolAccountQuota,
|
||||||
invalidateQuotaCache,
|
invalidateQuotaCache: invalidateCachedBarPoolAccountQuota,
|
||||||
fetchAccountQuota,
|
fetchAccountQuota: fetchBarPoolAccountQuota,
|
||||||
getTodayCostByAccount,
|
getTodayCostByAccount,
|
||||||
loadCliproxyDetails: loadCliproxySnapshotDetails,
|
loadCliproxyDetails: loadCliproxySnapshotDetails,
|
||||||
loadDailyUsage: () => getCachedDailyData(),
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user