Merge PR #59: OMP provider and model alias mapping

Adds Oh My Pi support by parameterizing the Pi JSONL reader to accept a
providerName, so sessions at ~/.omp/agent/sessions/ are discovered and
tracked alongside Pi. Ships a model-alias CLI command plus five
built-in aliases for the anthropic--claude-X.Y-tier double-dash format
that some Anthropic-compatible proxies emit, so cost rows no longer
read $0.00 for those names.

Contributed by @cgrossde.
This commit is contained in:
iamtoruk 2026-04-21 11:52:13 -07:00
commit ac31883edc
9 changed files with 505 additions and 20 deletions

View file

@ -17,7 +17,7 @@
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
</p>
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 20+
- 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: `better-sqlite3` is installed automatically as an optional dependency
## Usage
@ -93,6 +93,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
```
@ -136,6 +137,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) |
@ -149,6 +151,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:
@ -306,9 +324,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/<sanitized-cwd>/*.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/<sanitized-cwd>/*.jsonl` (Pi) and `~/.omp/agent/sessions/<sanitized-cwd>/*.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
@ -342,7 +360,7 @@ src/
codex.ts Codex session discovery and JSONL parsing
cursor.ts Cursor SQLite parsing, language extraction
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

View file

@ -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'
@ -139,6 +139,8 @@ const program = new Command()
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
program.hook('preAction', async (thisCommand) => {
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
@ -689,6 +691,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 <from>', '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 <from> <to>\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('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')

View file

@ -19,6 +19,7 @@ export type CodeburnConfig = {
symbol?: string
}
plan?: Plan
modelAliases?: Record<string, string>
}
function getConfigDir(): string {

View file

@ -126,14 +126,40 @@ export async function loadPricing(): Promise<void> {
}
}
// 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<string, string> = {
'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<string, string> = {}
// Called once during CLI startup after config is loaded.
// User aliases take precedence over built-ins.
export function setModelAliases(aliases: Record<string, string>): void {
userAliases = aliases
}
function resolveAlias(model: string): string {
if (Object.hasOwn(userAliases, model)) return userAliases[model]!
if (Object.hasOwn(BUILTIN_ALIASES, model)) return BUILTIN_ALIASES[model]!
return 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)!
@ -176,7 +202,7 @@ export function calculateCost(
}
export function getShortModelName(model: string): string {
const canonical = getCanonicalName(model)
const canonical = resolveAlias(getCanonicalName(model))
const shortNames: Record<string, string> = {
'claude-opus-4-7': 'Opus 4.7',
'claude-opus-4-6': 'Opus 4.6',

View file

@ -1,7 +1,7 @@
import { claude } from './claude.js'
import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { pi } from './pi.js'
import { pi, omp } from './pi.js'
import type { Provider, SessionSource } from './types.js'
let cursorProvider: Provider | null = null
@ -49,7 +49,7 @@ async function loadCursorAgent(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, codex, copilot, pi]
const coreProviders: Provider[] = [claude, codex, copilot, pi, omp]
export async function getAllProviders(): Promise<Provider[]> {
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])

View file

@ -56,6 +56,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<PiEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
@ -68,7 +72,7 @@ async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
}
}
async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource[]> {
async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
let projectDirs: string[]
@ -100,7 +104,7 @@ async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource
if (!first || first.type !== 'session') continue
const cwd = first.cwd ?? dirName
sources.push({ path: filePath, project: basename(cwd), provider: 'pi' })
sources.push({ path: filePath, project: basename(cwd), provider: providerName })
}
}
@ -150,7 +154,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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)
@ -168,7 +172,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const timestamp = entry.timestamp ?? ''
yield {
provider: 'pi',
provider: source.provider,
model,
inputTokens: input,
outputTokens: output,
@ -212,7 +216,7 @@ export function createPiProvider(sessionsDir?: string): Provider {
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSessionsInDir(dir)
return discoverSessionsInDir(dir, 'pi')
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
@ -222,3 +226,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<SessionSource[]> {
return discoverSessionsInDir(dir, 'omp')
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const omp = createOmpProvider()

View file

@ -1,11 +1,13 @@
import { describe, it, expect, beforeAll } from 'vitest'
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import { getModelCosts, getShortModelName, loadPricing } from '../src/models.js'
import { getModelCosts, getShortModelName, calculateCost, loadPricing, setModelAliases } from '../src/models.js'
beforeAll(async () => {
await loadPricing()
})
afterEach(() => setModelAliases({}))
describe('getModelCosts', () => {
it('does not match short canonical against longer pricing key', () => {
const costs = getModelCosts('gpt-4')
@ -50,3 +52,130 @@ describe('getShortModelName', () => {
expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
})
})
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('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', () => {
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({})
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()
})
})

View file

@ -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 () => {

225
tests/providers/omp.test.ts Normal file
View file

@ -0,0 +1,225 @@
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([])
})
})
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).toBeGreaterThanOrEqual(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)
})
})