Files
ccs/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts

299 lines
8.1 KiB
TypeScript

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');
});
it('ignores ccs_runtime-backed codex bridge rollouts to avoid double counting', async () => {
writeCodexFixture('ccs_runtime');
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');
});
});