mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-20 17:47:19 +00:00
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:
commit
ac31883edc
9 changed files with 505 additions and 20 deletions
28
README.md
28
README.md
|
|
@ -17,7 +17,7 @@
|
||||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
||||||
</p>
|
</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).
|
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
|
### Requirements
|
||||||
|
|
||||||
- Node.js 20+
|
- 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
|
- For Cursor/OpenCode support: `better-sqlite3` is installed automatically as an optional dependency
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -93,6 +93,7 @@ codeburn report --provider cursor-agent # cursor-agent CLI only
|
||||||
codeburn report --provider opencode # OpenCode only
|
codeburn report --provider opencode # OpenCode only
|
||||||
codeburn report --provider pi # Pi only
|
codeburn report --provider pi # Pi only
|
||||||
codeburn report --provider copilot # GitHub Copilot only
|
codeburn report --provider copilot # GitHub Copilot only
|
||||||
|
codeburn report --provider omp # OMP only
|
||||||
codeburn today --provider codex # Codex today
|
codeburn today --provider codex # Codex today
|
||||||
codeburn export --provider claude # export Claude data only
|
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 |
|
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported |
|
||||||
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
|
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
|
||||||
| Pi | `~/.pi/agent/sessions/` | Supported |
|
| Pi | `~/.pi/agent/sessions/` | Supported |
|
||||||
|
| OMP | `~/.omp/agent/sessions/` | Supported |
|
||||||
| GitHub Copilot | `~/.copilot/session-state/` | Supported (output tokens only) |
|
| GitHub Copilot | `~/.copilot/session-state/` | Supported (output tokens only) |
|
||||||
| Amp | -- | Planned (provider plugin system) |
|
| 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.
|
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
|
## Currency
|
||||||
|
|
||||||
By default, costs are shown in USD. To display in a different 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`.
|
**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
|
## Environment variables
|
||||||
|
|
||||||
|
|
@ -342,7 +360,7 @@ src/
|
||||||
codex.ts Codex session discovery and JSONL parsing
|
codex.ts Codex session discovery and JSONL parsing
|
||||||
cursor.ts Cursor SQLite parsing, language extraction
|
cursor.ts Cursor SQLite parsing, language extraction
|
||||||
opencode.ts OpenCode SQLite 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
|
## Star History
|
||||||
|
|
|
||||||
54
src/cli.ts
54
src/cli.ts
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
import { installMenubarApp } from './menubar-installer.js'
|
import { installMenubarApp } from './menubar-installer.js'
|
||||||
import { exportCsv, exportJson, type PeriodExport } from './export.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 { parseAllSessions, filterProjectsByName } from './parser.js'
|
||||||
import { convertCost } from './currency.js'
|
import { convertCost } from './currency.js'
|
||||||
import { renderStatusBar } from './format.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')
|
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
|
||||||
|
|
||||||
program.hook('preAction', async (thisCommand) => {
|
program.hook('preAction', async (thisCommand) => {
|
||||||
|
const config = await readConfig()
|
||||||
|
setModelAliases(config.modelAliases ?? {})
|
||||||
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
|
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
|
||||||
process.env['CODEBURN_VERBOSE'] = '1'
|
process.env['CODEBURN_VERBOSE'] = '1'
|
||||||
}
|
}
|
||||||
|
|
@ -689,6 +691,56 @@ program
|
||||||
console.log(` Config saved to ${getConfigFilePath()}\n`)
|
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
|
program
|
||||||
.command('plan [action] [id]')
|
.command('plan [action] [id]')
|
||||||
.description('Show or configure a subscription plan for overage tracking')
|
.description('Show or configure a subscription plan for overage tracking')
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export type CodeburnConfig = {
|
||||||
symbol?: string
|
symbol?: string
|
||||||
}
|
}
|
||||||
plan?: Plan
|
plan?: Plan
|
||||||
|
modelAliases?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigDir(): string {
|
function getConfigDir(): string {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function getCanonicalName(model: string): string {
|
||||||
return model
|
return model
|
||||||
.replace(/@.*$/, '')
|
.replace(/@.*$/, '') // strip pin: claude-sonnet-4-6@20250929 -> claude-sonnet-4-6
|
||||||
.replace(/-\d{8}$/, '')
|
.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 {
|
export function getModelCosts(model: string): ModelCosts | null {
|
||||||
const canonical = getCanonicalName(model)
|
const canonical = resolveAlias(getCanonicalName(model))
|
||||||
|
|
||||||
if (pricingCache?.has(canonical)) return pricingCache.get(canonical)!
|
if (pricingCache?.has(canonical)) return pricingCache.get(canonical)!
|
||||||
|
|
||||||
|
|
@ -176,7 +202,7 @@ export function calculateCost(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShortModelName(model: string): string {
|
export function getShortModelName(model: string): string {
|
||||||
const canonical = getCanonicalName(model)
|
const canonical = resolveAlias(getCanonicalName(model))
|
||||||
const shortNames: Record<string, string> = {
|
const shortNames: Record<string, string> = {
|
||||||
'claude-opus-4-7': 'Opus 4.7',
|
'claude-opus-4-7': 'Opus 4.7',
|
||||||
'claude-opus-4-6': 'Opus 4.6',
|
'claude-opus-4-6': 'Opus 4.6',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { claude } from './claude.js'
|
import { claude } from './claude.js'
|
||||||
import { codex } from './codex.js'
|
import { codex } from './codex.js'
|
||||||
import { copilot } from './copilot.js'
|
import { copilot } from './copilot.js'
|
||||||
import { pi } from './pi.js'
|
import { pi, omp } from './pi.js'
|
||||||
import type { Provider, SessionSource } from './types.js'
|
import type { Provider, SessionSource } from './types.js'
|
||||||
|
|
||||||
let cursorProvider: Provider | null = null
|
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[]> {
|
export async function getAllProviders(): Promise<Provider[]> {
|
||||||
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])
|
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ function getPiSessionsDir(override?: string): string {
|
||||||
return override ?? join(homedir(), '.pi', 'agent', 'sessions')
|
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> {
|
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
|
||||||
const content = await readSessionFile(filePath)
|
const content = await readSessionFile(filePath)
|
||||||
if (content === null) return null
|
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[] = []
|
const sources: SessionSource[] = []
|
||||||
|
|
||||||
let projectDirs: string[]
|
let projectDirs: string[]
|
||||||
|
|
@ -100,7 +104,7 @@ async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource
|
||||||
if (!first || first.type !== 'session') continue
|
if (!first || first.type !== 'session') continue
|
||||||
|
|
||||||
const cwd = first.cwd ?? dirName
|
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 model = msg.model ?? 'gpt-5'
|
||||||
const responseId = msg.responseId ?? ''
|
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
|
if (seenKeys.has(dedupKey)) continue
|
||||||
seenKeys.add(dedupKey)
|
seenKeys.add(dedupKey)
|
||||||
|
|
@ -168,7 +172,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
||||||
const timestamp = entry.timestamp ?? ''
|
const timestamp = entry.timestamp ?? ''
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
provider: 'pi',
|
provider: source.provider,
|
||||||
model,
|
model,
|
||||||
inputTokens: input,
|
inputTokens: input,
|
||||||
outputTokens: output,
|
outputTokens: output,
|
||||||
|
|
@ -212,7 +216,7 @@ export function createPiProvider(sessionsDir?: string): Provider {
|
||||||
},
|
},
|
||||||
|
|
||||||
async discoverSessions(): Promise<SessionSource[]> {
|
async discoverSessions(): Promise<SessionSource[]> {
|
||||||
return discoverSessionsInDir(dir)
|
return discoverSessionsInDir(dir, 'pi')
|
||||||
},
|
},
|
||||||
|
|
||||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||||
|
|
@ -222,3 +226,33 @@ export function createPiProvider(sessionsDir?: string): Provider {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pi = createPiProvider()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
beforeAll(async () => {
|
||||||
await loadPricing()
|
await loadPricing()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => setModelAliases({}))
|
||||||
|
|
||||||
describe('getModelCosts', () => {
|
describe('getModelCosts', () => {
|
||||||
it('does not match short canonical against longer pricing key', () => {
|
it('does not match short canonical against longer pricing key', () => {
|
||||||
const costs = getModelCosts('gpt-4')
|
const costs = getModelCosts('gpt-4')
|
||||||
|
|
@ -50,3 +52,130 @@ describe('getShortModelName', () => {
|
||||||
expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
|
||||||
|
|
||||||
describe('provider registry', () => {
|
describe('provider registry', () => {
|
||||||
it('has core providers registered synchronously', () => {
|
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 () => {
|
it('includes sqlite providers after async load', async () => {
|
||||||
|
|
|
||||||
225
tests/providers/omp.test.ts
Normal file
225
tests/providers/omp.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue