mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(bar): enumerate ccs/ccsx subscription profiles with per-profile quota
Replace single-account native quota collection with per-profile enumeration: read ccs auth (Claude) and ccsx auth (Codex) profile registries plus the bare ~/.codex login, fetch each profile's quota under the existing TTL cache, per-profile circuit breaker and 2.5s summary deadline. Emit surface, profile and is_subscription wire fields; account_id becomes "<surface>:<profile>". Active profiles (valid token) are live-polled and shown undimmed regardless of default status; profiles without resolvable on-disk credentials are parked (cache-only, dimmed). Claude per-profile credentials are read from disk only -- no macOS Keychain access -- so a profile without a credentials file renders as needs-reauth instead of triggering a keychain prompt.
This commit is contained in:
1 parent
25d67f8ed7
commit
ed86a089ba
4 files changed
+1391
-62
No files matched your search
@@ -96,6 +96,22 @@ export interface BarSummaryRow {
|
|||||||
fetchedAt: string;
|
fetchedAt: string;
|
||||||
/** True if account token is expired and needs re-authentication */
|
/** True if account token is expired and needs re-authentication */
|
||||||
needsReauth: boolean;
|
needsReauth: boolean;
|
||||||
|
/**
|
||||||
|
* Native subscription surface: "ccs" (Claude Code) or "ccsx" (Codex).
|
||||||
|
* Present ONLY on native subscription rows; omitted on CLIProxy pool rows.
|
||||||
|
*/
|
||||||
|
surface?: string;
|
||||||
|
/**
|
||||||
|
* Native profile name (e.g. "work", "ck", "personal").
|
||||||
|
* Present ONLY on native subscription rows; omitted on CLIProxy pool rows.
|
||||||
|
*/
|
||||||
|
profile?: string;
|
||||||
|
/**
|
||||||
|
* Explicit native-subscription flag. true on all native rows; omitted on
|
||||||
|
* CLIProxy pool rows (decodes to false/nil). Replaces the brittle
|
||||||
|
* accountId == "claude-code" heuristic in Swift.
|
||||||
|
*/
|
||||||
|
is_subscription?: boolean;
|
||||||
/**
|
/**
|
||||||
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||||
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
|
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1220,3 +1220,151 @@ describe('/summary native subscription rows', () => {
|
|||||||
expect(body[0].provider).toBe('agy');
|
expect(body[0].provider).toBe('agy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GH-1595: wire contract — native rows carry surface/profile/is_subscription;
|
||||||
|
// CLIProxy pool rows OMIT all three fields.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('/summary wire contract: surface/profile/is_subscription fields (GH-1595)', () => {
|
||||||
|
/**
|
||||||
|
* Extended wire row type that includes the new optional fields.
|
||||||
|
* BarSummaryRow in the test file omits them; extend locally here.
|
||||||
|
*/
|
||||||
|
interface WireRow extends BarSummaryRow {
|
||||||
|
surface?: string;
|
||||||
|
profile?: string;
|
||||||
|
is_subscription?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNativeRow(
|
||||||
|
surface: 'ccs' | 'ccsx',
|
||||||
|
profile: string,
|
||||||
|
paused = false
|
||||||
|
): BarSummaryRow {
|
||||||
|
return {
|
||||||
|
account_id: `${surface}:${profile}`,
|
||||||
|
provider: surface === 'ccs' ? 'claude-code' : 'codex',
|
||||||
|
displayName: profile,
|
||||||
|
tier: 'pro',
|
||||||
|
paused,
|
||||||
|
quota_percentage: 55,
|
||||||
|
quotaStatus: 'ok',
|
||||||
|
next_reset: null,
|
||||||
|
is_default: !paused,
|
||||||
|
last_activity_at: null,
|
||||||
|
today_cost: null,
|
||||||
|
health: 'ok',
|
||||||
|
cached: false,
|
||||||
|
fetchedAt: '2026-06-23T20:00:00.000Z',
|
||||||
|
needsReauth: false,
|
||||||
|
// The TS interface now has these optional fields — set them explicitly.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
...(({ surface, profile, is_subscription: true }) as any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildWireRouter(nativeRows: BarSummaryRow[]) {
|
||||||
|
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||||
|
'../../../src/web-server/routes/bar-routes'
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const cliproxyAccount = makeAccountInfo({ id: 'pool@example.com', provider: 'agy' });
|
||||||
|
|
||||||
|
const router = createBarRouter({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
getAllAccountsSummary: () => ({ agy: [cliproxyAccount] }) as any,
|
||||||
|
getCachedQuota: () => makeQuotaResult(),
|
||||||
|
setCachedQuota: () => {},
|
||||||
|
invalidateQuotaCache: () => {},
|
||||||
|
fetchAccountQuota: async () => makeQuotaResult(),
|
||||||
|
getTodayCostByAccount: () => ({}),
|
||||||
|
loadCliproxyDetails: async () => [],
|
||||||
|
loadDailyUsage: async () => [],
|
||||||
|
loadHourlyUsage: async () => [],
|
||||||
|
runHealthChecks: async () => makeHealthReport(),
|
||||||
|
getNativeAccountRows: async () => nativeRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/bar', router);
|
||||||
|
const srv = await new Promise<Server>((resolve, reject) => {
|
||||||
|
const instance = app.listen(0, '127.0.0.1');
|
||||||
|
instance.once('error', reject);
|
||||||
|
instance.once('listening', () => resolve(instance));
|
||||||
|
});
|
||||||
|
const addr = srv.address();
|
||||||
|
if (!addr || typeof addr === 'string') throw new Error('No server address');
|
||||||
|
resetDebounce();
|
||||||
|
return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('native rows include surface, profile, is_subscription=true in the JSON response', async () => {
|
||||||
|
const { srv, url } = await buildWireRouter([
|
||||||
|
makeNativeRow('ccs', 'work', false),
|
||||||
|
makeNativeRow('ccsx', 'personal', false),
|
||||||
|
]);
|
||||||
|
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
|
||||||
|
const claudeRow = body.find((r) => r.provider === 'claude-code');
|
||||||
|
expect(claudeRow).toBeDefined();
|
||||||
|
expect(claudeRow?.surface).toBe('ccs');
|
||||||
|
expect(claudeRow?.profile).toBe('work');
|
||||||
|
expect(claudeRow?.is_subscription).toBe(true);
|
||||||
|
expect(claudeRow?.account_id).toBe('ccs:work');
|
||||||
|
|
||||||
|
const codexRow = body.find((r) => r.provider === 'codex');
|
||||||
|
expect(codexRow).toBeDefined();
|
||||||
|
expect(codexRow?.surface).toBe('ccsx');
|
||||||
|
expect(codexRow?.profile).toBe('personal');
|
||||||
|
expect(codexRow?.is_subscription).toBe(true);
|
||||||
|
expect(codexRow?.account_id).toBe('ccsx:personal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CLIProxy pool rows OMIT surface, profile, is_subscription', async () => {
|
||||||
|
const { srv, url } = await buildWireRouter([
|
||||||
|
makeNativeRow('ccs', 'work', false),
|
||||||
|
]);
|
||||||
|
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
|
||||||
|
// The CLIProxy row (provider 'agy') should NOT have the new fields.
|
||||||
|
const cliproxyRow = body.find((r) => r.provider === 'agy');
|
||||||
|
expect(cliproxyRow).toBeDefined();
|
||||||
|
expect(cliproxyRow?.surface).toBeUndefined();
|
||||||
|
expect(cliproxyRow?.profile).toBeUndefined();
|
||||||
|
expect(cliproxyRow?.is_subscription).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parked native row (paused:true) is present with is_subscription=true and paused=true', async () => {
|
||||||
|
const { srv, url } = await buildWireRouter([
|
||||||
|
makeNativeRow('ccsx', 'ck', true), // parked Codex profile
|
||||||
|
]);
|
||||||
|
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
|
||||||
|
const parked = body.find((r) => r.profile === 'ck');
|
||||||
|
expect(parked).toBeDefined();
|
||||||
|
expect(parked?.paused).toBe(true);
|
||||||
|
expect(parked?.is_subscription).toBe(true);
|
||||||
|
expect(parked?.surface).toBe('ccsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('account_id on native rows uses the <surface>:<profile> scheme', async () => {
|
||||||
|
const { srv, url } = await buildWireRouter([
|
||||||
|
makeNativeRow('ccs', 'ck', false),
|
||||||
|
makeNativeRow('ccsx', 'ck', true),
|
||||||
|
]);
|
||||||
|
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
|
||||||
|
const claudeRow = body.find((r) => r.surface === 'ccs');
|
||||||
|
expect(claudeRow?.account_id).toBe('ccs:ck');
|
||||||
|
|
||||||
|
const codexRow = body.find((r) => r.surface === 'ccsx');
|
||||||
|
expect(codexRow?.account_id).toBe('ccsx:ck');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -775,3 +775,433 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => {
|
|||||||
expect(getCachedNativeAccountRows()).toEqual([]);
|
expect(getCachedNativeAccountRows()).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Multi-profile path tests (GH-1595)
|
||||||
|
//
|
||||||
|
// These tests inject listClaudeProfiles / listCodexProfiles / defaultClaudeProfile
|
||||||
|
// / defaultCodexProfile so the production profile-enumeration path is exercised
|
||||||
|
// without touching real ~/.ccs or any Keychain. The readClaudeCredentialsForProfile
|
||||||
|
// and readCodexNativeAuth seams prevent fs access.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a NativeQuotaDeps for the multi-profile path.
|
||||||
|
*
|
||||||
|
* - claudeProfiles: profile names for the Claude surface (ccs)
|
||||||
|
* - codexProfiles: profile names for the Codex surface (ccsx)
|
||||||
|
* - claudeDefault / codexDefault: the active profile per surface (paused:false)
|
||||||
|
* - credsForProfile: map from profile name to credentials (null = parked)
|
||||||
|
* - claudeFetch: network fetcher for Claude (all profiles share one implementation)
|
||||||
|
* - codexNativeAuth: map from profile name to {accessToken, accountId}
|
||||||
|
* - codexNetworkFetch: network fetcher for Codex (all profiles share one impl)
|
||||||
|
*/
|
||||||
|
function makeMultiProfileDeps(opts: {
|
||||||
|
clock: { now: number };
|
||||||
|
claudeProfiles: string[];
|
||||||
|
codexProfiles: string[];
|
||||||
|
claudeDefault?: string | null;
|
||||||
|
codexDefault?: string | null;
|
||||||
|
credsForProfile?: (profile: string) => ClaudeNativeCredentials | null;
|
||||||
|
claudeFetch?: (token: string, accountId?: string) => Promise<ClaudeQuotaResult>;
|
||||||
|
codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null;
|
||||||
|
codexNetworkFetch?: (accountId: string) => Promise<CodexQuotaResult>;
|
||||||
|
codexLocalFallback?: () => Promise<CodexLocalQuota | null>;
|
||||||
|
}): NativeQuotaDeps & {
|
||||||
|
claudeFetchCount: () => number;
|
||||||
|
codexNetworkCount: () => number;
|
||||||
|
} {
|
||||||
|
let claudeFetches = 0;
|
||||||
|
let codexNetworkFetches = 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
clock,
|
||||||
|
claudeProfiles,
|
||||||
|
codexProfiles,
|
||||||
|
claudeDefault = null,
|
||||||
|
codexDefault = null,
|
||||||
|
credsForProfile = () => null,
|
||||||
|
claudeFetch = async () => successQuota(),
|
||||||
|
codexNativeAuth = () => null,
|
||||||
|
codexNetworkFetch = async () => codexSuccessQuota(),
|
||||||
|
codexLocalFallback = async () => null,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Enumeration seams
|
||||||
|
listClaudeProfiles: () => claudeProfiles,
|
||||||
|
listCodexProfiles: () => codexProfiles,
|
||||||
|
defaultClaudeProfile: () => claudeDefault,
|
||||||
|
defaultCodexProfile: () => codexDefault,
|
||||||
|
// Credential seams (file-only, no keychain)
|
||||||
|
readClaudeCredentialsForProfile: credsForProfile,
|
||||||
|
readCodexNativeAuth: codexNativeAuth,
|
||||||
|
// Fetch seams
|
||||||
|
fetchClaudeQuota: async (token: string, accountId?: string) => {
|
||||||
|
claudeFetches += 1;
|
||||||
|
return claudeFetch(token, accountId);
|
||||||
|
},
|
||||||
|
fetchCodexNetworkQuota: async (accountId: string) => {
|
||||||
|
codexNetworkFetches += 1;
|
||||||
|
return codexNetworkFetch(accountId);
|
||||||
|
},
|
||||||
|
getCodexQuota: codexLocalFallback,
|
||||||
|
// Disable legacy single-profile paths
|
||||||
|
readCredentials: () => null,
|
||||||
|
getDefaultCodexAccountId: () => null,
|
||||||
|
// Clock + sleep seams
|
||||||
|
now: () => clock.now,
|
||||||
|
sleep: async () => {},
|
||||||
|
// Counters
|
||||||
|
claudeFetchCount: () => claudeFetches,
|
||||||
|
codexNetworkCount: () => codexNetworkFetches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('multi-profile: account_id and wire fields', () => {
|
||||||
|
it('Claude profile rows carry surface="ccs", account_id="ccs:<p>", is_subscription=true', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['work', 'ck'],
|
||||||
|
codexProfiles: [],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
// 'work' has creds; 'ck' does not (parked)
|
||||||
|
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
||||||
|
claudeFetch: async () => successQuota(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
|
||||||
|
const work = rows.find((r) => r.profile === 'work');
|
||||||
|
expect(work).toBeDefined();
|
||||||
|
expect(work?.account_id).toBe('ccs:work');
|
||||||
|
expect(work?.surface).toBe('ccs');
|
||||||
|
expect(work?.is_subscription).toBe(true);
|
||||||
|
expect(work?.provider).toBe('claude-code');
|
||||||
|
|
||||||
|
const ck = rows.find((r) => r.profile === 'ck');
|
||||||
|
expect(ck).toBeDefined();
|
||||||
|
expect(ck?.account_id).toBe('ccs:ck');
|
||||||
|
expect(ck?.surface).toBe('ccs');
|
||||||
|
expect(ck?.is_subscription).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Codex profile rows carry surface="ccsx", account_id="ccsx:<p>", is_subscription=true', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: [],
|
||||||
|
codexProfiles: ['personal', 'ck'],
|
||||||
|
codexDefault: 'personal',
|
||||||
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||||
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
|
||||||
|
const personal = rows.find((r) => r.profile === 'personal');
|
||||||
|
expect(personal?.account_id).toBe('ccsx:personal');
|
||||||
|
expect(personal?.surface).toBe('ccsx');
|
||||||
|
expect(personal?.is_subscription).toBe(true);
|
||||||
|
expect(personal?.provider).toBe('codex');
|
||||||
|
|
||||||
|
const ck = rows.find((r) => r.profile === 'ck');
|
||||||
|
expect(ck?.account_id).toBe('ccsx:ck');
|
||||||
|
expect(ck?.surface).toBe('ccsx');
|
||||||
|
expect(ck?.is_subscription).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
// Claude: work = default + creds (live); ck = non-default + NO creds (parked).
|
||||||
|
claudeProfiles: ['work', 'ck'],
|
||||||
|
// Codex: personal = default + creds (live); ck = NON-default + creds (live).
|
||||||
|
codexProfiles: ['personal', 'ck'],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
codexDefault: 'personal',
|
||||||
|
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
||||||
|
claudeFetch: async () => successQuota(),
|
||||||
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||||
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
|
||||||
|
// Claude work: default + creds -> live, not dimmed.
|
||||||
|
const claudeWork = rows.find((r) => r.surface === 'ccs' && r.profile === 'work');
|
||||||
|
expect(claudeWork?.paused).toBe(false);
|
||||||
|
expect(claudeWork?.is_default).toBe(true);
|
||||||
|
|
||||||
|
// Claude ck: non-default + NO creds -> parked/dimmed.
|
||||||
|
const claudeCk = rows.find((r) => r.surface === 'ccs' && r.profile === 'ck');
|
||||||
|
expect(claudeCk?.paused).toBe(true);
|
||||||
|
expect(claudeCk?.is_default).toBe(false);
|
||||||
|
|
||||||
|
// Codex personal: default + creds -> live.
|
||||||
|
const codexPersonal = rows.find((r) => r.surface === 'ccsx' && r.profile === 'personal');
|
||||||
|
expect(codexPersonal?.paused).toBe(false);
|
||||||
|
expect(codexPersonal?.is_default).toBe(true);
|
||||||
|
|
||||||
|
// Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key
|
||||||
|
// correctness guarantee: a valid isolated subscription is never dimmed just
|
||||||
|
// because it is not the surface default.
|
||||||
|
const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck');
|
||||||
|
expect(codexCk?.paused).toBe(false);
|
||||||
|
expect(codexCk?.is_default).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N Claude + M Codex profiles produce N+M rows', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const claudeProfiles = ['work', 'ck', 'personal'];
|
||||||
|
const codexProfiles = ['personal', 'ck'];
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles,
|
||||||
|
codexProfiles,
|
||||||
|
claudeDefault: 'work',
|
||||||
|
codexDefault: 'personal',
|
||||||
|
credsForProfile: () => maxCreds(),
|
||||||
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rows are sorted by (surface, profile)', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['work', 'ck'],
|
||||||
|
codexProfiles: ['ck', 'personal'],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
codexDefault: 'personal',
|
||||||
|
credsForProfile: () => maxCreds(),
|
||||||
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
const keys = rows.map((r) => `${r.surface}:${r.profile}`);
|
||||||
|
// ccs:ck < ccs:work < ccsx:ck < ccsx:personal
|
||||||
|
expect(keys).toEqual(['ccs:ck', 'ccs:work', 'ccsx:ck', 'ccsx:personal']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-profile: Claude file-only reader', () => {
|
||||||
|
it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['work'],
|
||||||
|
codexProfiles: [],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
||||||
|
claudeFetch: async () => successQuota(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
const row = rows[0];
|
||||||
|
expect(row?.profile).toBe('work');
|
||||||
|
expect(row?.quotaStatus).toBe('ok');
|
||||||
|
expect(row?.needsReauth).toBe(false);
|
||||||
|
expect(row?.paused).toBe(false);
|
||||||
|
expect(deps.claudeFetchCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('profile without .credentials.json -> parked row (needsReauth:true, no live fetch)', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['ck'],
|
||||||
|
codexProfiles: [],
|
||||||
|
claudeDefault: 'ck',
|
||||||
|
credsForProfile: () => null, // no file on disk
|
||||||
|
claudeFetch: async () => successQuota(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
const row = rows[0];
|
||||||
|
expect(row?.profile).toBe('ck');
|
||||||
|
expect(row?.needsReauth).toBe(true);
|
||||||
|
expect(row?.quota_percentage).toBeNull();
|
||||||
|
// No live network call when creds are absent
|
||||||
|
expect(deps.claudeFetchCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('absent creds row has quotaStatus unsupported (honest "needs auth" state)', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['ck'],
|
||||||
|
codexProfiles: [],
|
||||||
|
claudeDefault: 'ck',
|
||||||
|
credsForProfile: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
const row = rows[0];
|
||||||
|
expect(row?.quotaStatus).toBe('unsupported');
|
||||||
|
expect(row?.is_subscription).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||||
|
it("one profile's 429 does not open another profile's breaker", async () => {
|
||||||
|
const MAX_COOLDOWN_JUMP_MP = 61_000;
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
let workFails = true;
|
||||||
|
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['work', 'ck'],
|
||||||
|
codexProfiles: [],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
credsForProfile: () => maxCreds(),
|
||||||
|
claudeFetch: async (_token, accountId) => {
|
||||||
|
// 'work' (ccs:work) always 429s; 'ck' always succeeds
|
||||||
|
if (accountId?.includes('work') && workFails) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
windows: [],
|
||||||
|
coreUsage: { fiveHour: null, weekly: null },
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
accountId: accountId ?? 'ccs:work',
|
||||||
|
httpStatus: 429,
|
||||||
|
retryable: true,
|
||||||
|
error: 'rate limited',
|
||||||
|
} as ClaudeQuotaResult;
|
||||||
|
}
|
||||||
|
return successQuota();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trip the work breaker with 3 consecutive 429s.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
resetNativeQuotaState();
|
||||||
|
clock.now += i === 0 ? 0 : MAX_COOLDOWN_JUMP_MP;
|
||||||
|
// Re-inject the multi-profile deps after reset so the state maps are fresh.
|
||||||
|
await getNativeAccountRows({
|
||||||
|
...deps,
|
||||||
|
listClaudeProfiles: () => ['work'],
|
||||||
|
listCodexProfiles: () => [],
|
||||||
|
defaultClaudeProfile: () => 'work',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the three 429s on 'work', check that 'ck' still succeeds.
|
||||||
|
// We reset state to have a clean run where 'ck' has no prior breaker history.
|
||||||
|
resetNativeQuotaState();
|
||||||
|
clock.now += MAX_COOLDOWN_JUMP_MP;
|
||||||
|
workFails = false;
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
const ckRow = rows.find((r) => r.profile === 'ck');
|
||||||
|
const workRow = rows.find((r) => r.profile === 'work');
|
||||||
|
|
||||||
|
// 'ck' should succeed — its breaker was never tripped.
|
||||||
|
expect(ckRow?.quotaStatus).toBe('ok');
|
||||||
|
// 'work' is also fine after reset (no breaker state).
|
||||||
|
expect(workRow?.quotaStatus).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-profile breaker: one profile's 429s only block that profile", async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
let workCall429Count = 0;
|
||||||
|
|
||||||
|
// 'work' returns 429 each call; 'ck' returns success.
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['work', 'ck'],
|
||||||
|
codexProfiles: [],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
credsForProfile: () => maxCreds(),
|
||||||
|
claudeFetch: async (_token, accountId) => {
|
||||||
|
if (accountId?.includes('work')) {
|
||||||
|
workCall429Count += 1;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
windows: [],
|
||||||
|
coreUsage: { fiveHour: null, weekly: null },
|
||||||
|
lastUpdated: clock.now,
|
||||||
|
accountId: accountId ?? '',
|
||||||
|
httpStatus: 429,
|
||||||
|
retryable: true,
|
||||||
|
error: 'rate limited',
|
||||||
|
} as ClaudeQuotaResult;
|
||||||
|
}
|
||||||
|
return successQuota();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call: 'work' gets a 429, 'ck' succeeds.
|
||||||
|
const rows1 = await getNativeAccountRows(deps);
|
||||||
|
const ck1 = rows1.find((r) => r.profile === 'ck');
|
||||||
|
expect(ck1?.quotaStatus).toBe('ok');
|
||||||
|
expect(workCall429Count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Skip past cooldown for 'work' only; 'ck' is within TTL.
|
||||||
|
clock.now += 62_000;
|
||||||
|
|
||||||
|
// Second call past 'work' cooldown: work tries again (429 again); ck cached.
|
||||||
|
const rows2 = await getNativeAccountRows(deps);
|
||||||
|
const ck2 = rows2.find((r) => r.profile === 'ck');
|
||||||
|
// 'ck' still has a good cached row.
|
||||||
|
expect(ck2?.quotaStatus).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-profile: displayName uses profile name', () => {
|
||||||
|
it('displayName is the profile name, not "Claude Code" or "Codex"', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['my-work'],
|
||||||
|
codexProfiles: ['my-codex'],
|
||||||
|
claudeDefault: 'my-work',
|
||||||
|
codexDefault: 'my-codex',
|
||||||
|
credsForProfile: () => maxCreds(),
|
||||||
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await getNativeAccountRows(deps);
|
||||||
|
const c = rows.find((r) => r.surface === 'ccs');
|
||||||
|
const x = rows.find((r) => r.surface === 'ccsx');
|
||||||
|
expect(c?.displayName).toBe('my-work');
|
||||||
|
expect(x?.displayName).toBe('my-codex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-profile: getCachedNativeAccountRows reflects per-profile maps', () => {
|
||||||
|
it('returns cached rows from all profiles after a collect', async () => {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
const deps = makeMultiProfileDeps({
|
||||||
|
clock,
|
||||||
|
claudeProfiles: ['work', 'ck'],
|
||||||
|
codexProfiles: ['personal'],
|
||||||
|
claudeDefault: 'work',
|
||||||
|
codexDefault: 'personal',
|
||||||
|
credsForProfile: () => maxCreds(),
|
||||||
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getNativeAccountRows(deps);
|
||||||
|
|
||||||
|
const cached = getCachedNativeAccountRows();
|
||||||
|
expect(cached.every((r) => r.cached === true)).toBe(true);
|
||||||
|
// Should have rows for work, ck, and personal (parked 'ck' has no cached row
|
||||||
|
// yet because it had creds in this test so it did fetch)
|
||||||
|
const profiles = cached.map((r) => r.profile);
|
||||||
|
expect(profiles).toContain('work');
|
||||||
|
expect(profiles).toContain('personal');
|
||||||
|
|
||||||
|
resetNativeQuotaState();
|
||||||
|
expect(getCachedNativeAccountRows()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user