mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +00:00
fix(bar): preserve quota cache and reset semantics
This commit is contained in:
1 parent
f77ed33cc7
commit
8996c92fda
4 files changed
+163
-31
No files matched your search
@@ -29,6 +29,7 @@ import type {
|
|||||||
CodexQuotaResult,
|
CodexQuotaResult,
|
||||||
QuotaErrorMetadata,
|
QuotaErrorMetadata,
|
||||||
} from '../../cliproxy/quota/quota-types';
|
} from '../../cliproxy/quota/quota-types';
|
||||||
|
import { shouldCacheQuotaResult } from './cliproxy-stats-routes/quota-helpers';
|
||||||
|
|
||||||
interface BindingWindow {
|
interface BindingWindow {
|
||||||
name: string;
|
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(
|
function normalizeResult(
|
||||||
result: ProviderQuotaResult,
|
result: ProviderQuotaResult,
|
||||||
bindingWindow: BindingWindow | null
|
bindingWindow: BindingWindow | null,
|
||||||
|
earliestReset: string | null
|
||||||
): QuotaResult {
|
): QuotaResult {
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
@@ -94,7 +108,7 @@ function normalizeResult(
|
|||||||
name: bindingWindow.name,
|
name: bindingWindow.name,
|
||||||
displayName: bindingWindow.displayName,
|
displayName: bindingWindow.displayName,
|
||||||
percentage: normalizeRemainingPercent(bindingWindow.remainingPercent),
|
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?.fiveHour || result.coreUsage?.weekly
|
||||||
? result.coreUsage
|
? result.coreUsage
|
||||||
: buildClaudeCoreUsageSummary(result.windows);
|
: buildClaudeCoreUsageSummary(result.windows);
|
||||||
const bindingWindow = pickBindingWindow(
|
const windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
|
||||||
[coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
|
name: window.rateLimitType,
|
||||||
name: window.rateLimitType,
|
displayName: window.label,
|
||||||
displayName: window.label,
|
remainingPercent: window.remainingPercent,
|
||||||
remainingPercent: window.remainingPercent,
|
resetAt: window.resetAt,
|
||||||
resetAt: window.resetAt,
|
}));
|
||||||
}))
|
const bindingWindow = pickBindingWindow(windows);
|
||||||
);
|
|
||||||
|
|
||||||
const normalized = normalizeResult(result, bindingWindow);
|
const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows));
|
||||||
rawQuotaByNormalizedResult.set(normalized, result);
|
rawQuotaByNormalizedResult.set(normalized, result);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -135,16 +148,15 @@ function normalizeCodexQuota(result: CodexQuotaResult): QuotaResult {
|
|||||||
result.coreUsage?.fiveHour || result.coreUsage?.weekly
|
result.coreUsage?.fiveHour || result.coreUsage?.weekly
|
||||||
? result.coreUsage
|
? result.coreUsage
|
||||||
: buildCodexCoreUsageSummary(result.windows);
|
: buildCodexCoreUsageSummary(result.windows);
|
||||||
const bindingWindow = pickBindingWindow(
|
const windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
|
||||||
[coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
|
name: window.label,
|
||||||
name: window.label,
|
displayName: window.label,
|
||||||
displayName: window.label,
|
remainingPercent: window.remainingPercent,
|
||||||
remainingPercent: window.remainingPercent,
|
resetAt: window.resetAt,
|
||||||
resetAt: window.resetAt,
|
}));
|
||||||
}))
|
const bindingWindow = pickBindingWindow(windows);
|
||||||
);
|
|
||||||
|
|
||||||
const normalized = normalizeResult(result, bindingWindow);
|
const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows));
|
||||||
rawQuotaByNormalizedResult.set(normalized, result);
|
rawQuotaByNormalizedResult.set(normalized, result);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -182,7 +194,17 @@ export function setCachedBarPoolAccountQuota<T>(
|
|||||||
typeof data === 'object' && data !== null
|
typeof data === 'object' && data !== null
|
||||||
? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult)
|
? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult)
|
||||||
: undefined;
|
: 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(
|
export function invalidateCachedBarPoolAccountQuota(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function codexResult(overrides: Partial<CodexQuotaResult> = {}): CodexQuotaResul
|
|||||||
},
|
},
|
||||||
weekly: {
|
weekly: {
|
||||||
label: 'Secondary',
|
label: 'Secondary',
|
||||||
remainingPercent: 64,
|
remainingPercent: 24,
|
||||||
resetAfterSeconds: 259200,
|
resetAfterSeconds: 259200,
|
||||||
resetAt: '2026-07-21T13:00:00.000Z',
|
resetAt: '2026-07-21T13:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -81,7 +81,7 @@ function createDeps(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Bar pool account quota adapter', () => {
|
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 fetchQuota = createBarPoolAccountQuotaFetcher(createDeps());
|
||||||
|
|
||||||
const result = await fetchQuota('claude', 'claude@example.com');
|
const result = await fetchQuota('claude', 'claude@example.com');
|
||||||
@@ -92,13 +92,13 @@ describe('Bar pool account quota adapter', () => {
|
|||||||
name: 'seven_day',
|
name: 'seven_day',
|
||||||
displayName: 'Weekly usage limit',
|
displayName: 'Weekly usage limit',
|
||||||
percentage: 45,
|
percentage: 45,
|
||||||
resetTime: '2026-07-21T12:00:00.000Z',
|
resetTime: '2026-07-18T16:00:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.accountId).toBe('claude@example.com');
|
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 fetchQuota = createBarPoolAccountQuotaFetcher(createDeps());
|
||||||
|
|
||||||
const result = await fetchQuota('codex', 'codex@example.com');
|
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.success).toBe(true);
|
||||||
expect(result.models).toEqual([
|
expect(result.models).toEqual([
|
||||||
{
|
{
|
||||||
name: 'Primary',
|
name: 'Secondary',
|
||||||
displayName: 'Primary',
|
displayName: 'Secondary',
|
||||||
percentage: 36,
|
percentage: 24,
|
||||||
resetTime: '2026-07-18T14:00:00.000Z',
|
resetTime: '2026-07-18T14:00:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -209,5 +209,4 @@ describe('Bar pool account quota adapter', () => {
|
|||||||
errorCode: 'quota_not_supported',
|
errorCode: 'quota_not_supported',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -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<QuotaResult> {
|
||||||
|
return createBarPoolAccountQuotaFetcher({
|
||||||
|
fetchLegacyAccountQuota: async () => {
|
||||||
|
throw new Error('Legacy fetcher should not be called');
|
||||||
|
},
|
||||||
|
fetchClaudeQuota: async () => claudeResult,
|
||||||
|
fetchCodexQuota: async () => codexResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeFailure(overrides: Partial<ClaudeQuotaResult> = {}): ClaudeQuotaResult {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
windows: [],
|
||||||
|
coreUsage: { fiveHour: null, weekly: null },
|
||||||
|
lastUpdated: 1,
|
||||||
|
accountId: 'claude@example.com',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexFailure(overrides: Partial<CodexQuotaResult> = {}): 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<CodexQuotaResult>('codex', 'codex@example.com');
|
||||||
|
expect(shared).toEqual(raw);
|
||||||
|
expect((shared as CodexQuotaResult & Partial<QuotaResult>)?.models).toBeUndefined();
|
||||||
|
expect(getCachedBarPoolAccountQuota<QuotaResult>('codex', 'codex@example.com')).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
models: [],
|
||||||
|
needsReauth: true,
|
||||||
|
httpStatus: 401,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,13 +100,13 @@ it('stores normalized Claude quota in the existing Bar cache', async () => {
|
|||||||
expect(fetchCount).toBe(1);
|
expect(fetchCount).toBe(1);
|
||||||
expect(first[0]).toMatchObject({
|
expect(first[0]).toMatchObject({
|
||||||
quota_percentage: 45,
|
quota_percentage: 45,
|
||||||
next_reset: '2026-07-21T12:00:00.000Z',
|
next_reset: '2026-07-18T16:00:00.000Z',
|
||||||
quotaStatus: 'ok',
|
quotaStatus: 'ok',
|
||||||
cached: false,
|
cached: false,
|
||||||
});
|
});
|
||||||
expect(second[0]).toMatchObject({
|
expect(second[0]).toMatchObject({
|
||||||
quota_percentage: 45,
|
quota_percentage: 45,
|
||||||
next_reset: '2026-07-21T12:00:00.000Z',
|
next_reset: '2026-07-18T16:00:00.000Z',
|
||||||
quotaStatus: 'ok',
|
quotaStatus: 'ok',
|
||||||
cached: true,
|
cached: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user