fix(analytics): add native codex and droid collectors

This commit is contained in:
Tam Nhu Tran
2026-04-26 18:13:33 -04:00
parent 01fcd77573
commit fe7cc6f9ba
10 changed files with 1084 additions and 4 deletions
+5 -3
View File
@@ -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
+35 -1
View File
@@ -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();
@@ -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<string, unknown> {
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<string[]> {
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<RawUsageEntry[]> {
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<string, unknown>;
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<string, unknown>).total_token_usage
: null
);
const lastSnapshot = normalizeTokenSnapshot(
parsed.payload.info && isObject(parsed.payload.info)
? (parsed.payload.info as Record<string, unknown>).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<RawUsageEntry[]> {
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;
}
@@ -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<string, unknown> {
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<string, unknown>): Array<Record<string, unknown>> {
const raw = settings.customModels ?? settings.custom_models;
if (Array.isArray(raw)) {
return raw.filter((entry): entry is Record<string, unknown> => isObject(entry));
}
if (isObject(raw)) {
return Object.values(raw).filter((entry): entry is Record<string, unknown> => isObject(entry));
}
return [];
}
function buildCustomModelMap(settingsPath: string): Map<string, string> {
const selectors = new Map<string, string>();
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<string, DroidSessionMetadata> {
const metadata = new Map<string, DroidSessionMetadata>();
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<RawUsageEntry[]> {
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;
}
+41
View File
@@ -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<string, unknown>;
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<SqliteJsonRow[] | null> {
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 [];
}
}
@@ -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);
});
});
@@ -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);
});
});
@@ -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 });
});
@@ -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');
});
});
@@ -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 });
});