diff --git a/README.md b/README.md
index ec633cd..69138ed 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-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)
+ })
+})