diff --git a/README.md b/README.md index ec633cd..69138ed 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ CodeBurn TUI dashboard

-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **cursor-agent**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export. +By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **cursor-agent**, **OpenCode**, **Pi**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My Pi), and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export. Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported). @@ -36,7 +36,7 @@ npx codeburn ### Requirements - Node.js 22+ -- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, Pi (`~/.pi/agent/sessions/`), and/or GitHub Copilot (`~/.copilot/session-state/`) +- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, Pi (`~/.pi/agent/sessions/`), OMP (`~/.omp/agent/sessions/`), and/or GitHub Copilot (`~/.copilot/session-state/`) - For Cursor/OpenCode support: uses Node's built-in `node:sqlite` (Node 22+) ## Usage @@ -112,6 +112,7 @@ codeburn report --provider cursor-agent # cursor-agent CLI only codeburn report --provider opencode # OpenCode only codeburn report --provider pi # Pi only codeburn report --provider copilot # GitHub Copilot only +codeburn report --provider omp # OMP only codeburn today --provider codex # Codex today codeburn export --provider claude # export Claude data only ``` @@ -155,6 +156,7 @@ Either flag alone is valid. Inverted or malformed dates exit with a clear error. | Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported | | OpenCode | `~/.local/share/opencode/` (SQLite) | Supported | | Pi | `~/.pi/agent/sessions/` | Supported | +| OMP | `~/.omp/agent/sessions/` | Supported | | GitHub Copilot | `~/.copilot/session-state/` | Supported (output tokens only) | | Amp | -- | Planned (provider plugin system) | @@ -168,6 +170,22 @@ GitHub Copilot only logs output tokens in its session state, so Copilot cost row The provider plugin system makes adding a new provider a single file. Each provider implements session discovery, JSONL parsing, tool normalization, and model display names. See `src/providers/codex.ts` for an example. +## Model aliases + +If you see `$0.00` for some models, the model name reported by your provider doesn't match any entry in the LiteLLM pricing data. This commonly happens when using a proxy that rewrites model names. + +Map any model name to a canonical one: + +```bash +codeburn model-alias "my-proxy-model" "claude-opus-4-6" # add alias +codeburn model-alias --list # show configured aliases +codeburn model-alias --remove "my-proxy-model" # remove alias +``` + +Aliases are stored in `~/.config/codeburn/config.json` and applied at runtime before pricing lookup. The target name can be anything in the [LiteLLM model list](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) or a canonical name from the fallback table (e.g. `claude-sonnet-4-6`, `claude-opus-4-5`, `gpt-4o`). + +Built-in aliases ship for known proxy model name variants (such as `anthropic--claude-4.6-opus`). User-configured aliases take precedence over built-ins. + ## Currency By default, costs are shown in USD. To display in a different currency: @@ -325,9 +343,9 @@ All metrics are computed from your local session data. No LLM calls, fully deter **OpenCode** stores sessions in SQLite databases at `~/.local/share/opencode/opencode*.db`. CodeBurn queries the `session`, `message`, and `part` tables read-only, extracts token counts and tool usage, and recalculates cost using the LiteLLM pricing engine. Falls back to OpenCode's own cost field for models not in our pricing data. Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double-counting. Supports multiple channel databases and respects `XDG_DATA_HOME`. -**Pi** stores sessions as JSONL at `~/.pi/agent/sessions//*.jsonl`. Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes Pi's lowercase tool names to the standard set (`bash` -> `Bash`, `dispatch_agent` -> `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown. +**Pi / OMP** stores sessions as JSONL at `~/.pi/agent/sessions//*.jsonl` (Pi) and `~/.omp/agent/sessions//*.jsonl` (OMP). Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes tool names to the standard set (`bash` -> `Bash`, `dispatch_agent` -> `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown. -CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi), filters by date range per entry, and classifies each turn. +CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn. ## Environment variables @@ -362,13 +380,14 @@ src/ fs-utils.ts Bounded file readers with stream support providers/ types.ts Provider interface definitions - index.ts Provider registry (lazy-loads Cursor, OpenCode) + index.ts Provider registry (lazy-loads Cursor, OpenCode, cursor-agent) claude.ts Claude Code session discovery codex.ts Codex session discovery and JSONL parsing copilot.ts GitHub Copilot session state parsing cursor.ts Cursor SQLite parsing, language extraction + cursor-agent.ts cursor-agent CLI session discovery and parsing opencode.ts OpenCode SQLite session discovery and parsing - pi.ts Pi agent JSONL session discovery and parsing + pi.ts Pi/OMP agent JSONL session discovery and parsing ``` ## Star History diff --git a/src/cli.ts b/src/cli.ts index 7bd91a9..612a023 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { Command } from 'commander' import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' -import { loadPricing } from './models.js' +import { loadPricing, setModelAliases } from './models.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' @@ -158,6 +158,8 @@ program.hook('preAction', async (thisCommand) => { if (thisCommand.opts<{ verbose?: boolean }>().verbose) { process.env['CODEBURN_VERBOSE'] = '1' } + const config = await readConfig() + setModelAliases(config.modelAliases ?? {}) await loadCurrency() }) @@ -835,6 +837,56 @@ program console.log(` Config saved to ${getConfigFilePath()}\n`) }) +program + .command('model-alias [from] [to]') + .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)') + .option('--remove ', 'Remove an alias') + .option('--list', 'List configured aliases') + .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => { + const config = await readConfig() + const aliases = config.modelAliases ?? {} + + if (opts?.list || (!from && !opts?.remove)) { + const entries = Object.entries(aliases) + if (entries.length === 0) { + console.log('\n No model aliases configured.') + console.log(` Config: ${getConfigFilePath()}\n`) + } else { + console.log('\n Model aliases:') + for (const [src, dst] of entries) { + console.log(` ${src} -> ${dst}`) + } + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + if (opts?.remove) { + if (!(opts.remove in aliases)) { + console.error(`\n Alias not found: ${opts.remove}\n`) + process.exitCode = 1 + return + } + delete aliases[opts.remove] + config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined + await saveConfig(config) + console.log(`\n Removed alias: ${opts.remove}\n`) + return + } + + if (!from || !to) { + console.error('\n Usage: codeburn model-alias \n') + process.exitCode = 1 + return + } + + aliases[from] = to + config.modelAliases = aliases + await saveConfig(config) + console.log(`\n Alias saved: ${from} -> ${to}`) + console.log(` Config: ${getConfigFilePath()}\n`) + }) + program .command('optimize') .description('Find token waste and get exact fixes') diff --git a/src/config.ts b/src/config.ts index d275200..c10e7fe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,7 @@ export type CodeburnConfig = { symbol?: string } plan?: Plan + modelAliases?: Record } function getConfigDir(): string { diff --git a/src/models.ts b/src/models.ts index bc5d6f6..b7c7b52 100644 --- a/src/models.ts +++ b/src/models.ts @@ -124,14 +124,38 @@ export async function loadPricing(): Promise { } } +// Known model name variants that providers emit but LiteLLM/fallback don't index under. +// OMP emits 'anthropic--claude-4.6-opus' (double-dash, dot version, tier-last). +// getCanonicalName strips any 'provider/' prefix first, so only the post-strip +// forms need to be listed here. +const BUILTIN_ALIASES: Record = { + 'anthropic--claude-4.6-opus': 'claude-opus-4-6', + 'anthropic--claude-4.6-sonnet': 'claude-sonnet-4-6', + 'anthropic--claude-4.5-opus': 'claude-opus-4-5', + 'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5', + 'anthropic--claude-4.5-haiku': 'claude-haiku-4-5', +} + +let userAliases: Record = {} + +// Called once during CLI startup after config is loaded. +// User aliases take precedence over built-ins. +export function setModelAliases(aliases: Record): void { + userAliases = aliases +} + +function resolveAlias(model: string): string { + return userAliases[model] ?? BUILTIN_ALIASES[model] ?? model +} function getCanonicalName(model: string): string { return model - .replace(/@.*$/, '') - .replace(/-\d{8}$/, '') + .replace(/@.*$/, '') // strip pin: claude-sonnet-4-6@20250929 -> claude-sonnet-4-6 + .replace(/-\d{8}$/, '') // strip date: claude-sonnet-4-20250514 -> claude-sonnet-4 + .replace(/^[^/]+\//, '') // strip provider prefix: anthropic/foo -> foo } export function getModelCosts(model: string): ModelCosts | null { - const canonical = getCanonicalName(model) + const canonical = resolveAlias(getCanonicalName(model)) if (pricingCache?.has(canonical)) return pricingCache.get(canonical)! @@ -174,7 +198,7 @@ export function calculateCost( } export function getShortModelName(model: string): string { - const canonical = getCanonicalName(model) + const canonical = resolveAlias(getCanonicalName(model)) const shortNames: Record = { 'claude-opus-4-7': 'Opus 4.7', 'claude-opus-4-6': 'Opus 4.6', diff --git a/src/providers/index.ts b/src/providers/index.ts index 8419fda..2fb2673 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -2,6 +2,7 @@ import { claude } from './claude.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { pi } from './pi.js' +import { omp } from './pi.js' import type { Provider, SessionSource } from './types.js' let cursorProvider: Provider | null = null @@ -49,7 +50,7 @@ async function loadCursorAgent(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, pi] +const coreProviders: Provider[] = [claude, codex, copilot, pi, omp] export async function getAllProviders(): Promise { const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()]) diff --git a/src/providers/pi.ts b/src/providers/pi.ts index 8013e68..8ccbc06 100644 --- a/src/providers/pi.ts +++ b/src/providers/pi.ts @@ -57,6 +57,10 @@ function getPiSessionsDir(override?: string): string { return override ?? join(homedir(), '.pi', 'agent', 'sessions') } +function getOmpSessionsDir(override?: string): string { + return override ?? join(homedir(), '.omp', 'agent', 'sessions') +} + async function readFirstEntry(filePath: string): Promise { const content = await readSessionFile(filePath) if (content === null) return null @@ -69,7 +73,7 @@ async function readFirstEntry(filePath: string): Promise { } } -async function collectPiDiscoverySnapshot(sessionsDir: string): Promise { +async function collectDiscoverySnapshot(sessionsDir: string): Promise { const snapshot: DiscoverySnapshotEntry[] = [] let projectDirs: string[] @@ -84,14 +88,31 @@ async function collectPiDiscoverySnapshot(sessionsDir: string): Promise null) if (!dirStat?.isDirectory()) continue snapshot.push({ path: dirPath, mtimeMs: dirStat.mtimeMs }) + + // Sub-agent sessions land in // subdirectories. + // Their mtimes must be tracked separately — adding files inside a subdir + // does not bump the parent project dir's mtime. + let subEntries: string[] + try { + subEntries = await readdir(dirPath) + } catch { + continue + } + for (const subName of subEntries) { + const subPath = join(dirPath, subName) + const subStat = await stat(subPath).catch(() => null) + if (subStat?.isDirectory()) { + snapshot.push({ path: subPath, mtimeMs: subStat.mtimeMs }) + } + } } return snapshot } -async function discoverSessionsInDir(sessionsDir: string): Promise { - const snapshot = await collectPiDiscoverySnapshot(sessionsDir) - const cached = await loadDiscoveryCache('pi', sessionsDir, snapshot) +async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise { + const snapshot = await collectDiscoverySnapshot(sessionsDir) + const cached = await loadDiscoveryCache(providerName, sessionsDir, snapshot) if (cached) return cached const sources: SessionSource[] = [] @@ -108,37 +129,56 @@ async function discoverSessionsInDir(sessionsDir: string): Promise null) if (!dirStat?.isDirectory()) continue - let files: string[] - try { - files = await readdir(dirPath) - } catch { + await collectJsonlFromDir(dirPath, dirName, providerName, sources) + } + + await saveDiscoveryCache(providerName, sessionsDir, snapshot, sources) + return sources +} + +// Collects session sources from dirPath and one level of subdirectories. +// Sub-agent sessions land in //.jsonl, +// so we must recurse one level deeper than the project directory. +async function collectJsonlFromDir( + dirPath: string, + projectDirName: string, + providerName: string, + sources: SessionSource[], +): Promise { + let entries: string[] + try { + entries = await readdir(dirPath) + } catch { + return + } + + for (const entry of entries) { + const entryPath = join(dirPath, entry) + const entryStat = await stat(entryPath).catch(() => null) + if (!entryStat) continue + + if (entryStat.isDirectory()) { + // Sub-agent session dir: recurse one level, but don't go deeper + await collectJsonlFromDir(entryPath, projectDirName, providerName, sources) continue } - for (const file of files) { - if (!file.endsWith('.jsonl')) continue - const filePath = join(dirPath, file) - const fileStat = await stat(filePath).catch(() => null) - if (!fileStat?.isFile()) continue - - const first = await readFirstEntry(filePath) - if (!first || first.type !== 'session') continue - - const cwd = first.cwd ?? dirName - sources.push({ - path: filePath, - project: basename(cwd), - provider: 'pi', - fingerprintPath: filePath, - cacheStrategy: 'append-jsonl', - progressLabel: basename(filePath), - parserVersion: 'pi:v1', - }) - } + if (!entryStat.isFile() || !entry.endsWith('.jsonl')) continue + + const first = await readFirstEntry(entryPath) + if (!first || first.type !== 'session') continue + + const cwd = first.cwd ?? projectDirName + sources.push({ + path: entryPath, + project: basename(cwd), + provider: providerName, + fingerprintPath: entryPath, + cacheStrategy: 'append-jsonl', + progressLabel: basename(entryPath), + parserVersion: `${providerName}:v1`, + }) } - - await saveDiscoveryCache('pi', sessionsDir, snapshot, sources) - return sources } function createParser(source: SessionSource, seenKeys: Set): SessionParser { @@ -184,7 +224,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const model = msg.model ?? 'gpt-5' const responseId = msg.responseId ?? '' - const dedupKey = `pi:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + const dedupKey = `${source.provider}:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) @@ -202,7 +242,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const timestamp = entry.timestamp ?? '' yield { - provider: 'pi', + provider: source.provider, model, inputTokens: input, outputTokens: output, @@ -246,7 +286,7 @@ export function createPiProvider(sessionsDir?: string): Provider { }, async discoverSessions(): Promise { - return discoverSessionsInDir(dir) + return discoverSessionsInDir(dir, 'pi') }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { @@ -256,3 +296,33 @@ export function createPiProvider(sessionsDir?: string): Provider { } export const pi = createPiProvider() + +export function createOmpProvider(sessionsDir?: string): Provider { + const dir = getOmpSessionsDir(sessionsDir) + + return { + name: 'omp', + displayName: 'OMP', + + modelDisplayName(model: string): string { + for (const [key, name] of modelDisplayEntries) { + if (model.startsWith(key)) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(dir, 'omp') + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const omp = createOmpProvider() diff --git a/tests/models.test.ts b/tests/models.test.ts index 71f944e..5e9505e 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -1,13 +1,145 @@ -import { describe, it, expect, beforeAll } from 'vitest' +import { describe, it, expect, afterEach, beforeAll } from 'vitest' +import { getModelCosts, getShortModelName, calculateCost, setModelAliases, loadPricing } from '../src/models.js' -import { getModelCosts, getShortModelName, loadPricing } from '../src/models.js' +// Alias tests run without loadPricing — fallback pricing only. +// setModelAliases resets between tests to avoid cross-contamination. +afterEach(() => setModelAliases({})) -beforeAll(async () => { - await loadPricing() +describe('builtin aliases - getModelCosts', () => { + it('resolves anthropic--claude-4.6-opus', () => { + expect(getModelCosts('anthropic--claude-4.6-opus')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.6-sonnet', () => { + expect(getModelCosts('anthropic--claude-4.6-sonnet')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.5-opus', () => { + expect(getModelCosts('anthropic--claude-4.5-opus')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.5-sonnet', () => { + expect(getModelCosts('anthropic--claude-4.5-sonnet')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.5-haiku', () => { + expect(getModelCosts('anthropic--claude-4.5-haiku')).not.toBeNull() + }) + + it('resolves double-wrapped anthropic/anthropic--claude-4.6-opus', () => { + expect(getModelCosts('anthropic/anthropic--claude-4.6-opus')).not.toBeNull() + }) + + it('resolves double-wrapped anthropic/anthropic--claude-4.6-sonnet', () => { + expect(getModelCosts('anthropic/anthropic--claude-4.6-sonnet')).not.toBeNull() + }) + + it('resolves double-wrapped anthropic/anthropic--claude-4.5-haiku', () => { + expect(getModelCosts('anthropic/anthropic--claude-4.5-haiku')).not.toBeNull() + }) + + it('OMP opus resolves to same pricing as canonical claude-opus-4-6', () => { + expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6')) + }) + + it('OMP sonnet resolves to same pricing as canonical claude-sonnet-4-6', () => { + expect(getModelCosts('anthropic--claude-4.6-sonnet')).toEqual(getModelCosts('claude-sonnet-4-6')) + }) + + it('OMP haiku resolves to same pricing as canonical claude-haiku-4-5', () => { + expect(getModelCosts('anthropic--claude-4.5-haiku')).toEqual(getModelCosts('claude-haiku-4-5')) + }) }) -describe('getModelCosts', () => { - it('does not match short canonical against longer pricing key', () => { +describe('builtin aliases - getShortModelName', () => { + it('anthropic--claude-4.6-opus -> Opus 4.6', () => { + expect(getShortModelName('anthropic--claude-4.6-opus')).toBe('Opus 4.6') + }) + + it('anthropic--claude-4.6-sonnet -> Sonnet 4.6', () => { + expect(getShortModelName('anthropic--claude-4.6-sonnet')).toBe('Sonnet 4.6') + }) + + it('anthropic--claude-4.5-opus -> Opus 4.5', () => { + expect(getShortModelName('anthropic--claude-4.5-opus')).toBe('Opus 4.5') + }) + + it('anthropic--claude-4.5-sonnet -> Sonnet 4.5', () => { + expect(getShortModelName('anthropic--claude-4.5-sonnet')).toBe('Sonnet 4.5') + }) + + it('anthropic--claude-4.5-haiku -> Haiku 4.5', () => { + expect(getShortModelName('anthropic--claude-4.5-haiku')).toBe('Haiku 4.5') + }) + + it('anthropic/anthropic--claude-4.6-opus -> Opus 4.6', () => { + expect(getShortModelName('anthropic/anthropic--claude-4.6-opus')).toBe('Opus 4.6') + }) +}) + +describe('user aliases via setModelAliases', () => { + it('user alias resolves for getModelCosts', () => { + setModelAliases({ 'my-internal-model': 'claude-sonnet-4-6' }) + expect(getModelCosts('my-internal-model')).toEqual(getModelCosts('claude-sonnet-4-6')) + }) + + it('user alias resolves for getShortModelName', () => { + setModelAliases({ 'my-internal-model': 'claude-opus-4-6' }) + expect(getShortModelName('my-internal-model')).toBe('Opus 4.6') + }) + + it('user alias overrides builtin', () => { + // Remap an OMP key to a different canonical target + setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) + expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-sonnet-4-5')) + }) + + it('resetting aliases restores builtins', () => { + setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) + setModelAliases({}) + // Back to builtin: should resolve as opus pricing, not sonnet + expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6')) + }) +}) + +describe('calculateCost - OMP names produce non-zero cost', () => { + it('calculates cost for anthropic--claude-4.6-opus', () => { + expect(calculateCost('anthropic--claude-4.6-opus', 1000, 200, 0, 0, 0)).toBeGreaterThan(0) + }) + + it('calculates cost for anthropic/anthropic--claude-4.6-sonnet', () => { + expect(calculateCost('anthropic/anthropic--claude-4.6-sonnet', 1000, 200, 0, 0, 0)).toBeGreaterThan(0) + }) +}) + +describe('existing model names still resolve', () => { + it('canonical claude-opus-4-6', () => { + expect(getModelCosts('claude-opus-4-6')).not.toBeNull() + }) + + it('canonical claude-sonnet-4-5', () => { + expect(getModelCosts('claude-sonnet-4-5')).not.toBeNull() + }) + + it('date-stamped claude-sonnet-4-20250514', () => { + expect(getModelCosts('claude-sonnet-4-20250514')).not.toBeNull() + }) + + it('pinned claude-sonnet-4-6@20250929', () => { + expect(getModelCosts('claude-sonnet-4-6@20250929')).not.toBeNull() + }) + + it('anthropic/-prefixed anthropic/claude-opus-4-6', () => { + expect(getModelCosts('anthropic/claude-opus-4-6')).not.toBeNull() + }) +}) + +describe('getModelCosts - prefix disambiguation (requires live pricing)', () => { + beforeAll(async () => { + await loadPricing() + }) + + it('does not match short canonical gpt-4 against longer pricing key', () => { const costs = getModelCosts('gpt-4') if (costs) { expect(costs.inputCostPerToken).not.toBe(2.5e-6) @@ -22,14 +154,14 @@ describe('getModelCosts', () => { expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken) }) - it('returns fallback pricing for known Claude models', () => { + it('returns fallback pricing for known Claude models with date suffix', () => { const costs = getModelCosts('claude-opus-4-6-20260205') expect(costs).not.toBeNull() expect(costs!.inputCostPerToken).toBe(5e-6) }) }) -describe('getShortModelName', () => { +describe('getShortModelName - GPT model disambiguation', () => { it('maps gpt-4o-mini correctly (not gpt-4o)', () => { expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini') }) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 8c452f6..f2db2b6 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi', 'omp']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/omp.test.ts b/tests/providers/omp.test.ts new file mode 100644 index 0000000..60ef7dd --- /dev/null +++ b/tests/providers/omp.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createOmpProvider } from '../../src/providers/pi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'omp-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionMeta(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 3, + id: opts.id ?? 'sess-001', + timestamp: '2026-04-14T10:00:00.000Z', + cwd: opts.cwd ?? '/Users/test/myproject', + }) +} + +function userMessage(text: string) { + return JSON.stringify({ + type: 'message', + id: 'msg-user-1', + timestamp: '2026-04-14T10:00:10.000Z', + message: { + role: 'user', + content: [{ type: 'text', text }], + timestamp: 1776023210000, + }, + }) +} + +function assistantMessage(opts: { + id?: string + responseId?: string + timestamp?: string + model?: string + input?: number + output?: number + cacheRead?: number + cacheWrite?: number + tools?: Array<{ name: string; command?: string }> +}) { + const content = (opts.tools ?? []).map(t => ({ + type: 'toolCall', + id: `call-${t.name}`, + name: t.name, + arguments: t.command !== undefined ? { command: t.command } : {}, + })) + + return JSON.stringify({ + type: 'message', + id: opts.id ?? 'msg-asst-1', + timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', + message: { + role: 'assistant', + content, + provider: 'anthropic', + model: opts.model ?? 'claude-sonnet-4-5', + responseId: opts.responseId ?? 'resp-001', + usage: { + input: opts.input ?? 1000, + output: opts.output ?? 200, + cacheRead: opts.cacheRead ?? 0, + cacheWrite: opts.cacheWrite ?? 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 1776023230000, + }, + }) +} + +async function writeSession(projectDir: string, filename: string, lines: string[]) { + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, filename) + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +describe('omp provider - identity', () => { + it('has correct name and displayName', () => { + const provider = createOmpProvider(tmpDir) + expect(provider.name).toBe('omp') + expect(provider.displayName).toBe('OMP') + }) +}) + +describe('omp provider - session discovery', () => { + it('discovers sessions from the omp sessions directory', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [ + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('omp') + expect(sessions[0]!.project).toBe('myproject') + }) + + it('returns empty for non-existent directory', async () => { + const provider = createOmpProvider('/nonexistent/omp/path') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips files whose first line is not a session entry', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'bad.jsonl', [ + JSON.stringify({ type: 'message', id: 'x' }), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + it('discovers sub-agent sessions nested inside session subdirectory', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const parentFile = '2026-04-21T10-00-00-000Z_abc123' + + // Main session at project-dir level + await writeSession(projectDir, `${parentFile}.jsonl`, [ + sessionMeta({ id: 'main-session', cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + // Sub-agent sessions inside / subdirectory + const subDir = join(projectDir, parentFile) + await writeSession(subDir, '0-SubAgent.jsonl', [ + sessionMeta({ id: 'sub-agent-0', cwd: '/Users/test/myproject' }), + assistantMessage({ input: 500, output: 100 }), + ]) + await writeSession(subDir, '1-SubAgent.jsonl', [ + sessionMeta({ id: 'sub-agent-1', cwd: '/Users/test/myproject' }), + assistantMessage({ input: 600, output: 120 }), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(3) + expect(sessions.every(s => s.provider === 'omp')).toBe(true) + expect(sessions.every(s => s.project === 'myproject')).toBe(true) + }) + +}) + +describe('omp provider - JSONL parsing', () => { + it('extracts token usage from an omp-format assistant message', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-omp-1', cwd: '/Users/test/myproject' }), + userMessage('write a test'), + assistantMessage({ + responseId: 'resp-omp-1', + timestamp: '2026-04-14T10:00:30.000Z', + model: 'claude-sonnet-4-5', + input: 1500, + output: 300, + cacheRead: 2000, + cacheWrite: 50, + }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('omp') + expect(call.model).toBe('claude-sonnet-4-5') + expect(call.inputTokens).toBe(1500) + expect(call.outputTokens).toBe(300) + expect(call.cacheReadInputTokens).toBe(2000) + expect(call.cachedInputTokens).toBe(2000) + expect(call.cacheCreationInputTokens).toBe(50) + expect(call.sessionId).toBe('sess-omp-1') + expect(call.userMessage).toBe('write a test') + expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z') + expect(call.deduplicationKey).toContain('omp:') + expect(call.deduplicationKey).toContain('resp-omp-1') + }) + + it('ignores the embedded usage.cost and recalculates cost', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ input: 1000, output: 200, cacheRead: 0, cacheWrite: 0 }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + // cost must be calculated by codeburn, not taken from usage.cost (which is zeroed in fixture) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('collects tool names from toolCall content items', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [{ name: 'read' }, { name: 'edit' }, { name: 'bash', command: 'bun test' }], + }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(calls[0]!.bashCommands).toEqual(['bun']) + }) + + it('skips assistant messages with zero tokens', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ input: 0, output: 0 }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(0) + }) +})