Add OMP provider support and model alias mapping

- Add OMP provider reading from ~/.omp/agent/sessions (same JSONL
  format as Pi, shared parser)
- Parameterize discoverSessionsInDir with provider name so sessions
  carry correct provider field
- Add BUILTIN_ALIASES for proxy model name variants (anthropic--claude-*
  double-dash format) that don't match LiteLLM keys
- Add model-alias CLI command for user-defined name mappings
- Wire setModelAliases into preAction after config load
- Add modelAliases field to CodeburnConfig
- Update README: OMP in provider table, model-alias section
This commit is contained in:
AgentSeal 2026-04-16 23:31:22 +02:00 committed by Christoph Gross
parent b0d7a8b678
commit 79e67f0bc9
No known key found for this signature in database
9 changed files with 507 additions and 17 deletions

View file

@ -19,7 +19,7 @@
<img src="https://raw.githubusercontent.com/AgentSeal/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**, **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. macOS menu bar widget via SwiftBar. CSV/JSON export.
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **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. macOS menu bar widget via SwiftBar. 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).
@ -38,7 +38,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
@ -70,6 +70,7 @@ codeburn report --provider cursor # Cursor 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
```
@ -86,6 +87,7 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status
| 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) |
@ -99,6 +101,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:
@ -181,9 +199,9 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
**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
@ -216,7 +234,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
```
## License

View file

@ -1,6 +1,6 @@
import { Command } from 'commander'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing } from './models.js'
import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions } from './parser.js'
import { renderStatusBar } from './format.js'
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
@ -64,6 +64,8 @@ const program = new Command()
.version(version)
program.hook('preAction', async () => {
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
await loadCurrency()
})
@ -274,4 +276,54 @@ program
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program.parse()
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.parse()

View file

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

View file

@ -124,14 +124,38 @@ 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 {
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<string, string> = {
'claude-opus-4-7': 'Opus 4.7',
'claude-opus-4-6': 'Opus 4.6',

View file

@ -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
@ -34,7 +35,7 @@ async function loadOpenCode(): 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] = await Promise.all([loadCursor(), loadOpenCode()])

View file

@ -55,6 +55,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> {
try {
const content = await readFile(filePath, 'utf-8')
@ -66,7 +70,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[]
@ -98,7 +102,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 })
}
}
@ -171,7 +175,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const timestamp = entry.timestamp ?? ''
yield {
provider: 'pi',
provider: source.provider,
model,
inputTokens: input,
outputTokens: output,
@ -215,7 +219,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 {
@ -225,3 +229,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()

135
tests/models.test.ts Normal file
View file

@ -0,0 +1,135 @@
import { describe, it, expect, afterEach } from 'vitest'
import { getModelCosts, getShortModelName, calculateCost, setModelAliases } from '../src/models.js'
// Tests run without loadPricing — fallback pricing only.
// setModelAliases resets between tests to avoid cross-contamination.
afterEach(() => setModelAliases({}))
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', () => {
// 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()
})
})

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('pi:')
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)
})
})