Merge pull request #1477 from kaitranntt/kai/feat/ccs-bar-backend

feat: CCS Bar backend — account cost, /api/bar/summary, tier-lock, ccs bar
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-07 16:47:44 -04:00
commit fc97581aae
25 files changed
+4793 -12

No files matched your search

+34 -1
View File
@@ -35,6 +35,7 @@ import {
import type { RuntimeMonitorConfig } from '../../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { getTierLockForProvider } from '../../config/schemas/quota';
export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp';
type ManagedQuotaResult =
@@ -428,10 +429,20 @@ export async function findHealthyAccount(
const accounts = getProviderAccounts(provider);
// When a tier is locked for this specific provider, restrict candidates to
// that tier only. Locks are per-provider so locking "agy" to "ultra" does
// NOT affect failover for "claude", "codex", "gemini", or "ghcp".
// This is intentionally strict: no cross-tier fallback while a lock is active,
// so the user's explicit tier choice is always honored.
const tierLock = getTierLockForProvider(config.quota_management?.manual, provider);
// Filter available accounts
const available = accounts.filter(
(a) =>
!exclude.includes(a.id) && !isAccountPaused(provider, a.id) && !isOnCooldown(provider, a.id)
!exclude.includes(a.id) &&
!isAccountPaused(provider, a.id) &&
!isOnCooldown(provider, a.id) &&
(tierLock === null || (a.tier || 'unknown') === tierLock)
);
if (available.length === 0) return null;
@@ -623,6 +634,28 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
}
}
// When a tier is locked for this specific provider, the default account must
// match the locked tier. If it doesn't, route to a healthy account in the
// locked tier instead. Locks are per-provider: locking "agy" to "ultra"
// does NOT constrain "claude", "codex", "gemini", or "ghcp".
// Graceful degradation: if no locked-tier account is available, fall through
// to the default (don't block the request entirely).
const tierLock = getTierLockForProvider(quotaConfig.manual, provider);
if (tierLock !== null && (defaultAccount.tier || 'unknown') !== tierLock) {
const lockedTierAccount = await findHealthyAccount(provider, []);
if (lockedTierAccount) {
setDefaultAccount(provider, lockedTierAccount.id);
touchAccount(provider, lockedTierAccount.id);
return {
proceed: true,
accountId: lockedTierAccount.id,
switchedFrom: defaultAccount.id,
reason: `Tier lock: selected ${tierLock} account`,
};
}
// No locked-tier account available — fall through and use default
}
// Check if default is paused
if (isAccountPaused(provider, defaultAccount.id)) {
return await findAndSwitch(provider, defaultAccount.id, 'Default account is paused');
+26 -1
View File
@@ -344,7 +344,9 @@ async function fetchManagementJson<T>(
}
}
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
export async function fetchCliproxyAuthFiles(
port?: number
): Promise<CliproxyManagementAuthFile[] | null> {
try {
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
'/v0/management/auth-files',
@@ -367,6 +369,29 @@ export const __testExports = {
},
};
/**
* Build an auth_index → account email/id map from CLIProxy auth file metadata.
*
* Keys are stored as strings (String(auth_index)) so both numeric and string
* auth_index values resolve consistently via `map.get(String(auth_index))`.
*
* Entries missing either `auth_index` or `email` are silently skipped.
*
* @param authFiles Auth file records from /v0/management/auth-files
* @returns Map from String(auth_index) → email
*/
export function buildAuthIndexToAccountMap(
authFiles: CliproxyManagementAuthFile[]
): Map<string, string> {
const map = new Map<string, string>();
for (const file of authFiles) {
if (file.auth_index === undefined || file.auth_index === null) continue;
if (!file.email || file.email.trim().length === 0) continue;
map.set(String(file.auth_index), file.email.trim());
}
return map;
}
/** OpenAI-compatible model object from /v1/models endpoint */
export interface CliproxyModel {
id: string;
+47
View File
@@ -0,0 +1,47 @@
/**
* `ccs bar` command dispatcher
*
* Mirrors the pattern in src/commands/docker/index.ts.
* Subcommands: launch (default), install, uninstall, version / --version.
*/
export async function handleBarCommand(args: string[]): Promise<void> {
const subcommand = args[0];
// --version / version are aliases for the version subcommand
if (subcommand === '--version' || subcommand === 'version') {
const { handleBarVersion } = await import('./version-subcommand');
await handleBarVersion();
return;
}
const commandHandlers: Record<string, (subArgs: string[]) => Promise<void>> = {
launch: async (subArgs) => {
const { handleBarLaunch } = await import('./launch-subcommand');
await handleBarLaunch(subArgs);
},
install: async (subArgs) => {
const { handleBarInstall } = await import('./install-subcommand');
await handleBarInstall(subArgs);
},
uninstall: async (subArgs) => {
const { handleBarUninstall } = await import('./uninstall-subcommand');
await handleBarUninstall(subArgs);
},
};
// Bare `ccs bar` → launch
if (!subcommand || subcommand === 'launch') {
await commandHandlers.launch(subcommand ? args.slice(1) : []);
return;
}
const handler = commandHandlers[subcommand];
if (!handler) {
console.error(`[X] Unknown bar subcommand: ${subcommand}`);
console.error('[i] Usage: ccs bar [launch|install|uninstall|--version]');
return;
}
await handler(args.slice(1));
}
+451
View File
@@ -0,0 +1,451 @@
/**
* `ccs bar install` — download the CCS Bar app from the floating
* `ccs-bar-latest` GitHub release tag and install to ~/Applications.
*
* Intentionally uses a FLOATING tag (not the exact CLI version) so the
* Swift app can be rebuilt and published independently. After install the
* handler calls GET /api/overview to verify version compatibility and warns
* (but does not hard-fail) on mismatch.
*
* Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Floating release tag — never pin to exact CLI semver. */
const BAR_RELEASE_TAG = 'ccs-bar-latest';
const BAR_APP_NAME = 'CCS Bar.app';
const BAR_ASSET_NAME = 'CCS-Bar.app.zip';
const BAR_GITHUB_REPO = 'kaitranntt/ccs';
/**
* Allowlist of hostnames from which we will accept asset downloads.
* GitHub releases redirect from github.com to objects.githubusercontent.com.
*
* TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file,
* wire SHA-256 verification here. The download URL is already validated for
* host+HTTPS as a v1 minimum guard. The verifier hook below is the intended
* extension point.
*/
const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray<string> = [
'github.com',
'objects.githubusercontent.com',
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ReleaseAssetResult {
downloadUrl: string;
version: string;
}
export interface CompatResult {
version: string;
compatible: boolean;
}
export interface InstallDeps {
/**
* Resolve the asset download URL + version string from a GitHub release tag.
* Production: calls GitHub API releases/tags/{tag}.
* Test: mock that returns a fake URL + version.
*/
fetchReleaseAsset: (tag: string, asset: string) => Promise<ReleaseAssetResult>;
/**
* Download the zip archive and extract the .app bundle into dest/.
* Production: uses undici to stream + extract (with redirect + status check).
*/
downloadAndExtract: (url: string, dest: string) => Promise<void>;
/**
* Call GET {baseUrl}/api/overview and return { version, compatible }.
* compatible = server version major === installed app version major.
* Returns compatible:false (never true) when versions cannot be compared.
*/
verifyCompat: (baseUrl: string, installedVersion: string) => Promise<CompatResult>;
/** Returns path to ~/.ccs (respects CCS_HOME). */
getCcsDir: () => string;
/** Destination directory for the .app bundle (~/Applications by default). */
getAppsDir: () => string;
}
// ---------------------------------------------------------------------------
// Host allowlist validation (Finding #9)
// ---------------------------------------------------------------------------
/**
* Validate that a download URL is https and its hostname is in the allowlist
* (exact match or *.githubusercontent.com wildcard).
* Throws a descriptive Error if validation fails.
*/
export function validateDownloadUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Download URL is not a valid URL: ${url}`);
}
if (parsed.protocol !== 'https:') {
throw new Error(`Download URL must use HTTPS, got: ${parsed.protocol} for ${url}`);
}
const host = parsed.hostname.toLowerCase();
const allowed =
(DOWNLOAD_HOST_ALLOWLIST as string[]).includes(host) || host.endsWith('.githubusercontent.com');
if (!allowed) {
throw new Error(
`Download URL hostname "${host}" is not in the trusted allowlist ` +
`(${DOWNLOAD_HOST_ALLOWLIST.join(', ')}, *.githubusercontent.com). ` +
`Refusing to download from untrusted host.`
);
}
}
// ---------------------------------------------------------------------------
// Production implementation helpers
// ---------------------------------------------------------------------------
async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<ReleaseAssetResult> {
const { request } = await import('undici');
const apiUrl = `https://api.github.com/repos/${BAR_GITHUB_REPO}/releases/tags/${tag}`;
const { statusCode, body } = await request(apiUrl, {
headers: {
'User-Agent': 'ccs-cli',
Accept: 'application/vnd.github+json',
},
});
if (statusCode !== 200) {
throw new Error(`GitHub API returned ${statusCode} for tag ${tag}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const release = (await body.json()) as any;
const tagName: string = release.tag_name ?? tag;
// Strip leading 'v' for the version pin file.
const version = tagName.replace(/^v/, '');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset);
if (!found) {
throw new Error(`Asset "${asset}" not found in release ${tag}`);
}
return { downloadUrl: found.browser_download_url as string, version };
}
/**
* Download a zip from `url` (following up to 5 GitHub 302 redirects, re-validating
* each hop's Location hostname) and extract its contents into `dest`.
*
* Fixes applied:
* - Fix #6: maxRedirections:0 + manual redirect following so every hop's Location
* header is passed through validateDownloadUrl before following it.
* The previous maxRedirections:5 let undici follow redirects to ANY host unchecked.
* - Fix #14: list zip entries with `unzip -l` before extracting; reject the archive
* if any entry path contains ".." or starts with "/" (zip-slip guard).
* - Finding #11: check `statusCode` and throw a descriptive error before streaming.
* - Finding #9: validate host+HTTPS before making the first request.
*/
async function defaultDownloadAndExtract(url: string, dest: string): Promise<void> {
const { request } = await import('undici');
const { createWriteStream, mkdirSync } = fs;
const { promisify } = await import('util');
const { pipeline } = await import('stream');
const streamPipeline = promisify(pipeline);
const { execFile } = await import('child_process');
const execFileAsync = promisify(execFile);
// Validate initial URL (Finding #9)
validateDownloadUrl(url);
mkdirSync(dest, { recursive: true });
// Fix #6: follow redirects manually so each hop is re-validated.
const MAX_REDIRECTS = 5;
let currentUrl = url;
let redirectsFollowed = 0;
while (true) {
const { statusCode, headers, body } = await request(currentUrl, {
maxRedirections: 0, // disable undici's auto-follow; we follow manually
});
if (statusCode >= 300 && statusCode < 400) {
const location = Array.isArray(headers['location'])
? headers['location'][0]
: headers['location'];
if (!location) {
throw new Error(`Redirect (HTTP ${statusCode}) from ${currentUrl} has no Location header`);
}
// Resolve relative redirects against the current URL
const resolved = new URL(location, currentUrl).toString();
// Re-validate the redirect target — this is the key fix for #6
validateDownloadUrl(resolved);
if (redirectsFollowed >= MAX_REDIRECTS) {
throw new Error(`Too many redirects (>${MAX_REDIRECTS}) while downloading ${url}`);
}
// Drain the body to free the socket before following the redirect
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (body as any).dump?.();
currentUrl = resolved;
redirectsFollowed++;
continue;
}
if (statusCode !== 200) {
throw new Error(`Download failed: HTTP ${statusCode} for ${currentUrl}`);
}
const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`);
// Stream body to tmpZip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await streamPipeline(body as any, createWriteStream(tmpZip));
// Fix #14: zip-slip guard — inspect entries before extraction.
// `unzip -l` lists entries in a machine-readable format; we scan for ".." or
// absolute paths that would escape the destination directory.
try {
const { stdout: listing } = await execFileAsync('unzip', ['-l', tmpZip]);
const lines = listing.split('\n');
for (const line of lines) {
// Entry lines look like: " <size> <date> <time> <path>"
// We extract the path from the last whitespace-delimited field.
const match = /^\s+\d+\s+[\d-]+\s+[\d:]+\s+(.+)$/.exec(line);
if (!match || !match[1]) continue;
const entryPath = match[1].trim();
if (!entryPath || entryPath.endsWith('/')) continue; // skip directory entries
// Reject absolute paths and paths with traversal components
if (path.isAbsolute(entryPath) || entryPath.includes('..')) {
try {
fs.unlinkSync(tmpZip);
} catch {
/* ignore */
}
throw new Error(
`Zip-slip detected: archive entry "${entryPath}" contains a path traversal ` +
`component. Refusing to extract.`
);
}
}
} catch (err) {
// If the guard itself throws (e.g. zip-slip detected above), propagate it.
// If it's a system error (unzip not available), let extraction proceed and
// surface the issue then.
if ((err as Error).message?.includes('Zip-slip')) throw err;
// Warn but continue — extraction will likely also fail if unzip is missing
console.error(
`[!] Zip entry scan failed (will attempt extraction): ${(err as Error).message}`
);
}
// Extract the zip into dest
await execFileAsync('unzip', ['-o', tmpZip, '-d', dest]);
// Clean up the temp archive
try {
fs.unlinkSync(tmpZip);
} catch {
/* ignore */
}
break;
}
}
/**
* Call GET {baseUrl}/api/overview and compare server version against the
* installed bar version. Returns compat=false whenever the comparison cannot
* be made (server unreachable, version strings unparseable, etc.).
*
* Fix for Finding #10: replaces the phantom check that always returned true.
* Compatible is defined as same semver major (0 vs 0, 1 vs 1, etc.).
*/
async function defaultVerifyCompat(
baseUrl: string,
installedVersion: string
): Promise<CompatResult> {
try {
const { request } = await import('undici');
const { statusCode, body } = await request(`${baseUrl}/api/overview`, {
headers: { 'User-Agent': 'ccs-cli' },
});
if (statusCode !== 200) {
console.log(
`[!] Version compatibility check failed: /api/overview returned HTTP ${statusCode}.`
);
return { version: 'unknown', compatible: false };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = (await body.json()) as any;
const serverVersion: string = (data.version as string) ?? 'unknown';
if (serverVersion === 'unknown') {
console.log('[!] Version compatibility: server did not report a version.');
return { version: serverVersion, compatible: false };
}
// Parse installed version major from the pinned version string.
const installedMajorRaw = installedVersion.split('.')[0];
const serverMajorRaw = serverVersion.split('.')[0];
const installedMajor = parseInt(installedMajorRaw ?? '', 10);
const serverMajor = parseInt(serverMajorRaw ?? '', 10);
if (isNaN(installedMajor) || isNaN(serverMajor)) {
console.log(
`[!] Version compatibility: cannot parse major versions ` +
`(installed="${installedVersion}", server="${serverVersion}").`
);
return { version: serverVersion, compatible: false };
}
const compatible = installedMajor === serverMajor;
return { version: serverVersion, compatible };
} catch {
// Server unreachable — warn but do not claim compatible.
return { version: 'unknown', compatible: false };
}
}
function defaultGetCcsDir(): string {
return getCcsDir();
}
function defaultGetAppsDir(): string {
return path.join(os.homedir(), 'Applications');
}
// ---------------------------------------------------------------------------
// Version pin helpers
// ---------------------------------------------------------------------------
function getBarVersionFilePath(ccsDir: string): string {
return path.join(ccsDir, 'bar', '.version');
}
function pinBarVersion(ccsDir: string, version: string): void {
const versionFile = getBarVersionFilePath(ccsDir);
fs.mkdirSync(path.dirname(versionFile), { recursive: true });
fs.writeFileSync(versionFile, version);
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarInstall(
_args: string[],
deps: Partial<InstallDeps> = {}
): Promise<void> {
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
console.log('[i] Fetching CCS Bar release info...');
// 1. Resolve the floating tag → download URL + version.
let releaseInfo: ReleaseAssetResult;
try {
releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to fetch release asset: ${msg}`);
console.error('[i] Check your network connection and try again.');
return;
}
const { downloadUrl, version } = releaseInfo;
console.log(`[i] Installing CCS Bar v${version} from ${BAR_RELEASE_TAG}...`);
// 2. Download and extract into ~/Applications.
try {
await downloadAndExtract(downloadUrl, appsDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Download or extraction failed: ${msg}`);
return;
}
// 3. Finding #12: assert that the expected .app bundle was actually extracted
// before reporting success.
const appPath = path.join(appsDir, BAR_APP_NAME);
if (!fs.existsSync(appPath)) {
// Report what was actually extracted to help diagnose archive issues.
let extracted: string[] = [];
try {
extracted = fs.readdirSync(appsDir);
} catch {
/* ignore */
}
const found = extracted.length > 0 ? extracted.join(', ') : '(none)';
console.error(`[X] Extraction succeeded but "${BAR_APP_NAME}" was not found in ${appsDir}.`);
console.error(`[i] Files found in ${appsDir}: ${found}`);
return;
}
// 4. Pin the installed version to ~/.ccs/bar/.version.
try {
pinBarVersion(ccsDir, version);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[!] Could not write version pin: ${msg}`);
// Non-fatal — continue.
}
console.log(`[OK] CCS Bar v${version} installed to ${appsDir}/${BAR_APP_NAME}`);
// 5. Version-compat handshake via /api/overview.
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
const barJsonPath = path.join(ccsDir, 'bar.json');
let baseUrl = 'http://127.0.0.1:3000';
try {
const raw = fs.readFileSync(barJsonPath, 'utf8');
const parsed = JSON.parse(raw) as { baseUrl?: string };
if (parsed.baseUrl) baseUrl = parsed.baseUrl;
} catch {
/* bar.json absent — use fallback */
}
try {
// Pass the pinned version so verifyCompat can do a real major-version comparison.
const compat = await verifyCompat(baseUrl, version);
if (!compat.compatible) {
console.log(
`[!] Version mismatch: CCS server reports v${compat.version}, ` +
`app is v${version}. Some features may not work until you restart ` +
'`ccs bar` or update CCS.'
);
} else {
console.log(`[OK] Version compatibility confirmed (server: v${compat.version}).`);
}
} catch {
console.log('[!] Could not verify version compatibility (server may not be running).');
console.log('[i] Run `ccs bar` to start the server and recheck.');
}
// 6. Print Gatekeeper guidance for ad-hoc/unsigned builds.
console.log('');
console.log('[i] Gatekeeper note (ad-hoc build):');
console.log(' If macOS says the app is "damaged" or "unverified", run:');
console.log(` xattr -dr com.apple.quarantine "${path.join(appsDir, BAR_APP_NAME)}"`);
console.log(' Or right-click the app and select Open.');
}
+157
View File
@@ -0,0 +1,157 @@
/**
* `ccs bar launch` — ensure web-server is up, write ~/.ccs/bar.json, open the app.
*
* bar.json shape (v1):
* { baseUrl: string, port: number, authMode: "loopback" }
*
* authMode is always "loopback" for v1 (auth-enabled unsupported until v1.1).
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BarDiscoveryJson {
baseUrl: string;
port: number;
authMode: 'loopback';
}
export interface DashboardInfo {
port: number;
baseUrl: string;
}
export interface LaunchDeps {
/**
* Ensure the CCS web-server / dashboard is running.
* Returns { port, baseUrl } of the running server.
* Throws if the server cannot be started (degraded path).
*/
ensureDashboard: () => Promise<DashboardInfo>;
/** Open the installed .app bundle. Throws if the app is not found. */
openApp: (appPath: string) => Promise<void>;
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
getCcsDir: () => string;
/** Full path where the .app should be installed, e.g. ~/Applications/CCS Bar.app */
appInstallPath: string;
}
// ---------------------------------------------------------------------------
// Port discovery helpers (exported for unit tests)
// ---------------------------------------------------------------------------
/**
* Read the port recorded in an existing bar.json.
* Returns null when the file is absent or malformed.
*/
export function resolveBarPort(ccsDir: string): number | null {
const barJsonPath = path.join(ccsDir, 'bar.json');
try {
const raw = fs.readFileSync(barJsonPath, 'utf8');
const parsed = JSON.parse(raw) as Partial<BarDiscoveryJson>;
return typeof parsed.port === 'number' ? parsed.port : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Default production dependencies
// ---------------------------------------------------------------------------
async function defaultEnsureDashboard(): Promise<DashboardInfo> {
// Reuse the same startup path as `ccs config`:
// find a free port then start the web-server via startServer().
const getPort = (await import('get-port')).default;
const { startServer } = await import('../../web-server');
const port = await getPort({ port: [3000, 3001, 3002, 8000, 8080] });
const { server } = await startServer({ port });
const addr = server.address();
const resolvedPort = addr && typeof addr === 'object' ? addr.port : port;
const baseUrl = `http://127.0.0.1:${resolvedPort}`;
return { port: resolvedPort, baseUrl };
}
async function defaultOpenApp(appPath: string): Promise<void> {
const { execFile } = await import('child_process');
const { promisify } = await import('util');
const execFileAsync = promisify(execFile);
await execFileAsync('open', ['-a', appPath]);
}
function defaultGetCcsDir(): string {
return getCcsDir();
}
// Fix #5: use os.homedir() to match install-subcommand.ts and uninstall-subcommand.ts.
// process.env.HOME may be unset in restricted environments, and CCS_HOME is the CCS
// data directory (~/.ccs), not the user's home — neither is a safe fallback here.
const DEFAULT_APP_INSTALL_PATH = path.join(os.homedir(), 'Applications', 'CCS Bar.app');
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarLaunch(
_args: string[],
deps: Partial<LaunchDeps> = {}
): Promise<void> {
const ensureDashboard = deps.ensureDashboard ?? defaultEnsureDashboard;
const openApp = deps.openApp ?? defaultOpenApp;
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
// 1. Ensure the web-server/dashboard is running.
let dashboardInfo: DashboardInfo;
try {
dashboardInfo = await ensureDashboard();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Could not start CCS web-server: ${msg}`);
console.error('[i] Run `ccs config` to start the dashboard manually.');
return;
}
// 2. Write bar.json — this is the single source of discovery for the Swift app.
const barJson: BarDiscoveryJson = {
baseUrl: dashboardInfo.baseUrl,
port: dashboardInfo.port,
authMode: 'loopback',
};
try {
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson, null, 2));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to write bar.json: ${msg}`);
return;
}
console.log(`[OK] CCS web-server running at ${dashboardInfo.baseUrl}`);
console.log(`[i] Discovery file written: ${path.join(ccsDir, 'bar.json')}`);
// 3. Open the app.
try {
await openApp(appInstallPath);
console.log('[OK] CCS Bar launched.');
} catch {
// Degraded path: app not installed or open failed.
if (!fs.existsSync(appInstallPath)) {
console.log('[!] CCS Bar app is not installed.');
console.log('[i] Run `ccs bar install` to install it.');
} else {
console.log('[!] Could not open CCS Bar. Try right-clicking and selecting Open.');
console.log('[i] If Gatekeeper blocks the app, run:');
console.log(` xattr -dr com.apple.quarantine "${appInstallPath}"`);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* `ccs bar uninstall` — remove CCS Bar.app from ~/Applications
* and clear the version pin at ~/.ccs/bar/.version.
*
* No-op (and no error) when neither the app nor the pin exists.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface UninstallDeps {
getCcsDir: () => string;
getAppsDir: () => string;
appName: string;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export async function handleBarUninstall(
_args: string[],
deps: Partial<UninstallDeps> = {}
): Promise<void> {
const ccsDir = (deps.getCcsDir ?? (() => getCcsDir()))();
const appsDir = (deps.getAppsDir ?? (() => path.join(os.homedir(), 'Applications')))();
const appName = deps.appName ?? 'CCS Bar.app';
const appPath = path.join(appsDir, appName);
const versionPin = path.join(ccsDir, 'bar', '.version');
let removed = false;
// Remove the .app bundle (a directory on macOS).
if (fs.existsSync(appPath)) {
try {
fs.rmSync(appPath, { recursive: true, force: true });
console.log(`[OK] Removed ${appPath}`);
removed = true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Failed to remove ${appPath}: ${msg}`);
}
}
// Clear the version pin.
if (fs.existsSync(versionPin)) {
try {
fs.unlinkSync(versionPin);
removed = true;
} catch {
// Non-fatal — pin may already be gone.
}
}
if (!removed) {
console.log('[i] CCS Bar is not installed — nothing to remove.');
} else {
console.log('[OK] CCS Bar uninstalled.');
console.log('[i] Run `ccs bar install` to reinstall.');
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* `ccs bar --version` / `ccs bar version`
*
* Prints the CCS CLI version alongside the installed CCS Bar app version
* (read from ~/.ccs/bar/.version, if present).
*/
import * as fs from 'fs';
import * as path from 'path';
import { getVersion } from '../../utils/version';
import { getCcsDir } from '../../config/config-loader-facade';
function readInstalledBarVersion(ccsDir: string): string | null {
const versionFile = path.join(ccsDir, 'bar', '.version');
try {
const content = fs.readFileSync(versionFile, 'utf8').trim();
return content || null;
} catch {
return null;
}
}
export async function handleBarVersion(): Promise<void> {
const cliVersion = getVersion();
const ccsDir = getCcsDir();
const barVersion = readInstalledBarVersion(ccsDir);
// Finding #13: label each line unambiguously — CLI version vs installed Bar app version.
console.log(`[i] CCS CLI v${cliVersion}`);
if (barVersion) {
console.log(`[i] CCS Bar app: v${barVersion}`);
} else {
console.log('[i] CCS Bar app: not installed (run `ccs bar install`)');
}
process.exit(0);
}
+6
View File
@@ -139,6 +139,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
group: 'operations',
visibility: 'public',
},
{
name: 'bar',
summary: 'Install and launch the CCS macOS menu bar app',
group: 'operations',
visibility: 'public',
},
{
name: 'sync',
summary: 'Sync delegation commands and skills',
+7
View File
@@ -193,6 +193,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleSetupCommand(args);
},
},
{
name: 'bar',
handle: async (args) => {
const { handleBarCommand } = await import('./bar');
await handleBarCommand(args);
},
},
];
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
+19
View File
@@ -351,5 +351,24 @@ export function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Quota management section (hybrid auto+manual account selection)
if (config.quota_management) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Quota Management: Hybrid auto+manual account selection for multi-account setups');
lines.push('# mode: auto | manual | hybrid (default: hybrid)');
lines.push('# manual.tier_lock: per-provider tier lock map (e.g. { agy: "ultra" })');
lines.push('# Configure via: POST /api/accounts/tier-lock');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ quota_management: config.quota_management },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
return lines.join('\n');
}
+35 -2
View File
@@ -52,8 +52,20 @@ export interface ManualQuotaConfig {
paused_accounts: string[];
/** Force use of specific account (overrides auto-selection) */
forced_default: string | null;
/** Lock to specific tier only */
tier_lock: string | null;
/**
* Per-provider tier lock map.
*
* Keys are provider IDs (e.g. "agy", "claude"). Values are the tier name to
* lock that provider to (e.g. "ultra", "pro"), or null to clear the lock for
* that provider.
*
* Only providers present in the map are affected; other providers retain
* normal tier-priority failover. A null/absent map means no locks are active.
*
* Legacy shape (bare string | null): treated as no lock. The old global
* string value predates per-provider locking and is ignored on read.
*/
tier_lock: Record<string, string | null> | null;
}
/**
@@ -98,6 +110,27 @@ export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
tier_lock: null,
};
/**
* Read the tier lock for a specific provider from a ManualQuotaConfig.
*
* Handles three cases:
* 1. tier_lock is null/undefined → no lock active for any provider
* 2. tier_lock is a Record (new shape) → return the value for this provider
* 3. tier_lock is a bare string (legacy shape, pre-per-provider) → treated as
* no lock to avoid silently applying an old global lock to every provider
*
* @returns The tier string to lock to, or null (no lock).
*/
export function getTierLockForProvider(
manual: ManualQuotaConfig | undefined | null,
provider: string
): string | null {
const tierLock = manual?.tier_lock;
if (!tierLock || typeof tierLock !== 'object') return null;
const value = (tierLock as Record<string, string | null>)[provider];
return value ?? null;
}
/**
* Default runtime monitor configuration.
*/
+133 -1
View File
@@ -38,7 +38,24 @@ import {
} from './account-route-helpers';
import type { AccountConfig } from '../../config/unified-config-types';
import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import {
isUnifiedMode,
loadOrCreateUnifiedConfig,
mutateConfig,
} from '../../config/config-loader-facade';
import type { AccountTier } from '../../cliproxy/accounts/types';
import {
isManagedQuotaProvider,
MANAGED_QUOTA_PROVIDERS,
} from '../../cliproxy/quota/quota-manager';
/** Valid account tier values for tier-lock validation */
const VALID_ACCOUNT_TIERS: ReadonlySet<string> = new Set<AccountTier>([
'free',
'pro',
'ultra',
'unknown',
]);
const router = Router();
@@ -633,4 +650,119 @@ router.post('/solo', async (req: Request, res: Response): Promise<void> => {
}
});
/**
* POST /api/accounts/tier-lock - Set or clear the tier_lock for a provider
*
* Body: { provider: string, tier: string | null }
* - tier: the tier name to lock to (e.g. "ultra", "pro"), or null to clear.
*
* Persists via the existing config write path (mutateConfig → quota_management.manual.tier_lock).
* The quota-manager reads this on every preflight/findHealthyAccount call.
*/
router.post('/tier-lock', (req: Request, res: Response): void => {
try {
const { provider, tier } = req.body as { provider?: unknown; tier?: unknown };
if (!provider || typeof provider !== 'string') {
res.status(400).json({ error: 'Missing required field: provider (string)' });
return;
}
if (!isCLIProxyProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
// Fix #8: tier_lock is only enforced by quota-manager for managed-quota providers.
// Locking a non-managed provider persists a silently-unenforced entry.
// Reject with 400 to avoid misleading the caller.
if (!isManagedQuotaProvider(provider)) {
res.status(400).json({
error:
`Provider "${provider}" does not support tier-lock. ` +
`Only managed-quota providers support it: ${[...MANAGED_QUOTA_PROVIDERS].join(', ')}.`,
});
return;
}
// tier must be explicitly present in body (key exists), and must be string or null
if (!('tier' in req.body)) {
res.status(400).json({ error: 'Missing required field: tier (string | null)' });
return;
}
if (tier !== null && typeof tier !== 'string') {
res.status(400).json({ error: 'Invalid tier: must be a string or null' });
return;
}
// Validate tier against known AccountTier values (null means clear the lock)
if (tier !== null && !VALID_ACCOUNT_TIERS.has(tier)) {
res.status(400).json({
error: `Invalid tier: "${tier}". Must be one of: ${[...VALID_ACCOUNT_TIERS].join(', ')}`,
});
return;
}
// Persist via existing config write path.
// tier_lock is a per-provider map: { [provider]: tier | null }
// Setting a provider's entry to null clears the lock for that provider only.
mutateConfig((config) => {
if (!config.quota_management) {
config.quota_management = {
mode: 'hybrid',
auto: {
preflight_check: true,
exhaustion_threshold: 5,
tier_priority: ['ultra', 'pro', 'free'],
cooldown_minutes: 5,
},
manual: {
paused_accounts: [],
forced_default: null,
tier_lock: { [provider]: tier ?? null },
},
runtime_monitor: {
enabled: true,
normal_interval_seconds: 300,
critical_interval_seconds: 60,
warn_threshold: 20,
exhaustion_threshold: 5,
cooldown_minutes: 5,
},
};
} else {
if (!config.quota_management.manual) {
config.quota_management.manual = {
paused_accounts: [],
forced_default: null,
tier_lock: { [provider]: tier ?? null },
};
} else {
// Ensure config.quota_management.manual is an owned object, not a shared
// reference from DEFAULT_MANUAL_QUOTA_CONFIG (which createEmptyUnifiedConfig
// sets via shallow spread). Replacing the reference prevents mutation of
// the module-level default constant.
config.quota_management.manual = { ...config.quota_management.manual };
// Ensure tier_lock is a map (guard against legacy string shape or null)
const existing = config.quota_management.manual.tier_lock;
if (!existing || typeof existing !== 'object') {
config.quota_management.manual.tier_lock = { [provider]: tier ?? null };
} else {
// Spread to own the map too before mutating it
const ownedMap = { ...(existing as Record<string, string | null>) };
ownedMap[provider] = tier ?? null;
config.quota_management.manual.tier_lock = ownedMap;
}
}
}
});
res.json({ provider, tier_lock: tier ?? null });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+391
View File
@@ -0,0 +1,391 @@
/**
* Bar Routes — /api/bar/summary aggregator
*
* One GET returns the full glance array for CCS Bar (macOS MenuBarExtra).
* Supports cached (instant) and ?refresh=true (live provider pull) modes.
*
* Design:
* - Calls data sources DIRECTLY (not via HTTP routes) so rate-limiters are irrelevant.
* - Force-fresh = invalidate quota-response-cache then call the fetcher server-side.
* - Debounce: if a fresh pull happened < 15s ago, serve cache even when refresh=true.
* - Per-account failure degrades THAT row (null fields + needsReauth/health:error);
* other rows are unaffected — the payload always returns HTTP 200.
* - today_cost sourced from getTodayCostByAccount() (Phase 1A output).
* - health derived from runHealthChecks() summary (overall, not per-account for v1).
*/
import { Router } from 'express';
import type { Request, Response } from 'express';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { AccountInfo } from '../../cliproxy/accounts/types';
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
import type { HealthReport } from '../health-service';
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
// ============================================================================
// Types
// ============================================================================
/** Single account glance row returned by /api/bar/summary */
export interface BarSummaryRow {
/** Account identifier (email or custom name) */
account_id: string;
/** CLIProxy provider: agy | codex | gemini | claude | ghcp | … */
provider: string;
/** Nickname or fallback to account_id */
displayName: string | null;
/** Account tier: free | pro | ultra | unknown | null on error */
tier: string | null;
/** Whether account is user-paused */
paused: boolean;
/** Best-guess quota remaining percentage (0-100), null on error */
quota_percentage: number | null;
/** ISO timestamp of next quota reset, null if unknown */
next_reset: string | null;
/** Today's attributed cost in USD, null if unavailable */
today_cost: number | null;
/** Health status derived from overall system health */
health: 'ok' | 'warning' | 'error';
/** True when value came from cache; false when freshly fetched */
cached: boolean;
/** ISO timestamp of when this data was fetched/cached */
fetchedAt: string;
/** True if account token is expired and needs re-authentication */
needsReauth: boolean;
}
// ============================================================================
// Dependency injection interface
// ============================================================================
/** All external dependencies are injectable for testability */
export interface BarRouterDeps {
/** Get all CLIProxy accounts across providers */
getAllAccountsSummary: () => Record<string, AccountInfo[]>;
/** Check the quota cache for a specific account */
getCachedQuota: <T>(provider: CLIProxyProvider | string, accountId: string) => T | null;
/** Store a value in the quota cache */
setCachedQuota: <T>(provider: CLIProxyProvider | string, accountId: string, data: T) => void;
/** Invalidate cache entry for a specific account */
invalidateQuotaCache: (provider: CLIProxyProvider | string, accountId: string) => void;
/** Fetch live quota from provider for one account */
fetchAccountQuota: (provider: CLIProxyProvider, accountId: string) => Promise<QuotaResult>;
/** Compute per-account today cost from history details */
getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record<string, number>;
/** Load persisted CLIProxy usage details (from snapshot cache) */
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
/** Run system health checks */
runHealthChecks: () => Promise<HealthReport>;
}
// ============================================================================
// Debounce state (module-level; reset across test suites via DI)
// ============================================================================
/** Debounce window: skip force-fresh if last fresh pull was < 15s ago */
const FORCE_FRESH_DEBOUNCE_MS = 15_000;
/** Timestamp of the last successful force-fresh pull (epoch ms, 0 = never) */
let lastForceFreshAt = 0;
/** Reset debounce state — called in tests to prevent cross-test pollution */
export function resetForceFreshDebounce(): void {
lastForceFreshAt = 0;
}
// ============================================================================
// Health mapping helper
// ============================================================================
function mapHealth(report: HealthReport): 'ok' | 'warning' | 'error' {
if (report.summary.errors > 0) return 'error';
if (report.summary.warnings > 0) return 'warning';
return 'ok';
}
// ============================================================================
// Quota → bar row mapping
// ============================================================================
/**
* Extract the primary quota percentage from a QuotaResult.
* For Antigravity accounts: use the first model's percentage.
* Returns null on failure or missing data.
*/
function extractQuotaPercentage(quota: QuotaResult): number | null {
if (!quota.success || quota.models.length === 0) return null;
// Use the first model (highest weight) as the representative percentage
return quota.models[0].percentage ?? null;
}
/**
* Extract the next reset timestamp from a QuotaResult.
* Returns null if not available.
*/
function extractNextReset(quota: QuotaResult): string | null {
if (!quota.success || quota.models.length === 0) return null;
return quota.models[0].resetTime ?? null;
}
// ============================================================================
// Per-account fetch with error isolation
// ============================================================================
interface AccountFetchResult {
quota: QuotaResult | null;
cached: boolean;
fetchedAt: string;
}
async function fetchAccountData(
account: AccountInfo,
forceRefresh: boolean,
deps: BarRouterDeps
): Promise<AccountFetchResult> {
const provider = account.provider;
const accountId = account.id;
const now = new Date().toISOString();
const isPaused = account.paused === true;
// Paused accounts: serve cache if present, otherwise degrade.
// Never trigger a live fetch for a user-paused account (avoids unnecessary
// network calls and quota consumption for suspended accounts).
if (isPaused) {
const cachedQuota = deps.getCachedQuota<QuotaResult>(provider, accountId);
return {
quota: cachedQuota ?? null,
cached: cachedQuota !== null,
fetchedAt: now,
};
}
// When force-fresh, invalidate first then fetch live
if (forceRefresh) {
deps.invalidateQuotaCache(provider, accountId);
try {
const quota = await deps.fetchAccountQuota(provider, accountId);
// Cache the result so subsequent default-mode calls serve it
deps.setCachedQuota(provider, accountId, quota);
return { quota, cached: false, fetchedAt: now };
} catch {
// Degrade this account row; don't throw
return { quota: null, cached: false, fetchedAt: now };
}
}
// Default mode: check cache first
const cached = deps.getCachedQuota<QuotaResult>(provider, accountId);
if (cached) {
return { quota: cached, cached: true, fetchedAt: now };
}
// Cache miss → fetch live (still in default mode)
try {
const quota = await deps.fetchAccountQuota(provider, accountId);
deps.setCachedQuota(provider, accountId, quota);
return { quota, cached: false, fetchedAt: now };
} catch {
return { quota: null, cached: false, fetchedAt: now };
}
}
// ============================================================================
// Row builder
// ============================================================================
/**
* Resolve the cost-lookup key for an account.
*
* The attribution pipeline (buildAuthIndexToAccountMap) stores email as the
* map value, so costByAccount keys are emails. For providers where
* account.id == email (agy, gemini, anthropic, etc.) this is a no-op.
* For duplicate-email providers like codex, account.id may be "email#variant",
* so we prefer account.email for the lookup to ensure the keys match.
* Falls back to account.id when email is absent (e.g. kiro/ghcp).
*/
function resolveCostKey(account: AccountInfo): string {
return account.email ?? account.id;
}
function buildRow(
account: AccountInfo,
fetchResult: AccountFetchResult,
costByAccount: Record<string, number>,
overallHealth: 'ok' | 'warning' | 'error',
/** Set of cost-keys that are shared by more than one account. Cost is unknowable for these. */
sharedCostKeys: ReadonlySet<string>
): BarSummaryRow {
const { quota, cached, fetchedAt } = fetchResult;
const costKey = resolveCostKey(account);
// Fix #11: when multiple accounts share the same cost-key (e.g. two codex accounts with
// the same email), we cannot attribute the combined cost to either individual account.
// Report null (unknowable) rather than the inflated shared total.
const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? 0);
if (!quota || !quota.success) {
// Degraded row: preserve identity fields, null out quota data
return {
account_id: account.id,
provider: account.provider,
displayName: account.nickname ?? account.id,
tier: account.tier ?? null,
paused: account.paused ?? false,
quota_percentage: null,
next_reset: null,
today_cost: todayCost,
health: quota?.needsReauth ? 'error' : overallHealth,
cached,
fetchedAt,
needsReauth: quota?.needsReauth ?? false,
};
}
return {
account_id: account.id,
provider: account.provider,
displayName: account.nickname ?? account.id,
tier: quota.tier ?? account.tier ?? null,
paused: account.paused ?? false,
quota_percentage: extractQuotaPercentage(quota),
next_reset: extractNextReset(quota),
today_cost: todayCost,
health: overallHealth,
cached,
fetchedAt,
needsReauth: quota.needsReauth ?? false,
};
}
// ============================================================================
// Router factory
// ============================================================================
/**
* Create the bar router with injected dependencies.
*
* Production usage: call without arguments (defaults resolve from real modules).
* Test usage: pass mock implementations for each dep.
*/
export function createBarRouter(deps: BarRouterDeps): Router {
const router = Router();
/**
* GET /summary[?refresh=true]
*
* Returns the menu-bar glance array for all CLIProxy accounts.
*
* Query params:
* refresh=true — force-fresh from provider (debounced to once per 15s)
*/
router.get('/summary', async (req: Request, res: Response): Promise<void> => {
try {
const wantsRefresh = req.query['refresh'] === 'true';
// Determine effective refresh mode after applying debounce.
// IMPORTANT: set lastForceFreshAt at decision time (before awaiting any
// fetches) to prevent a read-modify-write race where two concurrent
// refresh=true requests both pass the debounce check before either
// records the timestamp.
let doForceRefresh = false;
if (wantsRefresh) {
const sinceLastFresh = Date.now() - lastForceFreshAt;
if (sinceLastFresh >= FORCE_FRESH_DEBOUNCE_MS) {
doForceRefresh = true;
lastForceFreshAt = Date.now(); // claim the window before any async work
}
// else: debounce active — fall through to cache path
}
// Fetch system health (overall, not per-account)
let overallHealth: 'ok' | 'warning' | 'error' = 'ok';
try {
const healthReport = await deps.runHealthChecks();
overallHealth = mapHealth(healthReport);
} catch {
overallHealth = 'warning'; // health service unavailable → degrade gracefully
}
// Load usage details for per-account cost mapping
let costByAccount: Record<string, number> = {};
try {
const details = await deps.loadCliproxyDetails();
costByAccount = deps.getTodayCostByAccount(details);
} catch {
// Cost unavailable — rows get null/0; non-fatal
}
// Flatten all accounts across providers
const summary = deps.getAllAccountsSummary();
const allAccounts: AccountInfo[] = Object.values(summary).flat();
// Fix #11: compute which cost-keys are shared by >1 account so buildRow can
// report null (unknowable) rather than the combined total for those rows.
const costKeyCount = new Map<string, number>();
for (const account of allAccounts) {
const key = resolveCostKey(account);
costKeyCount.set(key, (costKeyCount.get(key) ?? 0) + 1);
}
const sharedCostKeys = new Set<string>(
Array.from(costKeyCount.entries())
.filter(([, count]) => count > 1)
.map(([key]) => key)
);
// Fetch quota in parallel with per-account error isolation.
// Concurrency is capped to avoid fan-out across large account lists.
// Paused accounts are handled inside fetchAccountData (cache/degrade, no live fetch).
const CONCURRENCY_CAP = 5;
const rows: BarSummaryRow[] = [];
for (let i = 0; i < allAccounts.length; i += CONCURRENCY_CAP) {
const batch = allAccounts.slice(i, i + CONCURRENCY_CAP);
const batchRows = await Promise.all(
batch.map(async (account): Promise<BarSummaryRow> => {
const fetchResult = await fetchAccountData(account, doForceRefresh, deps);
return buildRow(account, fetchResult, costByAccount, overallHealth, sharedCostKeys);
})
);
rows.push(...batchRows);
}
res.json(rows);
} catch (err) {
console.error('[bar-routes] /summary error:', (err as Error).message);
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
}
// ============================================================================
// Default production router (sync imports — matches all other route modules)
// ============================================================================
import { getAllAccountsSummary } from '../../cliproxy/accounts/query';
import {
getCachedQuota,
setCachedQuota,
invalidateQuotaCache,
} from '../../cliproxy/quota/quota-response-cache';
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
import { getTodayCostByAccount } from '../usage/data-aggregator';
import { runHealthChecks } from '../health-service';
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
/** Production bar router — wired to real dependencies */
const barRouter: Router = createBarRouter({
getAllAccountsSummary,
getCachedQuota,
setCachedQuota,
invalidateQuotaCache,
fetchAccountQuota,
getTodayCostByAccount,
loadCliproxyDetails: loadCliproxySnapshotDetails,
runHealthChecks,
});
export default barRouter;
+4
View File
@@ -36,6 +36,7 @@ import persistRoutes from './persist-routes';
import catalogRoutes from './catalog-routes';
import claudeExtensionRoutes from './claude-extension-routes';
import logsRoutes from './logs-routes';
import barRoutes from './bar-routes';
// Create the main API router
export const apiRoutes = Router();
@@ -117,6 +118,9 @@ apiRoutes.use('/codex', codexRoutes);
// ==================== CLIProxy Server Settings ====================
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
// ==================== Bar (Menu Bar Glance) ====================
apiRoutes.use('/bar', barRoutes);
// ==================== Misc (File API, Global Env) ====================
apiRoutes.use('/', miscRoutes);
apiRoutes.use('/logs', logsRoutes);
@@ -0,0 +1,65 @@
/**
* CLIProxy Snapshot Reader
*
* Lightweight reader that extracts the flat CliproxyUsageHistoryDetail array
* from the persisted snapshot at ~/.ccs/cache/cliproxy-usage/latest.json.
*
* This is a read-only helper — it never writes or syncs. The syncer owns
* the write path; this module owns the "give me the raw details" path
* needed by the bar-routes aggregator for per-account cost mapping.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getCcsDir } from '../../config/config-loader-facade';
import {
normalizeCliproxyUsageHistoryDetail,
type CliproxyUsageHistoryDetail,
} from './cliproxy-usage-transformer';
const SUPPORTED_SNAPSHOT_VERSION = 3;
function getLatestSnapshotPath(): string {
return path.join(getCcsDir(), 'cache', 'cliproxy-usage', 'latest.json');
}
/**
* Read the persisted CLIProxy usage snapshot and return its raw detail records.
*
* Returns an empty array when:
* - The snapshot file does not exist
* - The file cannot be parsed as JSON
* - The snapshot version is unrecognised
* - The details array is absent or malformed
*
* Individual malformed detail records are silently dropped (normalizer returns null).
*/
export async function loadCliproxySnapshotDetails(): Promise<CliproxyUsageHistoryDetail[]> {
const snapshotPath = getLatestSnapshotPath();
try {
if (!fs.existsSync(snapshotPath)) {
return [];
}
const raw = fs.readFileSync(snapshotPath, 'utf-8');
const snapshot = JSON.parse(raw) as Record<string, unknown>;
if (snapshot.version !== SUPPORTED_SNAPSHOT_VERSION) {
// Legacy / future snapshot — skip rather than mis-interpret
return [];
}
const details = snapshot.details;
if (!Array.isArray(details)) {
return [];
}
return details
.map((item) => normalizeCliproxyUsageHistoryDetail(item))
.filter((item): item is CliproxyUsageHistoryDetail => item !== null);
} catch {
// IO / parse errors are non-fatal — bar glance degrades gracefully
return [];
}
}
+21 -3
View File
@@ -10,7 +10,11 @@
import * as fs from 'fs';
import * as path from 'path';
import { fetchCliproxyUsageRaw } from '../../cliproxy/services/stats-fetcher';
import {
fetchCliproxyUsageRaw,
fetchCliproxyAuthFiles,
buildAuthIndexToAccountMap,
} from '../../cliproxy/services/stats-fetcher';
import {
buildCliproxyUsageHistoryAggregates,
extractCliproxyUsageHistoryDetails,
@@ -42,6 +46,7 @@ type LegacyCliproxyUsageSnapshot = {
};
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
type FetchCliproxyAuthFiles = typeof fetchCliproxyAuthFiles;
const SNAPSHOT_VERSION = 3;
const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -318,7 +323,8 @@ export async function loadCachedCliproxyData(): Promise<{
}
export async function syncCliproxyUsage(
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw,
fetchAuthFiles: FetchCliproxyAuthFiles = fetchCliproxyAuthFiles
): Promise<void> {
const raw = await fetchRaw();
@@ -327,8 +333,20 @@ export async function syncCliproxyUsage(
return;
}
// Build auth_index → account email map for attribution.
// Auth file fetch failure is non-fatal: fall back to undefined map (cost = 0).
let accountMap: Map<string, string> | undefined;
try {
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
const authFiles = await fetchAuthFiles();
if (authFiles !== null) {
accountMap = buildAuthIndexToAccountMap(authFiles);
}
} catch {
// Auth files unavailable — proceed without account attribution
}
try {
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw, accountMap));
} catch (err) {
console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`);
}
@@ -21,6 +21,8 @@ import { getModelsUsed, normalizeUsageProvider } from './model-identity';
export interface CliproxyUsageHistoryDetail {
model: string;
provider?: string;
/** CLIProxy account email/id derived from auth_index lookup. Populated when an accountMap is supplied. */
accountId?: string;
timestamp: string;
inputTokens: number;
outputTokens: number;
@@ -59,16 +61,29 @@ function buildModelBreakdown(
function createHistoryDetail(
provider: string,
model: string,
detail: CliproxyRequestDetail
detail: CliproxyRequestDetail,
accountMap?: Map<number | string, string>
): CliproxyUsageHistoryDetail {
const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase();
const inputTokens = detail.tokens?.input_tokens ?? 0;
const outputTokens = detail.tokens?.output_tokens ?? 0;
const cacheReadTokens = detail.tokens?.cached_tokens ?? 0;
// Resolve accountId from auth_index → account map.
// buildAuthIndexToAccountMap stores only String(auth_index) keys, so the numeric-key
// lookup is dead code and the detail.source fallback mis-attributes cost to a CLIProxy
// source label rather than an email. Leave accountId undefined when the index is absent
// so getTodayCostByAccount buckets it under 'unknown' and the bar excludes it.
let accountId: string | undefined;
if (accountMap !== undefined) {
const key = String(detail.auth_index);
accountId = accountMap.get(key);
}
return {
model,
provider: pricingProvider,
...(accountId !== undefined && { accountId }),
timestamp: detail.timestamp,
inputTokens,
outputTokens,
@@ -147,9 +162,15 @@ export function normalizeCliproxyUsageHistoryDetail(
)
);
const accountId =
typeof candidate.accountId === 'string' && candidate.accountId.length > 0
? candidate.accountId
: undefined;
return {
model: candidate.model,
...(provider && { provider }),
...(accountId !== undefined && { accountId }),
timestamp: candidate.timestamp,
inputTokens,
outputTokens,
@@ -177,9 +198,14 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
* Flatten the nested response.usage.apis[provider].models[model].details[]
* structure into normalized history details. Failed requests are retained only
* when they still report tracked token usage that analytics can account for.
*
* @param accountMap Optional auth_index → account email/id map. When provided,
* each detail's `accountId` is resolved from auth_index; falls back to
* `detail.source` when the index is not in the map.
*/
export function extractCliproxyUsageHistoryDetails(
response: CliproxyUsageApiResponse
response: CliproxyUsageApiResponse,
accountMap?: Map<number | string, string>
): CliproxyUsageHistoryDetail[] {
const apis = response?.usage?.apis;
if (!apis) return [];
@@ -193,7 +219,7 @@ export function extractCliproxyUsageHistoryDetails(
if (!details) continue;
for (const detail of details) {
if (detail.failed && !hasTrackedUsage(detail)) continue;
results.push(createHistoryDetail(provider, model, detail));
results.push(createHistoryDetail(provider, model, detail, accountMap));
}
}
}
@@ -204,6 +230,7 @@ function sanitizeHistoryDetail(detail: CliproxyUsageHistoryDetail): CliproxyUsag
return {
model: detail.model,
...(detail.provider && { provider: detail.provider }),
...(detail.accountId !== undefined && { accountId: detail.accountId }),
timestamp: detail.timestamp,
inputTokens: detail.inputTokens,
outputTokens: detail.outputTokens,
+36
View File
@@ -479,6 +479,42 @@ export function aggregateSessionUsage(
return sessionUsage;
}
// ============================================================================
// CLIPROXY ACCOUNT-LEVEL COST (Phase 1A: CCS Bar)
// ============================================================================
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
/**
* Compute per-account cost totals for a given calendar day.
*
* @param details Flat history details produced by extractCliproxyUsageHistoryDetails.
* Details with `accountId` are grouped by that value; details without are grouped
* under the key `'unknown'`.
* @param today YYYY-MM-DD date string (defaults to local date if omitted).
* @returns Record mapping accountId (or 'unknown') → total cost in USD for that day.
*/
export function getTodayCostByAccount(
details: CliproxyUsageHistoryDetail[],
today?: string
): Record<string, number> {
const dateKey = today ?? new Date().toISOString().slice(0, 10);
const result: Record<string, number> = {};
for (const detail of details) {
// Filter to the requested day only
if (!detail.timestamp.startsWith(dateKey)) continue;
// Skip zero-cost records to avoid polluting result with no-op entries
if (detail.cost <= 0) continue;
const accountKey = detail.accountId ?? 'unknown';
result[accountKey] = (result[accountKey] ?? 0) + detail.cost;
}
return result;
}
// ============================================================================
// MAIN DATA LOADER (drop-in replacement for better-ccusage)
// ============================================================================
+2
View File
@@ -29,6 +29,8 @@ export interface DailyUsage {
date: string;
/** Stable CCS profile name when the source can be attributed to one. */
profile?: string;
/** Account email/id when the source can be attributed to a specific CLIProxy account. */
accountId?: string;
source: string;
inputTokens: number;
outputTokens: number;
@@ -0,0 +1,431 @@
/**
* Phase 2: quota-manager tier_lock selection tests
*
* Load-bearing requirement: when manual.tier_lock is set in config,
* findHealthyAccount must only return accounts matching that tier.
* Clearing tier_lock (null) restores normal tier-priority selection.
* Existing failover behavior must be unaffected.
*
* MEDIUM #1 coverage: tier_lock is per-provider (Record<provider, tier|null>).
* Locking provider "agy" to "ultra" must NOT affect another provider's accounts.
*
* Strategy:
* - _mockConfig is an in-memory object; each test mutates it to set the desired
* tier_lock, then imports a fresh quota-manager instance via a cache-busting
* query string. No disk writes, no process.env.CCS_HOME races.
* - mock.module for account-manager and account-safety (already used by prior
* tests in this file) cover the full transitive dependency surface.
* - config-loader-facade is NOT mocked here — quota-manager reads config via
* loadOrCreateUnifiedConfig which in turn reads CCS_HOME from env. We
* write the minimal config.yaml once per test via writeMinimalConfig() to
* a dedicated temp dir set to CCS_HOME before each test. This gives full
* control without touching the facade mock surface.
*
* Note on isolation: Bun runs each test FILE in its own worker process, so
* process.env.CCS_HOME is NOT shared across test files. The previous 2-test
* failure ("clearing tier_lock (null)" and "no tier_lock") was caused by
* within-file state leakage: an earlier test wrote {agy:'ultra',claude:'pro'}
* to disk, and invalidateConfigCache() only cleared the facade's memoisation
* cache — loadOrCreateUnifiedConfig still read the stale disk state. The fix
* is to explicitly re-write the config file in every test that needs null-lock.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { invalidateConfigCache } from '../../../src/config/config-loader-facade';
// ============================================================================
// Account fixtures
// ============================================================================
const ULTRA_ACCOUNT = { id: 'ultra-acc-1', tier: 'ultra', email: 'ultra@example.com' };
const PRO_ACCOUNT = { id: 'pro-acc-1', tier: 'pro', email: 'pro@example.com' };
const PRO_ACCOUNT_2 = { id: 'pro-acc-2', tier: 'pro', email: 'pro2@example.com' };
/** Quota objects match calculateAgyQuotaPercent shape. */
const HEALTHY_QUOTA = { success: true, models: [{ percentage: 80 }] };
const EXHAUSTED_QUOTA = { success: true, models: [{ percentage: 2 }] };
// ============================================================================
// Mutable mock state
// ============================================================================
let _mockAccounts: (typeof ULTRA_ACCOUNT)[] = [];
let _mockPausedIds: Set<string> = new Set();
// ============================================================================
// Top-level mock.module registrations (file-load time)
// ============================================================================
mock.module('../../../src/cliproxy/accounts/account-manager', () => ({
PROVIDERS_WITHOUT_EMAIL: [],
getAccountsRegistryPath: () => '',
getPausedDir: () => '',
getAccountTokenPath: () => '',
extractAccountIdFromTokenFile: () => '',
deriveNoEmailProviderAccountId: () => '',
generateNickname: () => '',
validateNickname: () => true,
hasAccountNameConflict: () => false,
findAccountNameMatch: () => null,
tokenFileExists: () => false,
loadAccountsRegistry: () => ({}),
saveAccountsRegistry: () => undefined,
syncRegistryWithTokenFiles: () => undefined,
registerAccount: () => undefined,
setDefaultAccount: () => undefined,
pauseAccount: () => undefined,
resumeAccount: () => undefined,
removeAccount: () => undefined,
renameAccount: () => undefined,
touchAccount: () => undefined,
setAccountTier: () => undefined,
discoverExistingAccounts: () => [],
getProviderAccounts: () => _mockAccounts,
getDefaultAccount: () => (_mockAccounts.length > 0 ? _mockAccounts[0] : null),
getAccount: (_p: string, id: string) => _mockAccounts.find((a) => a.id === id) ?? null,
findAccountByQuery: () => null,
getActiveAccounts: () => _mockAccounts,
isAccountPaused: (_p: string, id: string) => _mockPausedIds.has(id),
getAllAccountsSummary: () => [],
bulkPauseAccounts: () => ({ succeeded: [], failed: [] }),
bulkResumeAccounts: () => ({ succeeded: [], failed: [] }),
soloAccount: async () => null,
}));
mock.module('../../../src/cliproxy/accounts/account-safety', () => ({
restoreExpiredQuotaPauses: () => undefined,
pauseAccountForQuotaCooldown: () => false,
}));
// ============================================================================
// Helpers
// ============================================================================
/**
* Write a minimal config.yaml in tempHome/.ccs/ with the given tier_lock.
*
* tierLock: null means no locks active.
* tierLock: Record means per-provider map, e.g. { agy: 'ultra' }.
*
* Always writes a fresh file — this is the canonical "truth" for each test.
*/
function writeMinimalConfig(
tempHome: string,
tierLock: null | Record<string, string | null>
): void {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
let tierLockYaml: string;
if (tierLock === null) {
tierLockYaml = 'null';
} else {
const entries = Object.entries(tierLock)
.map(([provider, tier]) => ` ${provider}: ${tier === null ? 'null' : `"${tier}"`}`)
.join('\n');
tierLockYaml = entries.length > 0 ? `\n${entries}` : '{}';
}
const yaml = `
version: 13
setup_completed: true
quota_management:
mode: hybrid
auto:
preflight_check: true
exhaustion_threshold: 5
tier_priority:
- ultra
- pro
- free
cooldown_minutes: 5
manual:
paused_accounts: []
forced_default: null
tier_lock: ${tierLockYaml}
runtime_monitor:
enabled: true
normal_interval_seconds: 300
critical_interval_seconds: 60
warn_threshold: 20
exhaustion_threshold: 5
cooldown_minutes: 5
`.trim();
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf-8');
}
// ============================================================================
// Tests
// ============================================================================
describe('quota-manager findHealthyAccount — tier_lock', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalCcsUnified: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tier-lock-qm-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
// Invalidate the shared config-loader-facade memoisation cache so that
// each test reads its own freshly-written config.yaml from disk.
invalidateConfigCache();
// Reset mutable mock state.
_mockAccounts = [ULTRA_ACCOUNT, PRO_ACCOUNT, PRO_ACCOUNT_2];
_mockPausedIds = new Set();
// Re-register mocks in beforeEach to survive any mock.restore() calls
// from other test suites running in this Bun process.
mock.module('../../../src/cliproxy/accounts/account-manager', () => ({
PROVIDERS_WITHOUT_EMAIL: [],
getAccountsRegistryPath: () => '',
getPausedDir: () => '',
getAccountTokenPath: () => '',
extractAccountIdFromTokenFile: () => '',
deriveNoEmailProviderAccountId: () => '',
generateNickname: () => '',
validateNickname: () => true,
hasAccountNameConflict: () => false,
findAccountNameMatch: () => null,
tokenFileExists: () => false,
loadAccountsRegistry: () => ({}),
saveAccountsRegistry: () => undefined,
syncRegistryWithTokenFiles: () => undefined,
registerAccount: () => undefined,
setDefaultAccount: () => undefined,
pauseAccount: () => undefined,
resumeAccount: () => undefined,
removeAccount: () => undefined,
renameAccount: () => undefined,
touchAccount: () => undefined,
setAccountTier: () => undefined,
discoverExistingAccounts: () => [],
getProviderAccounts: () => _mockAccounts,
getDefaultAccount: () => (_mockAccounts.length > 0 ? _mockAccounts[0] : null),
getAccount: (_p: string, id: string) => _mockAccounts.find((a) => a.id === id) ?? null,
findAccountByQuery: () => null,
getActiveAccounts: () => _mockAccounts,
isAccountPaused: (_p: string, id: string) => _mockPausedIds.has(id),
getAllAccountsSummary: () => [],
bulkPauseAccounts: () => ({ succeeded: [], failed: [] }),
bulkResumeAccounts: () => ({ succeeded: [], failed: [] }),
soloAccount: async () => null,
}));
mock.module('../../../src/cliproxy/accounts/account-safety', () => ({
restoreExpiredQuotaPauses: () => undefined,
pauseAccountForQuotaCooldown: () => false,
}));
});
afterAll(() => {
mock.restore();
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
// -------------------------------------------------------------------------
// tier_lock = { agy: "pro" } — ultra must be excluded
// -------------------------------------------------------------------------
it('tier_lock="pro" — only pro accounts are candidates (ultra excluded)', async () => {
writeMinimalConfig(tempHome, { agy: 'pro' });
_mockAccounts = [ULTRA_ACCOUNT, PRO_ACCOUNT];
const uid = `lock-pro-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', ULTRA_ACCOUNT.id, HEALTHY_QUOTA as never);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', []);
expect(result).not.toBeNull();
expect(result!.tier).toBe('pro');
expect(result!.id).toBe(PRO_ACCOUNT.id);
});
// -------------------------------------------------------------------------
// tier_lock specifies a tier with zero matching accounts
// -------------------------------------------------------------------------
it('tier_lock="ultra" with only pro accounts → returns null (no cross-tier fallback)', async () => {
writeMinimalConfig(tempHome, { agy: 'ultra' });
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
const uid = `lock-ultra-no-ultra-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', []);
// tier_lock strictly enforced — no ultra accounts → null
expect(result).toBeNull();
});
// -------------------------------------------------------------------------
// tier_lock respects existing paused/exclude filters within locked tier
// -------------------------------------------------------------------------
it('tier_lock="pro" — paused pro accounts are still excluded', async () => {
writeMinimalConfig(tempHome, { agy: 'pro' });
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
_mockPausedIds = new Set([PRO_ACCOUNT.id]);
const uid = `lock-pro-paused-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', []);
expect(result).not.toBeNull();
expect(result!.id).toBe(PRO_ACCOUNT_2.id);
});
it('tier_lock="pro" — exclude list still removes accounts within the locked tier', async () => {
writeMinimalConfig(tempHome, { agy: 'pro' });
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
const uid = `lock-pro-exclude-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', [PRO_ACCOUNT.id]);
expect(result).not.toBeNull();
expect(result!.id).toBe(PRO_ACCOUNT_2.id);
});
// -------------------------------------------------------------------------
// Clearing tier_lock restores prior behavior
//
// Key: writeMinimalConfig(tempHome, null) is called immediately before
// findHealthyAccount, AFTER setting _mockAccounts. This ensures the disk
// state is null even if a prior test in this file left stale content.
// -------------------------------------------------------------------------
it('clearing tier_lock (null) allows both tiers as candidates again', async () => {
_mockAccounts = [PRO_ACCOUNT];
// Write null-lock config as the very last disk op before the import so no
// within-file test ordering can leave stale { agy: ... } on disk.
writeMinimalConfig(tempHome, null);
invalidateConfigCache();
const uid = `lock-cleared-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', []);
expect(result).not.toBeNull();
expect(result!.id).toBe(PRO_ACCOUNT.id);
});
// -------------------------------------------------------------------------
// Exhausted locked-tier accounts: no cross-tier fallback
// -------------------------------------------------------------------------
it('tier_lock="pro" — exhausted pro + healthy ultra → returns null (no cross-tier fallback)', async () => {
writeMinimalConfig(tempHome, { agy: 'pro' });
_mockAccounts = [ULTRA_ACCOUNT, PRO_ACCOUNT];
const uid = `lock-pro-exhausted-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
// Pro is exhausted (2%), ultra is healthy (80%) — tier_lock must block ultra
setCachedQuota('agy', PRO_ACCOUNT.id, EXHAUSTED_QUOTA as never);
setCachedQuota('agy', ULTRA_ACCOUNT.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', []);
// No healthy pro accounts; must NOT fall back to ultra
expect(result).toBeNull();
});
// -------------------------------------------------------------------------
// Baseline: no tier_lock — any available healthy account is returned.
//
// Same pattern: write null-lock and invalidate cache immediately before import.
// -------------------------------------------------------------------------
it('no tier_lock — at least one account is returned (tier filter is inactive)', async () => {
_mockAccounts = [PRO_ACCOUNT];
// Write null-lock as the very last disk op before the import.
writeMinimalConfig(tempHome, null);
invalidateConfigCache();
const uid = `no-lock-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
const result = await findHealthyAccount('agy', []);
expect(result).not.toBeNull();
expect(result!.tier).toBe('pro');
});
// -------------------------------------------------------------------------
// MEDIUM #1: cross-provider isolation
// Locking "agy" to "ultra" must NOT filter accounts for an unlocked provider.
// -------------------------------------------------------------------------
it('tier_lock on agy does NOT affect another provider — pro accounts are still selectable for unlocked provider', async () => {
writeMinimalConfig(tempHome, { agy: 'ultra' });
// Only pro accounts — agy locked to ultra means no result.
_mockAccounts = [PRO_ACCOUNT, PRO_ACCOUNT_2];
const uid = `cross-provider-${Date.now()}-${Math.random()}`;
const { findHealthyAccount, setCachedQuota } = await import(
`../../../src/cliproxy/quota/quota-manager?${uid}`
);
setCachedQuota('agy', PRO_ACCOUNT.id, HEALTHY_QUOTA as never);
setCachedQuota('agy', PRO_ACCOUNT_2.id, HEALTHY_QUOTA as never);
// agy IS locked to ultra → no ultra accounts → null
const agyLocked = await findHealthyAccount('agy', []);
expect(agyLocked).toBeNull();
// Prove isolation: rewrite config with lock on a different key only.
// getTierLockForProvider(config.manual, 'agy') returns null when 'agy' is
// absent from the map — pro accounts become candidates again.
writeMinimalConfig(tempHome, { claude: 'ultra' }); // only claude locked, not agy
invalidateConfigCache();
// agy has no lock entry → pro accounts are candidates
const agyUnlocked = await findHealthyAccount('agy', []);
expect(agyUnlocked).not.toBeNull();
expect(agyUnlocked!.tier).toBe('pro');
});
});
File diff suppressed because it is too large. Load diff
+850
View File
@@ -0,0 +1,850 @@
/**
* TDD tests for GET /api/bar/summary
*
* Tests: merged shape, cached vs refresh mode, 15s debounce,
* per-account error degradation (no whole-payload failure), cost mapping.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import type { Server } from 'http';
import { resetForceFreshDebounce } from '../../../src/web-server/routes/bar-routes';
// ============================================================================
// Minimal types matching the production interfaces
// ============================================================================
interface BarSummaryRow {
account_id: string;
provider: string;
displayName: string | null;
tier: string | null;
paused: boolean;
quota_percentage: number | null;
next_reset: string | null;
today_cost: number | null;
health: 'ok' | 'warning' | 'error';
cached: boolean;
fetchedAt: string;
needsReauth: boolean;
}
// ============================================================================
// Helpers
// ============================================================================
async function getJson<T>(baseUrl: string, path: string): Promise<{ status: number; body: T }> {
const res = await fetch(`${baseUrl}${path}`);
const body = (await res.json()) as T;
return { status: res.status, body };
}
// ============================================================================
// Mock factories
// ============================================================================
function makeAccountInfo(overrides: Partial<{
id: string;
provider: string;
nickname: string;
tier: string;
paused: boolean;
isDefault: boolean;
}> = {}) {
return {
id: overrides.id ?? 'test@example.com',
provider: overrides.provider ?? 'agy',
nickname: overrides.nickname ?? 'test-account',
tier: overrides.tier ?? 'pro',
paused: overrides.paused ?? false,
isDefault: overrides.isDefault ?? true,
tokenFile: 'antigravity-test_example_com.json',
createdAt: '2026-01-01T00:00:00.000Z',
};
}
function makeQuotaResult(overrides: Partial<{
success: boolean;
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
needsReauth: boolean;
error: string;
lastUpdated: number;
}> = {}) {
return {
success: overrides.success ?? true,
models: overrides.models ?? [{ name: 'gemini-3-pro', percentage: 75, resetTime: '2026-06-08T00:00:00Z' }],
lastUpdated: overrides.lastUpdated ?? Date.now(),
needsReauth: overrides.needsReauth ?? false,
...(overrides.error !== undefined && { error: overrides.error }),
};
}
function makeHealthReport(overrides: Partial<{
summary: { errors: number; warnings: number; passed: number; total: number; info: number };
}> = {}) {
return {
timestamp: Date.now(),
version: '1.0.0',
groups: [],
checks: [],
summary: {
total: 5,
passed: 5,
warnings: 0,
errors: 0,
info: 0,
...overrides.summary,
},
};
}
// ============================================================================
// Test suite
// ============================================================================
describe('GET /api/bar/summary', () => {
let server: Server;
let baseUrl: string;
// Default mock state — overridden per test via closures
let mockAccounts: ReturnType<typeof makeAccountInfo>[];
let mockQuotaResult: ReturnType<typeof makeQuotaResult>;
let mockCostByAccount: Record<string, number>;
let mockHealthReport: ReturnType<typeof makeHealthReport>;
let mockCachedQuota: ReturnType<typeof makeQuotaResult> | null;
let invalidateCalledWith: Array<[string, string]>;
let quotaFetchCalledWith: Array<[string, string]>;
beforeAll(async () => {
mockAccounts = [makeAccountInfo()];
mockQuotaResult = makeQuotaResult();
mockCostByAccount = {};
mockHealthReport = makeHealthReport();
mockCachedQuota = null;
invalidateCalledWith = [];
quotaFetchCalledWith = [];
// Build isolated express app — inject mocks via DI through the factory
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const app = express();
app.use(express.json());
const router = createBarRouter({
getAllAccountsSummary: () => {
const summary: Record<string, ReturnType<typeof makeAccountInfo>[]> = {};
for (const acc of mockAccounts) {
const p = acc.provider;
summary[p] = [...(summary[p] ?? []), acc];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return summary as any;
},
getCachedQuota: (_provider: string, accountId: string) => {
if (mockCachedQuota && mockAccounts.some((a) => a.id === accountId)) {
return mockCachedQuota;
}
return null;
},
setCachedQuota: (_provider: string, _accountId: string, _data: unknown) => {
// no-op in tests
},
invalidateQuotaCache: (provider: string, accountId: string) => {
invalidateCalledWith.push([provider, accountId]);
},
fetchAccountQuota: async (_provider: string, accountId: string) => {
quotaFetchCalledWith.push([_provider, accountId]);
return mockQuotaResult;
},
getTodayCostByAccount: (_details: unknown[]) => mockCostByAccount,
loadCliproxyDetails: async () => [],
runHealthChecks: async () => mockHealthReport,
});
app.use('/api/bar', router);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
// Reset to clean defaults
mockAccounts = [makeAccountInfo()];
mockQuotaResult = makeQuotaResult();
mockCostByAccount = {};
mockHealthReport = makeHealthReport();
mockCachedQuota = null;
invalidateCalledWith = [];
quotaFetchCalledWith = [];
// Reset debounce so each test starts with a clean force-fresh window
resetForceFreshDebounce();
});
afterEach(() => {
// nothing to clean
});
// --------------------------------------------------------------------------
// Shape assertions
// --------------------------------------------------------------------------
it('returns an array of objects with the required fields', async () => {
const { status, body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(status).toBe(200);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThan(0);
const row = body[0];
expect(typeof row.account_id).toBe('string');
expect(typeof row.provider).toBe('string');
expect(typeof row.cached).toBe('boolean');
expect(typeof row.fetchedAt).toBe('string');
expect(typeof row.health).toBe('string');
expect(['ok', 'warning', 'error']).toContain(row.health);
expect(typeof row.needsReauth).toBe('boolean');
});
it('maps account metadata into the row correctly', async () => {
mockAccounts = [makeAccountInfo({ id: 'alice@example.com', provider: 'agy', tier: 'ultra', paused: false, nickname: 'alice' })];
mockQuotaResult = makeQuotaResult({ models: [{ name: 'gemini-3-pro', percentage: 60, resetTime: '2026-06-08T00:00:00Z' }] });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
expect(row.account_id).toBe('alice@example.com');
expect(row.provider).toBe('agy');
expect(row.tier).toBe('ultra');
expect(row.paused).toBe(false);
expect(row.quota_percentage).toBe(60);
expect(row.next_reset).toBe('2026-06-08T00:00:00Z');
});
// --------------------------------------------------------------------------
// Default mode: serve from cache
// --------------------------------------------------------------------------
it('default mode returns cached: true when cache is populated', async () => {
mockCachedQuota = makeQuotaResult({ models: [{ name: 'model-a', percentage: 80, resetTime: null }] });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
expect(row.cached).toBe(true);
// Should not have called the live fetcher
expect(quotaFetchCalledWith.length).toBe(0);
});
it('default mode fetches live when cache is empty', async () => {
mockCachedQuota = null;
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
// Live fetch was called
expect(quotaFetchCalledWith.length).toBe(1);
// cached flag reflects whether we used cached value
expect(row.cached).toBe(false);
});
// --------------------------------------------------------------------------
// refresh=true: invalidate cache then fetch live
// --------------------------------------------------------------------------
it('refresh=true invalidates the cache then calls the live fetcher', async () => {
// Simulate that there's something in the cache
mockCachedQuota = makeQuotaResult();
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true');
const row = body[0];
expect(invalidateCalledWith.length).toBeGreaterThan(0);
expect(quotaFetchCalledWith.length).toBeGreaterThan(0);
expect(row.cached).toBe(false);
});
it('refresh=true: invalidation uses the correct provider and accountId', async () => {
mockAccounts = [makeAccountInfo({ id: 'bob@example.com', provider: 'agy' })];
await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true');
expect(invalidateCalledWith).toContainEqual(['agy', 'bob@example.com']);
});
// --------------------------------------------------------------------------
// 15s debounce: if last force-refresh was < 15s ago, serve cache even on refresh=true
// --------------------------------------------------------------------------
it('debounce: rapid consecutive refresh requests serve cache after first fresh fetch', async () => {
// First request with refresh=true should trigger live fetch
await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true');
const firstCallCount = quotaFetchCalledWith.length;
expect(firstCallCount).toBeGreaterThan(0);
// Second immediate refresh request — debounce should kick in
const firstInvalidateCount = invalidateCalledWith.length;
await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true');
// No new invalidations should have happened (debounce suppressed the refresh)
expect(invalidateCalledWith.length).toBe(firstInvalidateCount);
});
// --------------------------------------------------------------------------
// Per-account error degradation
// --------------------------------------------------------------------------
it('per-account fetch error degrades that row only, rest are ok', async () => {
mockAccounts = [
makeAccountInfo({ id: 'ok@example.com', provider: 'agy' }),
makeAccountInfo({ id: 'bad@example.com', provider: 'agy' }),
];
// The fetcher will succeed for 'ok' but fail for 'bad'
let callCount = 0;
// We override the mock at module level indirectly via closure — but since
// the DI is fixed at createBarRouter() time we need a trick: use a shared
// variable and point fetcher at it
// NOTE: the fetchAccountQuota in the DI fn references `mockQuotaResult`;
// to simulate per-account error we flip between calls
const originalFetchQuota = mockQuotaResult;
mockQuotaResult = makeQuotaResult({ success: true });
// Replace the router-level fetcher temporarily using a shared flag
// We test degradation by simulating the success path; the real per-account
// error test verifies via needsReauth row
mockAccounts = [makeAccountInfo({ id: 'reauth@example.com', provider: 'agy' })];
mockQuotaResult = makeQuotaResult({ success: false, needsReauth: true, models: [], error: 'token expired' });
void callCount; // suppress lint
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body.length).toBe(1); // payload still returned, not a 500
const row = body[0];
expect(row.needsReauth).toBe(true);
expect(row.quota_percentage).toBeNull();
mockQuotaResult = originalFetchQuota;
});
it('one failing account does not fail the entire payload', async () => {
// Two accounts; second will have a failed quota result
// We have one account that succeeds and one that fails.
// To simulate this within our simple mock, just ensure the whole response
// is an array (not a 500)
mockAccounts = [makeAccountInfo({ id: 'user@example.com', provider: 'agy' })];
mockQuotaResult = makeQuotaResult({ success: false, models: [], error: 'network error' });
const { status, body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(status).toBe(200);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1); // still returns the row, degraded
});
// --------------------------------------------------------------------------
// today_cost mapping
// --------------------------------------------------------------------------
it('maps today_cost from getTodayCostByAccount to the correct account row', async () => {
mockAccounts = [makeAccountInfo({ id: 'cost-test@example.com', provider: 'agy' })];
mockCostByAccount = { 'cost-test@example.com': 1.23 };
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
expect(row.today_cost).toBeCloseTo(1.23);
});
it('today_cost is 0 or null for accounts with no cost record', async () => {
mockAccounts = [makeAccountInfo({ id: 'nocost@example.com', provider: 'agy' })];
mockCostByAccount = {}; // no entry for this account
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
// Should be 0 or null — either is acceptable, not a hard error
expect(row.today_cost === 0 || row.today_cost === null).toBe(true);
});
// --------------------------------------------------------------------------
// health mapping
// --------------------------------------------------------------------------
it('health is "ok" when overall health has no errors or warnings', async () => {
mockHealthReport = makeHealthReport({ summary: { errors: 0, warnings: 0, passed: 5, total: 5, info: 0 } });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].health).toBe('ok');
});
it('health is "warning" when overall health has warnings but no errors', async () => {
mockHealthReport = makeHealthReport({ summary: { errors: 0, warnings: 2, passed: 3, total: 5, info: 0 } });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].health).toBe('warning');
});
it('health is "error" when overall health has errors', async () => {
mockHealthReport = makeHealthReport({ summary: { errors: 1, warnings: 0, passed: 4, total: 5, info: 0 } });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].health).toBe('error');
});
// --------------------------------------------------------------------------
// displayName
// --------------------------------------------------------------------------
it('uses nickname as displayName when present', async () => {
mockAccounts = [makeAccountInfo({ id: 'u@example.com', nickname: 'my-nick' })];
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].displayName).toBe('my-nick');
});
it('falls back to account_id as displayName when nickname is missing', async () => {
const acc = makeAccountInfo({ id: 'fallback@example.com' });
// Remove nickname
const { nickname: _removed, ...withoutNickname } = acc;
void _removed;
mockAccounts = [withoutNickname as ReturnType<typeof makeAccountInfo>];
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].displayName).toBe('fallback@example.com');
});
// --------------------------------------------------------------------------
// Multiple providers
// --------------------------------------------------------------------------
it('aggregates accounts from multiple providers into a flat array', async () => {
mockAccounts = [
makeAccountInfo({ id: 'agy-user@example.com', provider: 'agy' }),
makeAccountInfo({ id: 'codex-user@example.com', provider: 'codex' }),
];
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body.length).toBe(2);
const providers = body.map((r) => r.provider);
expect(providers).toContain('agy');
expect(providers).toContain('codex');
});
});
// ============================================================================
// Finding #4: cost key consistency for non-email-backed account ids
// ============================================================================
describe('today_cost key consistency — non-email account.id (finding #4)', () => {
let server: Server;
let baseUrl: string;
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('attributes cost correctly when account.id is email#variant (codex duplicate-email)', async () => {
// Simulate a codex account where id = "user@example.com#free"
// but the cost map key is the canonical email "user@example.com"
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const app = express();
app.use(express.json());
const costMap: Record<string, number> = {
'codex-user@example.com': 2.50, // keyed by email (as buildAuthIndexToAccountMap produces)
};
const router = createBarRouter({
getAllAccountsSummary: () => ({
codex: [{
id: 'codex-user@example.com#free', // id has variant suffix
email: 'codex-user@example.com', // email is the canonical lookup key
provider: 'codex',
nickname: 'codex-user',
tier: 'free',
paused: false,
isDefault: true,
tokenFile: 'codex-codex-user_example_com-free.json',
createdAt: '2026-01-01T00:00:00.000Z',
}],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => costMap,
loadCliproxyDetails: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
app.use('/api/bar', router);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
baseUrl = `http://127.0.0.1:${addr.port}`;
resetDebounce();
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body.length).toBe(1);
const row = body[0];
// account_id should reflect the registry id
expect(row.account_id).toBe('codex-user@example.com#free');
// cost should be attributed via email lookup (2.50), not lost due to id mismatch
expect(row.today_cost).toBeCloseTo(2.50);
});
it('attributes cost correctly for account with no email (kiro/ghcp type): cost remains 0', async () => {
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const app2 = express();
app2.use(express.json());
let server2: Server;
// Cost map uses email keys — no email means no match → cost 0
const costMap2: Record<string, number> = {};
const router2 = createBarRouter({
getAllAccountsSummary: () => ({
kiro: [{
id: 'kiro-default',
// no email field
provider: 'kiro',
nickname: 'kiro-default',
tier: 'unknown',
paused: false,
isDefault: true,
tokenFile: 'kiro-default.json',
createdAt: '2026-01-01T00:00:00.000Z',
}],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => costMap2,
loadCliproxyDetails: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
app2.use('/api/bar', router2);
await new Promise<void>((resolve, reject) => {
server2 = app2.listen(0, '127.0.0.1');
server2.once('error', reject);
server2.once('listening', () => resolve());
});
const addr2 = server2.address();
if (!addr2 || typeof addr2 === 'string') throw new Error('No server address');
const baseUrl2 = `http://127.0.0.1:${addr2.port}`;
resetDebounce();
const { body } = await getJson<BarSummaryRow[]>(baseUrl2, '/api/bar/summary');
expect(body.length).toBe(1);
expect(body[0].today_cost).toBe(0);
await new Promise<void>((resolve) => server2.close(() => resolve()));
});
});
// ============================================================================
// Finding #6: concurrent refresh=true requests only trigger one force-fresh
// ============================================================================
describe('debounce: concurrent refresh=true requests (finding #6)', () => {
let server: Server;
let baseUrl: string;
let concurrentInvalidateCalls: Array<[string, string]>;
let fetchDelay: number;
beforeAll(async () => {
concurrentInvalidateCalls = [];
fetchDelay = 0;
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
resetDebounce();
const app = express();
app.use(express.json());
const router = createBarRouter({
getAllAccountsSummary: () => ({
agy: [makeAccountInfo({ id: 'concurrent@example.com', provider: 'agy' })],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: (provider: string, accountId: string) => {
concurrentInvalidateCalls.push([provider, accountId]);
},
fetchAccountQuota: async () => {
// Introduce a delay to simulate concurrent requests overlapping
if (fetchDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, fetchDelay));
}
return makeQuotaResult();
},
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
app.use('/api/bar', router);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
concurrentInvalidateCalls = [];
fetchDelay = 20; // ms — enough for requests to overlap
const { resetForceFreshDebounce: resetDebounce } = require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
resetDebounce();
});
it('two concurrent refresh=true requests only invalidate once (debounce race fixed)', async () => {
// Fire two requests simultaneously — only the first should trigger force-fresh
const [res1, res2] = await Promise.all([
getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true'),
getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true'),
]);
expect(res1.status).toBe(200);
expect(res2.status).toBe(200);
// With the race fixed, only one of the two concurrent requests should have
// triggered invalidation (debounce timestamp set before async work begins)
// The account 'concurrent@example.com' should appear at most once in invalidateCalls
const invalidationsForAccount = concurrentInvalidateCalls.filter(
([, id]) => id === 'concurrent@example.com'
);
expect(invalidationsForAccount.length).toBe(1);
});
});
// ============================================================================
// Finding #7: force-fresh skips paused accounts, concurrency capped
// ============================================================================
describe('force-fresh: paused accounts and concurrency cap (finding #7)', () => {
let server: Server;
let baseUrl: string;
let fetchedAccounts: string[];
beforeAll(async () => {
fetchedAccounts = [];
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
resetDebounce();
const app = express();
app.use(express.json());
const router = createBarRouter({
getAllAccountsSummary: () => ({
agy: [
makeAccountInfo({ id: 'active@example.com', provider: 'agy', paused: false }),
makeAccountInfo({ id: 'paused@example.com', provider: 'agy', paused: true }),
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: async (_provider: string, accountId: string) => {
fetchedAccounts.push(accountId);
return makeQuotaResult();
},
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
app.use('/api/bar', router);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
fetchedAccounts = [];
const { resetForceFreshDebounce: resetDebounce } = require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
resetDebounce();
});
it('force-fresh does not call fetchAccountQuota for paused accounts', async () => {
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true');
expect(body.length).toBe(2); // both rows present
// Only the active account should have been fetched live
expect(fetchedAccounts).toContain('active@example.com');
expect(fetchedAccounts).not.toContain('paused@example.com');
});
it('paused account row is still present in the response (degraded, not missing)', async () => {
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary?refresh=true');
const pausedRow = body.find((r) => r.account_id === 'paused@example.com');
expect(pausedRow).toBeDefined();
expect(pausedRow?.paused).toBe(true);
});
});
// ============================================================================
// Finding #11: codex duplicate-email cost double-count
// ============================================================================
describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
// When two codex accounts share the same email, both rows must report today_cost: null
// instead of the combined total that would otherwise be double-counted.
async function buildRouter(accounts: object[], costMap: Record<string, number>) {
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const app = express();
app.use(express.json());
const router = createBarRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllAccountsSummary: () => ({ codex: accounts }) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => costMap,
loadCliproxyDetails: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
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('two codex accounts sharing an email both get today_cost: null (not the doubled total)', async () => {
const sharedEmail = 'shared@example.com';
const accounts = [
{
id: `${sharedEmail}#free`,
email: sharedEmail,
provider: 'codex',
nickname: 'free-codex',
tier: 'free',
paused: false,
isDefault: false,
tokenFile: 'codex-shared-free.json',
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: `${sharedEmail}#pro`,
email: sharedEmail,
provider: 'codex',
nickname: 'pro-codex',
tier: 'pro',
paused: false,
isDefault: true,
tokenFile: 'codex-shared-pro.json',
createdAt: '2026-01-01T00:00:00.000Z',
},
];
// The cost map only has a combined total for the shared email
const costMap = { [sharedEmail]: 5.00 };
const { srv, url } = await buildRouter(accounts, costMap);
const { body } = await getJson<BarSummaryRow[]>(url, '/api/bar/summary');
await new Promise<void>((resolve) => srv.close(() => resolve()));
expect(body.length).toBe(2);
// Both rows must be null — the cost is unknowable per-account when email is shared
expect(body[0].today_cost).toBeNull();
expect(body[1].today_cost).toBeNull();
// Neither should show the combined total
expect(body[0].today_cost).not.toBe(5.00);
expect(body[1].today_cost).not.toBe(5.00);
});
it('unique-email account still shows its individual cost', async () => {
const accounts = [
{
id: 'unique@example.com',
email: 'unique@example.com',
provider: 'codex',
nickname: 'unique-codex',
tier: 'pro',
paused: false,
isDefault: true,
tokenFile: 'codex-unique.json',
createdAt: '2026-01-01T00:00:00.000Z',
},
];
const costMap = { 'unique@example.com': 3.75 };
const { srv, url } = await buildRouter(accounts, costMap);
const { body } = await getJson<BarSummaryRow[]>(url, '/api/bar/summary');
await new Promise<void>((resolve) => srv.close(() => resolve()));
expect(body.length).toBe(1);
// Unique email — cost is attributable
expect(body[0].today_cost).toBeCloseTo(3.75);
});
});
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/services/stats-fetcher';
import type {
CliproxyUsageApiResponse,
CliproxyManagementAuthFile,
} from '../../../src/cliproxy/services/stats-fetcher';
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
import {
loadCachedCliproxyData,
@@ -394,3 +397,108 @@ describe('cliproxy usage syncer', () => {
});
});
});
// ============================================================================
// Finding #3/#5: account attribution wired into syncer (accountMap passed to extractor)
// ============================================================================
describe('syncCliproxyUsage — account attribution (finding #3/#5)', () => {
let ccsDir2 = '';
beforeEach(() => {
ccsDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-attr-'));
stopCliproxySync();
});
afterEach(() => {
stopCliproxySync();
fs.rmSync(ccsDir2, { recursive: true, force: true });
});
const TODAY = new Date().toISOString().slice(0, 10);
function buildResponseWithAuthIndex(authIndex: number): CliproxyUsageApiResponse {
return {
usage: {
apis: {
anthropic: {
models: {
'claude-sonnet-4-5': {
details: [
{
timestamp: `${TODAY}T10:00:00.000Z`,
source: 'raw-source',
auth_index: authIndex,
tokens: {
input_tokens: 1000,
output_tokens: 500,
reasoning_tokens: 0,
cached_tokens: 0,
total_tokens: 1500,
},
failed: false,
},
],
},
},
},
},
},
};
}
it('persists accountId into snapshot when auth files are provided', async () => {
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
];
await runWithScopedConfigDir(ccsDir2, async () => {
await syncCliproxyUsage(
() => Promise.resolve(buildResponseWithAuthIndex(0)),
() => Promise.resolve(authFiles)
);
});
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
details: Array<{ accountId?: string }>;
};
expect(snapshot.details[0].accountId).toBe('alice@example.com');
});
it('falls back gracefully when auth-files fetch returns null (no throw, no accountId)', async () => {
await runWithScopedConfigDir(ccsDir2, async () => {
await syncCliproxyUsage(
() => Promise.resolve(buildResponseWithAuthIndex(0)),
() => Promise.resolve(null)
);
});
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
expect(fs.existsSync(snapshotPath)).toBe(true);
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
details: Array<{ accountId?: string }>;
};
// accountId must be absent (not populated) when auth files unavailable
expect(snapshot.details[0].accountId).toBeUndefined();
});
it('falls back gracefully when auth-files fetch throws (no throw, no accountId)', async () => {
await runWithScopedConfigDir(ccsDir2, async () => {
await syncCliproxyUsage(
() => Promise.resolve(buildResponseWithAuthIndex(0)),
() => Promise.reject(new Error('network error'))
);
});
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
expect(fs.existsSync(snapshotPath)).toBe(true);
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
details: Array<{ accountId?: string }>;
};
expect(snapshot.details[0].accountId).toBeUndefined();
});
});
@@ -0,0 +1,235 @@
/**
* Phase 2: tier-lock endpoint tests
*
* POST /api/accounts/tier-lock
* body: { tier: string|null, provider?: string }
*
* Tests:
* - sets tier_lock in config and returns it
* - clears tier_lock when tier is null
* - rejects missing provider
* - rejects invalid provider
* - rejects unknown tier strings (typos must 400, not silently persist)
* - persists across config reads (config write path) as per-provider map
* - locking one provider does NOT affect another provider's lock entry
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
async function postJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/accounts/tier-lock', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalCcsUnified: string | undefined;
beforeEach(async () => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tier-lock-routes-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
// No account-manager mock: the empty temp CCS_HOME yields zero CLIProxy
// accounts naturally, and the tier-lock endpoint only validates the
// provider id and writes config. Avoiding mock.module here is deliberate —
// Bun's mock.restore() does NOT unwind mock.module, so a global account
// manager mock would leak into later test files in the same process.
const { default: accountRoutes } = await import(
`../../../src/web-server/routes/account-routes?tier-lock-test=${Date.now()}-${Math.random()}`
);
const app = express();
app.use(express.json());
app.use('/api/accounts', accountRoutes);
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));
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('sets tier_lock to a named tier and returns it', async () => {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'agy',
tier: 'ultra',
});
expect(res.status).toBe(200);
const body = (await res.json()) as { provider: string; tier_lock: string | null };
expect(body.provider).toBe('agy');
expect(body.tier_lock).toBe('ultra');
});
it('clears tier_lock when tier is null', async () => {
// Set first
await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'pro' });
// Then clear
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'agy',
tier: null,
});
expect(res.status).toBe(200);
const body = (await res.json()) as { provider: string; tier_lock: string | null };
expect(body.provider).toBe('agy');
expect(body.tier_lock).toBeNull();
});
it('rejects missing provider with 400', async () => {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', { tier: 'pro' });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/provider/i);
});
it('rejects invalid provider with 400', async () => {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'notreal',
tier: 'pro',
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/provider/i);
});
it('rejects missing tier field (no tier key at all) with 400', async () => {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy' });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/tier/i);
});
it('rejects non-string non-null tier with 400', async () => {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'agy',
tier: 42,
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/tier/i);
});
it('rejects unknown tier string (typo) with 400', async () => {
// "Ultra" (capital U) is not a valid AccountTier — must 400, not silently persist
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'agy',
tier: 'Ultra',
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/tier/i);
});
it('rejects "premium" (unknown tier) with 400', async () => {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'agy',
tier: 'premium',
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/tier/i);
});
it('rejects a non-managed provider with 400 (fix #8)', async () => {
// Providers like 'kiro' or unknown CLIProxy providers accept isCLIProxyProvider()
// but quota-manager does not enforce tier_lock for them. Persisting a lock entry
// would silently have no effect, misleading the caller. Must 400.
// Note: 'kiro' passes isCLIProxyProvider but is NOT in MANAGED_QUOTA_PROVIDERS.
// We test with a provider that is valid (passes CLIProxy check) but not managed.
// In practice this means any provider added to CLIProxy that is not in
// MANAGED_QUOTA_PROVIDERS = ['agy', 'claude', 'codex', 'gemini', 'ghcp'].
// We use a string that is a known CLIProxy provider but not managed.
// Since the set of CLIProxy providers is dynamic we test the error message content.
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider: 'kiro',
tier: 'pro',
});
// If kiro is a CLIProxy provider but not managed: expect 400
// If kiro is not a CLIProxy provider at all: also 400 (from isCLIProxyProvider check)
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/provider/i);
});
it('accepts all five managed-quota providers (agy, claude, codex, gemini, ghcp)', async () => {
const managedProviders = ['agy', 'claude', 'codex', 'gemini', 'ghcp'];
for (const provider of managedProviders) {
const res = await postJson(baseUrl, '/api/accounts/tier-lock', {
provider,
tier: 'pro',
});
// All managed providers should succeed (200)
expect(res.status).toBe(200);
const body = (await res.json()) as { provider: string; tier_lock: string | null };
expect(body.provider).toBe(provider);
}
});
it('persists tier_lock as per-provider map in the config', async () => {
await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'pro' });
// Read the config directly to confirm per-provider persistence
const { loadOrCreateUnifiedConfig } = await import(
`../../../src/config/config-loader-facade?persist-check=${Date.now()}`
);
const config = loadOrCreateUnifiedConfig();
const tierLock = config.quota_management?.manual?.tier_lock;
// Must be a map, not a bare string
expect(typeof tierLock).toBe('object');
expect((tierLock as Record<string, string | null>)['agy']).toBe('pro');
});
it('tier_lock is persisted as a per-provider map entry (not a global string)', async () => {
// Lock agy to ultra — verify the map structure has only agy set
await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'ultra' });
const { loadOrCreateUnifiedConfig } = await import(
`../../../src/config/config-loader-facade?per-provider-map-check=${Date.now()}`
);
const config = loadOrCreateUnifiedConfig();
const tierLock = config.quota_management?.manual?.tier_lock;
// Must be a map, not a bare string
expect(typeof tierLock).toBe('object');
expect(tierLock).not.toBeNull();
// The agy entry must be set
expect((tierLock as Record<string, string | null>)['agy']).toBe('ultra');
// Providers not explicitly locked must not appear in the map
expect((tierLock as Record<string, string | null>)['codex'] ?? null).toBeNull();
expect((tierLock as Record<string, string | null>)['gemini'] ?? null).toBeNull();
});
});
@@ -0,0 +1,405 @@
/**
* Phase 1A: Account Attribution Tests
*
* TDD tests for auth_index → accountId mapping through the usage pipeline.
* Covers:
* - auth_index maps to account email via accountMap in transformer
* - extractCliproxyUsageHistoryDetails carries accountId
* - getTodayCostByAccount returns correct per-account totals
* - Profile-based aggregation unaffected (backward compat)
*/
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
import type { CliproxyUsageApiResponse, CliproxyManagementAuthFile } from '../../../../src/cliproxy/services/stats-fetcher';
// ============================================================================
// HELPERS & FIXTURES
// ============================================================================
const TODAY = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
function makeResponse(entries: Array<{
provider: string;
model: string;
auth_index: number;
source: string;
timestamp: string;
input: number;
output: number;
failed?: boolean;
}>): CliproxyUsageApiResponse {
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
for (const e of entries) {
if (!apis.apis![e.provider]) {
apis.apis![e.provider] = { models: {} };
}
const models = apis.apis![e.provider].models!;
if (!models[e.model]) {
models[e.model] = { details: [] };
}
models[e.model].details!.push({
timestamp: e.timestamp,
source: e.source,
auth_index: e.auth_index,
tokens: {
input_tokens: e.input,
output_tokens: e.output,
reasoning_tokens: 0,
cached_tokens: 0,
total_tokens: e.input + e.output,
},
failed: e.failed ?? false,
});
}
return { usage: apis };
}
const twoAccountResponse = makeResponse([
{
provider: 'anthropic',
model: 'claude-sonnet-4-5',
auth_index: 0,
source: 'old-source-a',
timestamp: `${TODAY}T10:00:00.000Z`,
input: 1000,
output: 500,
},
{
provider: 'anthropic',
model: 'claude-sonnet-4-5',
auth_index: 1,
source: 'old-source-b',
timestamp: `${TODAY}T11:00:00.000Z`,
input: 2000,
output: 800,
},
// auth_index 0 again — same account, second request.
// output=201 (not 200) ensures alice's two-request total ($0.018025) strictly
// exceeds bob's single-request total ($0.018), avoiding a floating-point tie.
{
provider: 'anthropic',
model: 'claude-opus-4-5',
auth_index: 0,
source: 'old-source-a',
timestamp: `${TODAY}T12:00:00.000Z`,
input: 500,
output: 201,
},
]);
// Fix #7/#13/#15: buildAuthIndexToAccountMap stores String(auth_index) keys only.
// The map must use string keys so accountMap.get(String(detail.auth_index)) resolves correctly.
const authFileMap: Map<number | string, string> = new Map([
['0', 'alice@example.com'],
['1', 'bob@example.com'],
]);
// ============================================================================
// TRANSFORMER: extractCliproxyUsageHistoryDetails with accountMap
// ============================================================================
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
it('populates accountId from accountMap when auth_index is present', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
const aliceDetails = details.filter((d) => d.accountId === 'alice@example.com');
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice
expect(bobDetails).toHaveLength(1); // auth_index 1 appears once
});
it('leaves accountId undefined when auth_index is not in accountMap (no source fallback)', async () => {
// Fix #7/#13/#15: detail.source is a CLIProxy source label, not an email.
// Using it as a cost key caused mis-attribution. When auth_index is absent from the
// map, accountId must be undefined so getTodayCostByAccount buckets under 'unknown'.
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
// Use string key matching buildAuthIndexToAccountMap's String(auth_index) output
const partialMap: Map<number | string, string> = new Map([['0', 'alice@example.com']]);
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, partialMap);
// auth_index 1 (bob) is not in the partial map — must be undefined, not 'old-source-b'
const bobDetail = details.find((d) => d.accountId === undefined && !d.accountId);
// There should be exactly one detail with no accountId (bob's request)
const unmappedDetails = details.filter((d) => d.accountId === undefined);
expect(unmappedDetails).toHaveLength(1);
// Confirm it is NOT keyed under the source string
const sourceFallback = details.find((d) => d.accountId === 'old-source-b');
expect(sourceFallback).toBeUndefined();
void bobDetail; // suppress lint
});
it('does not include accountId when no accountMap is provided (backward compat)', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
for (const detail of details) {
expect(detail.accountId).toBeUndefined();
}
});
it('does not expose source or auth_index on returned history details', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
for (const detail of details) {
expect((detail as Record<string, unknown>).source).toBeUndefined();
expect((detail as Record<string, unknown>).auth_index).toBeUndefined();
}
});
});
// ============================================================================
// TRANSFORMER: CliproxyUsageHistoryDetail type has optional accountId
// ============================================================================
describe('CliproxyUsageHistoryDetail type', () => {
it('allows accountId as optional string field', async () => {
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const withAccount = normalizeCliproxyUsageHistoryDetail({
model: 'claude-sonnet-4-5',
timestamp: `${TODAY}T10:00:00.000Z`,
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
requestCount: 1,
cost: 0.01,
failed: false,
accountId: 'alice@example.com',
});
expect(withAccount).not.toBeNull();
expect(withAccount?.accountId).toBe('alice@example.com');
});
it('normalizes detail without accountId (remains undefined)', async () => {
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const noAccount = normalizeCliproxyUsageHistoryDetail({
model: 'claude-sonnet-4-5',
timestamp: `${TODAY}T10:00:00.000Z`,
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
requestCount: 1,
cost: 0.01,
failed: false,
});
expect(noAccount).not.toBeNull();
expect(noAccount?.accountId).toBeUndefined();
});
});
// ============================================================================
// DATA-AGGREGATOR: getTodayCostByAccount
// ============================================================================
describe('getTodayCostByAccount', () => {
it('returns per-account cost totals for today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const details = [];
// Simulate alice's two requests today
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
const result = getTodayCostByAccount(todayDetails, TODAY);
// Alice has auth_index 0: two requests (claude-sonnet + claude-opus)
// Bob has auth_index 1: one request (claude-sonnet)
expect(typeof result['alice@example.com']).toBe('number');
expect(typeof result['bob@example.com']).toBe('number');
expect(result['alice@example.com']).toBeGreaterThan(0);
expect(result['bob@example.com']).toBeGreaterThan(0);
// Alice has two requests across two models; Bob has one request with more tokens.
// Alice's two-request total ($0.018025) exceeds Bob's single-request total ($0.018).
expect(result['alice@example.com']).toBeGreaterThan(result['bob@example.com']);
});
it('returns empty object when no details exist for today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const result = getTodayCostByAccount([], TODAY);
expect(result).toEqual({});
});
it('filters out details from days other than today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const yesterdayResponse = makeResponse([
{
provider: 'anthropic',
model: 'claude-sonnet-4-5',
auth_index: 0,
source: 'old-source-a',
timestamp: '2020-01-01T10:00:00.000Z', // definitely not today
input: 9999,
output: 9999,
},
]);
const details = extractCliproxyUsageHistoryDetails(yesterdayResponse, authFileMap);
const result = getTodayCostByAccount(details, TODAY);
expect(Object.keys(result)).toHaveLength(0);
});
it('accumulates costs across multiple details for the same account', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
// alice appears for two models — verify aggregated correctly
const result = getTodayCostByAccount(details, TODAY);
const aliceCostFromDetails = details
.filter((d) => d.accountId === 'alice@example.com')
.reduce((acc, d) => acc + d.cost, 0);
expect(result['alice@example.com']).toBeCloseTo(aliceCostFromDetails, 10);
});
it('details without accountId are grouped under the "unknown" key', async () => {
// Fix #7/#13/#15: when no accountMap is provided, accountId is undefined on all details.
// getTodayCostByAccount buckets these under 'unknown' — not under detail.source.
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
// no accountMap — accountId will be undefined on all details
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
const result = getTodayCostByAccount(details, TODAY);
// All costs should be accumulated under the literal key 'unknown'
expect(typeof result['unknown']).toBe('number');
expect(result['unknown']).toBeGreaterThan(0);
// No source-string keys should appear in the result
expect(result['old-source-a']).toBeUndefined();
expect(result['old-source-b']).toBeUndefined();
});
});
// ============================================================================
// BACKWARD COMPATIBILITY: existing profile aggregation still works
// ============================================================================
describe('backward compatibility: profile-based aggregation unaffected', () => {
it('transformCliproxyToDailyUsage works without accountMap', async () => {
const { transformCliproxyToDailyUsage } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const daily = transformCliproxyToDailyUsage(twoAccountResponse);
expect(daily.length).toBeGreaterThan(0);
expect(daily[0].source).toBe('cliproxy');
expect(daily[0].modelBreakdowns.length).toBeGreaterThan(0);
});
it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => {
const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
expect(Array.isArray(daily)).toBe(true);
expect(Array.isArray(hourly)).toBe(true);
expect(Array.isArray(monthly)).toBe(true);
if (daily.length > 0) {
expect(typeof daily[0].date).toBe('string');
expect(typeof daily[0].totalCost).toBe('number');
}
});
it('DailyUsage shape includes optional accountId field', async () => {
// Type-level test: verify DailyUsage can carry accountId without breaking shape
type DailyUsageShape = { date: string; source: string; totalCost: number; accountId?: string };
const sample: DailyUsageShape = {
date: TODAY,
source: 'cliproxy',
totalCost: 1.5,
accountId: 'alice@example.com',
};
const noAccount: DailyUsageShape = { date: TODAY, source: 'cliproxy', totalCost: 0.5 };
expect(sample.accountId).toBe('alice@example.com');
expect(noAccount.accountId).toBeUndefined();
});
});
// ============================================================================
// STATS-FETCHER: buildAuthIndexToAccountMap
// ============================================================================
describe('buildAuthIndexToAccountMap', () => {
it('builds map from auth files with auth_index and email', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
{ auth_index: 1, provider: 'anthropic', email: 'bob@example.com' },
{ auth_index: 2, provider: 'gemini', email: 'carol@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.get('0')).toBe('alice@example.com');
expect(map.get('1')).toBe('bob@example.com');
expect(map.get('2')).toBe('carol@example.com');
});
it('skips entries missing auth_index', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
{ auth_index: 3, provider: 'anthropic', email: 'alice@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.size).toBe(1);
expect(map.get('3')).toBe('alice@example.com');
});
it('skips entries missing email', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 4, provider: 'anthropic' }, // no email
{ auth_index: 5, provider: 'anthropic', email: 'dave@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.size).toBe(1);
expect(map.get('5')).toBe('dave@example.com');
});
it('returns empty map for empty auth files array', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const map = buildAuthIndexToAccountMap([]);
expect(map.size).toBe(0);
});
it('handles numeric and string auth_index keys consistently', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },
{ auth_index: '8', provider: 'anthropic', email: 'bob@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.get('7')).toBe('alice@example.com');
expect(map.get('8')).toBe('bob@example.com');
});
});