diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 1b6b8108..cdff9ac7 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2026-04-07 +Last Updated: 2026-04-26 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, and native Codex/Droid usage collectors. ## Repository Structure @@ -212,10 +212,12 @@ src/ │ └── [more routes...] ├── health/ # Health check system │ └── index.ts - ├── usage/ # Usage analytics module + ├── usage/ # Usage analytics module (default Claude, CCS instances, native Codex/Droid, CLIProxy snapshots) │ ├── index.ts │ ├── handlers.ts # Request handlers (633 lines) │ ├── aggregator.ts # Data aggregation (538 lines) + │ ├── codex-native-usage-collector.ts # Native Codex rollout JSONL collector + │ ├── droid-native-usage-collector.ts # Native Droid SQLite collector │ └── data-aggregator.ts ├── services/ # Shared services │ └── index.ts diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 34429df2..97913930 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -7,7 +7,13 @@ import * as fs from 'fs'; import * as path from 'path'; -import { loadAllUsageData } from './data-aggregator'; +import { + aggregateDailyUsage, + aggregateHourlyUsage, + aggregateMonthlyUsage, + aggregateSessionUsage, + loadAllUsageData, +} from './data-aggregator'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { readDiskCache, @@ -26,6 +32,8 @@ import { stopCliproxySync, syncCliproxyUsage, } from './cliproxy-usage-syncer'; +import { scanCodexNativeUsageEntries } from './codex-native-usage-collector'; +import { scanDroidNativeUsageEntries } from './droid-native-usage-collector'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -383,6 +391,32 @@ async function refreshFromSource(): Promise<{ console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); } + try { + const codexEntries = await scanCodexNativeUsageEntries(); + if (codexEntries.length > 0) { + allDailySources.push(aggregateDailyUsage(codexEntries, 'codex-native')); + allHourlySources.push(aggregateHourlyUsage(codexEntries, 'codex-native')); + allMonthlySources.push(aggregateMonthlyUsage(codexEntries, 'codex-native')); + allSessionSources.push(aggregateSessionUsage(codexEntries, 'codex-native')); + console.log(info(`Included native Codex usage data (${codexEntries.length} event(s))`)); + } + } catch (err) { + console.error(fail(`Failed to load native Codex usage data: ${err}`)); + } + + try { + const droidEntries = await scanDroidNativeUsageEntries(); + if (droidEntries.length > 0) { + allDailySources.push(aggregateDailyUsage(droidEntries, 'droid-native')); + allHourlySources.push(aggregateHourlyUsage(droidEntries, 'droid-native')); + allMonthlySources.push(aggregateMonthlyUsage(droidEntries, 'droid-native')); + allSessionSources.push(aggregateSessionUsage(droidEntries, 'droid-native')); + console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`)); + } + } catch (err) { + console.error(fail(`Failed to load native Droid usage data: ${err}`)); + } + // Load CLIProxy usage data (from local snapshot cache) try { const cliproxyData = await loadCachedCliproxyData(); diff --git a/src/web-server/usage/codex-native-usage-collector.ts b/src/web-server/usage/codex-native-usage-collector.ts new file mode 100644 index 00000000..81457a17 --- /dev/null +++ b/src/web-server/usage/codex-native-usage-collector.ts @@ -0,0 +1,194 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import type { RawUsageEntry } from '../jsonl-parser'; +import { resolveCodexConfigPaths } from '../services/codex-dashboard-service'; + +interface CodexNativeUsageCollectorOptions { + env?: NodeJS.ProcessEnv; + homeDir?: string; + includeCliproxySessions?: boolean; +} + +interface CodexTokenSnapshot { + inputTokens: number; + cacheReadTokens: number; + outputTokens: number; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function hasUsage(snapshot: CodexTokenSnapshot): boolean { + return snapshot.inputTokens > 0 || snapshot.cacheReadTokens > 0 || snapshot.outputTokens > 0; +} + +function normalizeTokenSnapshot(value: unknown): CodexTokenSnapshot | null { + if (!isObject(value)) return null; + return { + inputTokens: asNumber(value.input_tokens), + cacheReadTokens: asNumber(value.cached_input_tokens), + outputTokens: asNumber(value.output_tokens) + asNumber(value.reasoning_output_tokens), + }; +} + +function subtractSnapshots( + current: CodexTokenSnapshot, + previous: CodexTokenSnapshot +): CodexTokenSnapshot { + return { + inputTokens: Math.max(0, current.inputTokens - previous.inputTokens), + cacheReadTokens: Math.max(0, current.cacheReadTokens - previous.cacheReadTokens), + outputTokens: Math.max(0, current.outputTokens - previous.outputTokens), + }; +} + +function snapshotsEqual(left: CodexTokenSnapshot, right: CodexTokenSnapshot): boolean { + return ( + left.inputTokens === right.inputTokens && + left.cacheReadTokens === right.cacheReadTokens && + left.outputTokens === right.outputTokens + ); +} + +async function collectRolloutFiles(dir: string): Promise { + if (!fs.existsSync(dir)) return []; + + const files: string[] = []; + for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectRolloutFiles(entryPath))); + continue; + } + if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { + files.push(entryPath); + } + } + + return files.sort(); +} + +async function parseRolloutFile( + filePath: string, + includeCliproxySessions: boolean +): Promise { + const entries: RawUsageEntry[] = []; + let sessionId = ''; + let projectPath = ''; + let version: string | undefined; + let modelProvider: string | undefined; + let model = 'unknown-codex-model'; + let previousTotal: CodexTokenSnapshot | null = null; + + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const reader = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + try { + for await (const line of reader) { + if (!line.trim()) continue; + + let parsed: Record; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (parsed.type === 'session_meta' && isObject(parsed.payload)) { + sessionId = asString(parsed.payload.id) ?? sessionId; + projectPath = asString(parsed.payload.cwd) ?? projectPath; + version = asString(parsed.payload.cli_version) ?? version; + modelProvider = asString(parsed.payload.model_provider) ?? modelProvider; + continue; + } + + if (parsed.type === 'turn_context' && isObject(parsed.payload)) { + model = asString(parsed.payload.model) ?? model; + projectPath = asString(parsed.payload.cwd) ?? projectPath; + continue; + } + + if ( + parsed.type !== 'event_msg' || + !isObject(parsed.payload) || + parsed.payload.type !== 'token_count' || + !sessionId || + (modelProvider === 'cliproxy' && !includeCliproxySessions) + ) { + continue; + } + + const totalSnapshot = normalizeTokenSnapshot( + parsed.payload.info && isObject(parsed.payload.info) + ? (parsed.payload.info as Record).total_token_usage + : null + ); + const lastSnapshot = normalizeTokenSnapshot( + parsed.payload.info && isObject(parsed.payload.info) + ? (parsed.payload.info as Record).last_token_usage + : null + ); + + if (!totalSnapshot) continue; + if (previousTotal && snapshotsEqual(totalSnapshot, previousTotal)) continue; + + const delta = + previousTotal === null + ? lastSnapshot && hasUsage(lastSnapshot) + ? lastSnapshot + : totalSnapshot + : subtractSnapshots(totalSnapshot, previousTotal); + previousTotal = totalSnapshot; + + if (!hasUsage(delta)) continue; + + const timestamp = asString(parsed.timestamp); + if (!timestamp) continue; + + entries.push({ + inputTokens: delta.inputTokens, + outputTokens: delta.outputTokens, + cacheCreationTokens: 0, + cacheReadTokens: delta.cacheReadTokens, + model, + sessionId, + timestamp, + projectPath: projectPath || '/', + version, + target: 'codex', + }); + } + } finally { + reader.close(); + stream.destroy(); + } + + return entries; +} + +export async function scanCodexNativeUsageEntries( + options: CodexNativeUsageCollectorOptions = {} +): Promise { + const { baseDir } = resolveCodexConfigPaths({ + env: options.env, + homeDir: options.homeDir, + }); + const rolloutFiles = await collectRolloutFiles(path.join(baseDir, 'sessions')); + const entries: RawUsageEntry[] = []; + + for (const filePath of rolloutFiles) { + entries.push(...(await parseRolloutFile(filePath, options.includeCliproxySessions === true))); + } + + return entries; +} diff --git a/src/web-server/usage/droid-native-usage-collector.ts b/src/web-server/usage/droid-native-usage-collector.ts new file mode 100644 index 00000000..b3b9f074 --- /dev/null +++ b/src/web-server/usage/droid-native-usage-collector.ts @@ -0,0 +1,194 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { RawUsageEntry } from '../jsonl-parser'; +import { resolveDroidConfigPaths } from '../services/droid-dashboard-service'; +import { querySqliteJson } from './sqlite-cli'; + +export type DroidSqliteQuery = typeof querySqliteJson; + +interface DroidNativeUsageCollectorOptions { + env?: NodeJS.ProcessEnv; + homeDir?: string; + includeCcsManagedSessions?: boolean; + querySqliteJson?: DroidSqliteQuery; +} + +interface DroidSessionMetadata { + model: string; + projectPath: string; + rawSelector: string; + version?: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; +} + +function isCcsManagedSelector(selector: string): boolean { + return selector.startsWith('custom:CCS-') || selector.startsWith('custom:ccs-'); +} + +function buildSelectorAlias(displayName: string, index: number): string { + return `${displayName.trim().replace(/\s+/g, '-')}-${index}`; +} + +function normalizeCustomModels(settings: Record): Array> { + const raw = settings.customModels ?? settings.custom_models; + if (Array.isArray(raw)) { + return raw.filter((entry): entry is Record => isObject(entry)); + } + if (isObject(raw)) { + return Object.values(raw).filter((entry): entry is Record => isObject(entry)); + } + return []; +} + +function buildCustomModelMap(settingsPath: string): Map { + const selectors = new Map(); + if (!fs.existsSync(settingsPath)) return selectors; + + try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (!isObject(parsed)) return selectors; + + for (const [index, entry] of normalizeCustomModels(parsed).entries()) { + const displayName = asString(entry.displayName ?? entry.model_display_name); + const model = asString(entry.model); + if (!displayName || !model) continue; + selectors.set(`custom:${buildSelectorAlias(displayName, index)}`, model); + } + } catch { + return selectors; + } + + return selectors; +} + +function collectSessionFiles(dir: string, suffix: string, results: string[] = []): string[] { + if (!fs.existsSync(dir)) return results; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectSessionFiles(entryPath, suffix, results); + continue; + } + if (entry.isFile() && entry.name.endsWith(suffix)) { + results.push(entryPath); + } + } + + return results; +} + +function readSessionStartMetadata( + filePath: string +): { projectPath: string; version?: string } | null { + try { + const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0]; + const parsed = JSON.parse(firstLine); + if (parsed?.type !== 'session_start') return null; + return { + projectPath: asString(parsed.cwd) ?? '/', + version: + typeof parsed.version === 'number' + ? String(parsed.version) + : (asString(parsed.version) ?? undefined), + }; + } catch { + return null; + } +} + +function loadSessionMetadata(factoryDir: string): Map { + const metadata = new Map(); + const selectorMap = buildCustomModelMap(path.join(factoryDir, 'settings.json')); + const sessionsDir = path.join(factoryDir, 'sessions'); + + for (const settingsPath of collectSessionFiles(sessionsDir, '.settings.json')) { + const sessionId = path.basename(settingsPath, '.settings.json'); + const jsonlPath = settingsPath.replace(/\.settings\.json$/, '.jsonl'); + const start = readSessionStartMetadata(jsonlPath); + if (!start) continue; + + try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const rawSelector = asString(parsed?.model) ?? 'unknown-droid-model'; + metadata.set(sessionId, { + model: selectorMap.get(rawSelector) ?? rawSelector, + projectPath: start.projectPath, + rawSelector, + version: start.version, + }); + } catch { + continue; + } + } + + return metadata; +} + +export async function scanDroidNativeUsageEntries( + options: DroidNativeUsageCollectorOptions = {} +): Promise { + const query = options.querySqliteJson ?? querySqliteJson; + const { settingsPath } = resolveDroidConfigPaths({ + env: options.env, + homeDir: options.homeDir, + }); + const factoryDir = path.dirname(settingsPath); + const costsDbPath = path.join(factoryDir, 'costs.db'); + if (!fs.existsSync(costsDbPath)) return []; + + const rows = + (await query( + costsDbPath, + 'SELECT session_id, timestamp, input_tokens, output_tokens FROM costs ORDER BY timestamp ASC;' + )) ?? []; + if (!rows.length) return []; + + const metadata = loadSessionMetadata(factoryDir); + const entries: RawUsageEntry[] = []; + + for (const row of rows) { + if (!isObject(row)) continue; + const sessionId = asString(row.session_id); + const timestamp = asString(row.timestamp); + const inputTokens = asNumber(row.input_tokens); + const outputTokens = asNumber(row.output_tokens); + if (!sessionId || !timestamp || inputTokens === null || outputTokens === null) continue; + + const session = metadata.get(sessionId); + if ( + session && + !options.includeCcsManagedSessions && + isCcsManagedSelector(session.rawSelector) + ) { + continue; + } + + entries.push({ + inputTokens, + outputTokens, + cacheCreationTokens: 0, + cacheReadTokens: 0, + model: session?.model ?? 'unknown-droid-model', + sessionId, + timestamp, + projectPath: session?.projectPath ?? '/', + version: session?.version, + target: 'droid', + }); + } + + return entries; +} diff --git a/src/web-server/usage/sqlite-cli.ts b/src/web-server/usage/sqlite-cli.ts new file mode 100644 index 00000000..d4f9121d --- /dev/null +++ b/src/web-server/usage/sqlite-cli.ts @@ -0,0 +1,41 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const SQLITE_JSON_MAX_BUFFER = 10 * 1024 * 1024; + +export type SqliteJsonRow = Record; + +function isCommandMissing(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const nodeError = error as Error & { code?: string }; + return nodeError.code === 'ENOENT' || /not found/i.test(nodeError.message); +} + +export async function querySqliteJson( + dbPath: string, + sql: string +): Promise { + if (!fs.existsSync(dbPath)) { + return []; + } + + try { + const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], { + maxBuffer: SQLITE_JSON_MAX_BUFFER, + }); + const trimmed = stdout.trim(); + if (!trimmed) { + return []; + } + + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? (parsed as SqliteJsonRow[]) : []; + } catch (error) { + if (isCommandMissing(error)) { + return null; + } + return []; + } +} diff --git a/tests/unit/web-server/codex-native-usage-collector.test.ts b/tests/unit/web-server/codex-native-usage-collector.test.ts new file mode 100644 index 00000000..bbe8610d --- /dev/null +++ b/tests/unit/web-server/codex-native-usage-collector.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { scanCodexNativeUsageEntries } from '../../../src/web-server/usage/codex-native-usage-collector'; + +function writeCodexRollout( + baseDir: string, + options: { + sessionId?: string; + modelProvider?: string; + model?: string; + cwd?: string; + } = {} +): void { + const sessionId = options.sessionId ?? 'codex-session-1'; + const rolloutDir = path.join(baseDir, 'sessions', '2026', '03', '02'); + const rolloutPath = path.join(rolloutDir, `rollout-${sessionId}.jsonl`); + + const lines = [ + JSON.stringify({ + timestamp: '2026-03-02T10:00:00.000Z', + type: 'session_meta', + payload: { + id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + cwd: options.cwd ?? '/tmp/codex-project', + cli_version: '0.126.0', + source: 'cli', + model_provider: options.modelProvider ?? 'openai', + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:00:01.000Z', + type: 'turn_context', + payload: { + turn_id: 'turn-1', + cwd: options.cwd ?? '/tmp/codex-project', + model: options.model ?? 'gpt-5', + effort: 'high', + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: null, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:05:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + model_context_window: 200000, + }, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:05:30.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + model_context_window: 200000, + }, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:10:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 150, + cached_input_tokens: 30, + output_tokens: 10, + reasoning_output_tokens: 3, + total_tokens: 193, + }, + last_token_usage: { + input_tokens: 50, + cached_input_tokens: 10, + output_tokens: 5, + reasoning_output_tokens: 1, + total_tokens: 66, + }, + model_context_window: 200000, + }, + }, + }), + ]; + + fs.mkdirSync(rolloutDir, { recursive: true }); + fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8'); +} + +describe('codex native usage collector', () => { + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-native-')); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('parses token_count events into raw usage entries and suppresses duplicates', async () => { + writeCodexRollout(tempRoot); + + const entries = await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + sessionId: 'codex-session-1', + projectPath: '/tmp/codex-project', + model: 'gpt-5', + version: '0.126.0', + target: 'codex', + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 7, + }); + expect(entries[1]).toMatchObject({ + inputTokens: 50, + cacheReadTokens: 10, + outputTokens: 6, + }); + }); + + it('skips cliproxy-backed codex sessions by default to avoid double counting', async () => { + writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' }); + + const entries = await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + }); + + expect(entries).toHaveLength(0); + }); +}); diff --git a/tests/unit/web-server/droid-native-usage-collector.test.ts b/tests/unit/web-server/droid-native-usage-collector.test.ts new file mode 100644 index 00000000..00370521 --- /dev/null +++ b/tests/unit/web-server/droid-native-usage-collector.test.ts @@ -0,0 +1,134 @@ +import { 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 { + scanDroidNativeUsageEntries, + type DroidSqliteQuery, +} from '../../../src/web-server/usage/droid-native-usage-collector'; + +function writeDroidSessionFixture( + tempRoot: string, + options: { + sessionId?: string; + selector?: string; + cwd?: string; + } = {} +): string { + const sessionId = options.sessionId ?? 'droid-session-1'; + const sessionDir = path.join(tempRoot, '.factory', 'sessions', 'demo-project'); + fs.mkdirSync(sessionDir, { recursive: true }); + + fs.writeFileSync( + path.join(sessionDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + type: 'session_start', + id: sessionId, + version: 2, + cwd: options.cwd ?? '/tmp/droid-project', + })}\n`, + 'utf8' + ); + + fs.writeFileSync( + path.join(sessionDir, `${sessionId}.settings.json`), + JSON.stringify({ + model: options.selector ?? 'custom:Demo-0', + providerLock: 'openai', + }), + 'utf8' + ); + + return sessionId; +} + +function writeDroidGlobalSettings(tempRoot: string): void { + const factoryDir = path.join(tempRoot, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + displayName: 'Demo', + model: 'gpt-4.1', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + }, + { + displayName: 'CCS codex', + model: 'gpt-5', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }), + 'utf8' + ); + fs.writeFileSync(path.join(factoryDir, 'costs.db'), '', 'utf8'); +} + +describe('droid native usage collector', () => { + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-native-')); + }); + + afterEach(() => { + mock.restore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('hydrates cost rows with session metadata and resolved custom model IDs', async () => { + writeDroidGlobalSettings(tempRoot); + const sessionId = writeDroidSessionFixture(tempRoot); + + const querySqliteJson: DroidSqliteQuery = async () => [ + { + session_id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + input_tokens: 120, + output_tokens: 30, + }, + ]; + + const entries = await scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + sessionId, + projectPath: '/tmp/droid-project', + model: 'gpt-4.1', + inputTokens: 120, + outputTokens: 30, + target: 'droid', + }); + }); + + it('skips CCS-managed selectors to avoid double counting CLIProxy-backed Droid runs', async () => { + writeDroidGlobalSettings(tempRoot); + const sessionId = writeDroidSessionFixture(tempRoot, { selector: 'custom:CCS-codex-1' }); + + const querySqliteJson: DroidSqliteQuery = async () => [ + { + session_id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + input_tokens: 120, + output_tokens: 30, + }, + ]; + + const entries = await scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }); + + expect(entries).toHaveLength(0); + }); +}); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts index 7f4525e5..55fb2739 100644 --- a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -6,10 +6,12 @@ import * as path from 'path'; let tempRoot = ''; let ccsDir = ''; let claudeDir = ''; +let codexDir = ''; let aggregator: typeof import('../../../src/web-server/usage/aggregator'); let originalCcsDir: string | undefined; let originalClaudeConfigDir: string | undefined; let originalCcsHome: string | undefined; +let originalCodexHome: string | undefined; function writeClaudeJsonlFixture(): void { const projectDir = path.join(claudeDir, 'projects', 'project-one'); @@ -153,13 +155,16 @@ beforeEach(async () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-agg-')); ccsDir = path.join(tempRoot, '.ccs'); claudeDir = path.join(tempRoot, '.claude'); + codexDir = path.join(tempRoot, '.codex'); originalCcsDir = process.env.CCS_DIR; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; originalCcsHome = process.env.CCS_HOME; + originalCodexHome = process.env.CODEX_HOME; process.env.CCS_DIR = ccsDir; process.env.CCS_HOME = tempRoot; process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; writeUnifiedConfigFixture(); writeClaudeJsonlFixture(); @@ -191,6 +196,12 @@ afterEach(() => { delete process.env.CCS_HOME; } + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts b/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts new file mode 100644 index 00000000..b8201e3a --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempRoot = ''; +let ccsDir = ''; +let claudeDir = ''; +let codexDir = ''; +let aggregator: typeof import('../../../src/web-server/usage/aggregator'); +let originalCcsDir: string | undefined; +let originalClaudeConfigDir: string | undefined; +let originalCcsHome: string | undefined; +let originalCodexHome: string | undefined; + +function writeUnifiedConfigFixture(): void { + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: 65534 +`; + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); +} + +function writeClaudeJsonlFixture(): void { + const projectDir = path.join(claudeDir, 'projects', 'project-one'); + fs.mkdirSync(projectDir, { recursive: true }); + const line = JSON.stringify({ + type: 'assistant', + sessionId: 'claude-session', + timestamp: '2026-03-02T10:00:00.000Z', + cwd: '/tmp/project', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 100, + output_tokens: 40, + }, + }, + }); + fs.writeFileSync(path.join(projectDir, 'usage.jsonl'), `${line}\n`, 'utf8'); +} + +function writeCliproxySnapshotFixture(): void { + const snapshotDir = path.join(ccsDir, 'cache', 'cliproxy-usage'); + fs.mkdirSync(snapshotDir, { recursive: true }); + fs.writeFileSync( + path.join(snapshotDir, 'latest.json'), + JSON.stringify({ + version: 3, + timestamp: Date.now(), + details: [ + { + model: 'gemini-2.5-pro', + timestamp: '2026-03-02T10:00:00.000Z', + source: 'account-a', + authIndex: '0', + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 5, + requestCount: 1, + cost: 0.2, + failed: false, + }, + ], + daily: [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + hourly: [ + { + hour: '2026-03-02 10:00', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + monthly: [ + { + month: '2026-03', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + }), + 'utf8' + ); +} + +function writeCodexFixture(modelProvider = 'openai'): void { + const rolloutDir = path.join(codexDir, 'sessions', '2026', '03', '02'); + fs.mkdirSync(rolloutDir, { recursive: true }); + const rolloutPath = path.join(rolloutDir, 'rollout-native-codex.jsonl'); + const lines = [ + JSON.stringify({ + timestamp: '2026-03-02T10:00:00.000Z', + type: 'session_meta', + payload: { + id: 'codex-session', + timestamp: '2026-03-02T10:00:00.000Z', + cwd: '/tmp/codex-project', + cli_version: '0.126.0', + source: 'cli', + model_provider: modelProvider, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:00:01.000Z', + type: 'turn_context', + payload: { + turn_id: 'turn-1', + cwd: '/tmp/codex-project', + model: 'gpt-5', + effort: 'high', + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:05:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 30, + cached_input_tokens: 5, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 38, + }, + last_token_usage: { + input_tokens: 30, + cached_input_tokens: 5, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 38, + }, + model_context_window: 200000, + }, + }, + }), + ]; + + fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8'); +} + +beforeEach(async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-native-agg-')); + ccsDir = path.join(tempRoot, '.ccs'); + claudeDir = path.join(tempRoot, '.claude'); + codexDir = path.join(tempRoot, '.codex-native'); + + originalCcsDir = process.env.CCS_DIR; + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCodexHome = process.env.CODEX_HOME; + process.env.CCS_DIR = ccsDir; + process.env.CCS_HOME = tempRoot; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; + + writeUnifiedConfigFixture(); + writeClaudeJsonlFixture(); + writeCliproxySnapshotFixture(); + writeCodexFixture(); + + aggregator = await import('../../../src/web-server/usage/aggregator'); + aggregator.clearUsageCache(); +}); + +afterEach(() => { + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + else delete process.env.CLAUDE_CONFIG_DIR; + + if (originalCodexHome !== undefined) process.env.CODEX_HOME = originalCodexHome; + else delete process.env.CODEX_HOME; + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('usage aggregator native runtime integration', () => { + it('merges native codex usage with claude and cliproxy analytics', async () => { + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0]).toMatchObject({ + date: '2026-03-02', + inputTokens: 180, + outputTokens: 53, + cacheReadTokens: 10, + }); + expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(daily[0].modelsUsed).toContain('gemini-2.5-pro'); + expect(daily[0].modelsUsed).toContain('gpt-5'); + }); + + it('ignores cliproxy-backed codex rollouts to avoid double counting', async () => { + writeCodexFixture('cliproxy'); + aggregator.clearUsageCache(); + + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0]).toMatchObject({ + inputTokens: 150, + outputTokens: 50, + cacheReadTokens: 5, + }); + expect(daily[0].modelsUsed).not.toContain('gpt-5'); + }); +}); diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts index 2e1b9c70..9b9fe47d 100644 --- a/tests/unit/web-server/usage-handlers-semantics.test.ts +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -26,10 +26,12 @@ interface MockResponse { let tempHome = ''; let claudeDir = ''; +let codexDir = ''; let handlers: HandlersModule; let aggregator: AggregatorModule; let originalCcsHome: string | undefined; let originalClaudeConfigDir: string | undefined; +let originalCodexHome: string | undefined; function writeUnifiedConfigFixture(): void { const yaml = `version: 2 @@ -103,11 +105,14 @@ function createMockResponse(): MockResponse { beforeEach(async () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-')); claudeDir = path.join(tempHome, '.claude'); + codexDir = path.join(tempHome, '.codex'); originalCcsHome = process.env.CCS_HOME; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCodexHome = process.env.CODEX_HOME; process.env.CCS_HOME = tempHome; process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; writeUnifiedConfigFixture(); @@ -133,6 +138,12 @@ afterEach(() => { delete process.env.CLAUDE_CONFIG_DIR; } + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); });