mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +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 @@
|
||||
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