mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-28 18:13:16 +00:00
* fix(opencode): price Vertex turns on a Vertex row and keep an unpriceable recorded cost through the cache (#1547) OpenCode records the cost of each turn. For an id CodeBurn cannot price the parser fell back to that cost, but the session cache stored no cost and re-priced the cached tokens on every read, so the call showed $0. The recorded cost now rides on the cached call as fallbackCostUSD and applies only while the tokens price at $0, so a later catalog row or price override still wins. The session-level rollup used provider/model while per-message turns use the bare id since #1543; it now uses the bare id too. google-vertex and google-vertex-anthropic map to a metered Vertex route. OpenCode and Kilo Code re-parse once; daily cache v37 re-derives settled days. * fix(opencode): keep a declared-free id at $0 instead of its recorded cost A model the user marked flat-rate, priced at a zero rate, or that looks local prices at $0 on purpose. The recorded-cost fallback now skips those ids at parse time and on cache read, through one guard in models.ts.
1564 lines
66 KiB
TypeScript
1564 lines
66 KiB
TypeScript
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'
|
|
import { mkdirSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { isSqliteAvailable } from '../../src/sqlite.js'
|
|
import { calculateCost } from '../../src/models.js'
|
|
import { createOpenCodeProvider } from '../../src/providers/opencode.js'
|
|
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
|
|
|
type TestDb = {
|
|
exec(sql: string): void
|
|
prepare(sql: string): { run(...params: unknown[]): void }
|
|
close(): void
|
|
}
|
|
|
|
let tmpDir: string
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'opencode-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function createTestDb(dir: string): string {
|
|
const ocDir = join(dir, 'opencode')
|
|
mkdirSync(ocDir, { recursive: true })
|
|
const dbPath = join(ocDir, 'opencode.db')
|
|
|
|
const { DatabaseSync: Database } = require('node:sqlite')
|
|
const db = new Database(dbPath)
|
|
db.exec(`
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
|
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
|
|
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER,
|
|
time_archived INTEGER
|
|
)
|
|
`)
|
|
db.exec(`
|
|
CREATE TABLE message (
|
|
id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
|
|
time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL
|
|
)
|
|
`)
|
|
db.exec(`
|
|
CREATE TABLE part (
|
|
id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL, time_created INTEGER,
|
|
time_updated INTEGER, data TEXT NOT NULL
|
|
)
|
|
`)
|
|
db.close()
|
|
return dbPath
|
|
}
|
|
|
|
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
|
|
const { DatabaseSync: Database } = require('node:sqlite')
|
|
const db = new Database(dbPath)
|
|
fn(db)
|
|
db.close()
|
|
}
|
|
|
|
function insertSession(
|
|
db: TestDb,
|
|
id: string,
|
|
opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null } = {},
|
|
): void {
|
|
db.prepare(`
|
|
INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_archived, parent_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(id, 'proj-1', 'slug-1', opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', '1.0', 1700000000000, opts.archived ?? null, opts.parentId ?? null)
|
|
}
|
|
|
|
type MessageFixture = {
|
|
role: string
|
|
modelID?: string
|
|
providerID?: string
|
|
cost?: number
|
|
tokens?: {
|
|
input: number
|
|
output: number
|
|
reasoning: number
|
|
cache: { read: number; write: number }
|
|
}
|
|
}
|
|
|
|
type PartFixture = {
|
|
type: string
|
|
text?: string
|
|
tool?: string
|
|
state?: { status: string; input: { command?: string } }
|
|
}
|
|
|
|
function insertMessage(db: TestDb, id: string, sessionId: string, timeCreated: number, data: MessageFixture): void {
|
|
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`)
|
|
.run(id, sessionId, timeCreated, JSON.stringify(data))
|
|
}
|
|
|
|
function insertPart(db: TestDb, id: string, messageId: string, sessionId: string, data: PartFixture): void {
|
|
db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`)
|
|
.run(id, messageId, sessionId, JSON.stringify(data))
|
|
}
|
|
|
|
async function collectCalls(provider: ReturnType<typeof createOpenCodeProvider>, dbPath: string, sessionId: string, seenKeys?: Set<string>): Promise<ParsedProviderCall[]> {
|
|
const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: 'opencode' }
|
|
const calls: ParsedProviderCall[] = []
|
|
for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) {
|
|
calls.push(call)
|
|
}
|
|
return calls
|
|
}
|
|
|
|
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
|
|
|
skipUnlessSqlite('opencode provider - model display names', () => {
|
|
it('strips provider prefix and delegates to shared lookup', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
|
|
})
|
|
|
|
it('strips google provider prefix', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro')
|
|
})
|
|
|
|
it('strips openai provider prefix', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.modelDisplayName('openai/gpt-4o')).toBe('GPT-4o')
|
|
})
|
|
|
|
it('passes through models without prefix unchanged', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.modelDisplayName('gpt-4o')).toBe('GPT-4o')
|
|
expect(provider.modelDisplayName('gpt-4o-mini')).toBe('GPT-4o Mini')
|
|
})
|
|
|
|
it('returns unknown models as-is', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.modelDisplayName('big-pickle')).toBe('big-pickle')
|
|
})
|
|
|
|
it('has correct displayName', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.displayName).toBe('OpenCode')
|
|
expect(provider.name).toBe('opencode')
|
|
})
|
|
})
|
|
|
|
skipUnlessSqlite('opencode provider - tool display names', () => {
|
|
it('maps opencode builtins', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
|
expect(provider.toolDisplayName('edit')).toBe('Edit')
|
|
expect(provider.toolDisplayName('task')).toBe('Agent')
|
|
expect(provider.toolDisplayName('fetch')).toBe('WebFetch')
|
|
expect(provider.toolDisplayName('grep')).toBe('Grep')
|
|
expect(provider.toolDisplayName('write')).toBe('Write')
|
|
expect(provider.toolDisplayName('skill')).toBe('Skill')
|
|
})
|
|
|
|
it('returns unknown tools as-is', () => {
|
|
const provider = createOpenCodeProvider()
|
|
expect(provider.toolDisplayName('github_search_code')).toBe('github_search_code')
|
|
})
|
|
})
|
|
|
|
skipUnlessSqlite('opencode provider - session discovery', () => {
|
|
it('discovers sessions with correct path format', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.provider).toBe('opencode')
|
|
expect(sessions[0]!.project).toBe('home-user-myproject')
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:sess-1`)
|
|
})
|
|
|
|
it('discovers archived sessions (#1362: the archive is organizational, the rows stay)', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-archived', { archived: 1700000001000 })
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:sess-archived`)
|
|
})
|
|
|
|
it('excludes child sessions', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-child', { parentId: 'parent-id' })
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(0)
|
|
})
|
|
|
|
it('returns empty for non-existent path', async () => {
|
|
const provider = createOpenCodeProvider('/nonexistent/path')
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toEqual([])
|
|
})
|
|
|
|
it('returns empty for empty database', async () => {
|
|
createTestDb(tmpDir)
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toEqual([])
|
|
})
|
|
|
|
it('discovers sessions across multiple channel databases', async () => {
|
|
const ocDir = join(tmpDir, 'opencode')
|
|
await mkdir(ocDir, { recursive: true })
|
|
|
|
const { DatabaseSync: Database } = require('node:sqlite')
|
|
for (const file of ['opencode.db', 'opencode-dev.db']) {
|
|
const dbPath = join(ocDir, file)
|
|
const db = new Database(dbPath)
|
|
db.exec(`
|
|
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
|
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
|
|
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, time_archived INTEGER)
|
|
`)
|
|
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
|
|
time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
|
|
db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
|
|
db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(`sess-${file}`, 'proj-1', 'slug-1', '/home/user/myproject', 'My Project', '1.0', 1700000000000)
|
|
db.close()
|
|
}
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(2)
|
|
expect(sessions.map(s => s.path)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('opencode.db:sess-opencode.db'),
|
|
expect.stringContaining('opencode-dev.db:sess-opencode-dev.db'),
|
|
]),
|
|
)
|
|
expect(sessions.every(s => s.provider === 'opencode')).toBe(true)
|
|
})
|
|
|
|
it('ignores non-opencode db files in the directory', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
})
|
|
await writeFile(join(tmpDir, 'opencode', 'other.db'), '')
|
|
await writeFile(join(tmpDir, 'opencode', 'opencode.txt'), '')
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(1)
|
|
})
|
|
|
|
it('sanitizes title when directory is empty', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1', { directory: '', title: 'My Session Title' })
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions[0]!.project).toBe('My Session Title')
|
|
})
|
|
|
|
it('discovers multiple sessions in one database', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1', { directory: '/home/user/project-a', title: 'A' })
|
|
insertSession(db, 'sess-2', { directory: '/home/user/project-b', title: 'B' })
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
skipUnlessSqlite('opencode provider - session parsing', () => {
|
|
it('parses assistant messages with all fields', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'fix the login bug' })
|
|
|
|
insertMessage(db, 'msg-2', 'sess-1', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } },
|
|
})
|
|
insertPart(db, 'part-2', 'msg-2', 'sess-1', {
|
|
type: 'tool', tool: 'bash',
|
|
state: { status: 'completed', input: { command: 'npm test && git push' } },
|
|
})
|
|
insertPart(db, 'part-3', 'msg-2', 'sess-1', {
|
|
type: 'tool', tool: 'edit', state: { status: 'completed', input: {} },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const calls = await collectCalls(provider, dbPath, 'sess-1')
|
|
|
|
expect(calls).toHaveLength(1)
|
|
const call = calls[0]!
|
|
expect(call.provider).toBe('opencode')
|
|
expect(call.model).toBe('claude-opus-4-6')
|
|
expect(call.inputTokens).toBe(100)
|
|
expect(call.outputTokens).toBe(200)
|
|
expect(call.reasoningTokens).toBe(50)
|
|
expect(call.cacheReadInputTokens).toBe(500)
|
|
expect(call.cacheCreationInputTokens).toBe(300)
|
|
expect(call.cachedInputTokens).toBe(500)
|
|
expect(call.webSearchRequests).toBe(0)
|
|
expect(call.speed).toBe('standard')
|
|
expect(call.costUSD).toBeGreaterThan(0)
|
|
expect(call.tools).toEqual(['Bash', 'Edit'])
|
|
expect(call.bashCommands).toEqual(['npm', 'git'])
|
|
expect(call.userMessage).toBe('fix the login bug')
|
|
expect(call.sessionId).toBe('sess-1')
|
|
expect(call.timestamp).toBe(new Date(1700000001000).toISOString())
|
|
expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2')
|
|
})
|
|
|
|
it('carries the exact OpenRouter provider field as the billing route', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-openrouter')
|
|
insertMessage(db, 'msg-openrouter', 'sess-openrouter', 1700000001000, {
|
|
role: 'assistant',
|
|
providerID: 'openrouter',
|
|
modelID: 'cohere/north-mini-code:free',
|
|
cost: 0,
|
|
tokens: { input: 22_928, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-openrouter', 'msg-openrouter', 'sess-openrouter', { type: 'text', text: 'route test' })
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-openrouter')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]).toMatchObject({
|
|
provider: 'opencode',
|
|
model: 'cohere/north-mini-code:free',
|
|
route: 'openrouter',
|
|
costUSD: 0,
|
|
})
|
|
})
|
|
|
|
it('carries the exact Amazon Bedrock field without relying on a model-id shape', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-amazon-bedrock')
|
|
insertMessage(db, 'msg-amazon-bedrock', 'sess-amazon-bedrock', 1700000001000, {
|
|
role: 'assistant',
|
|
providerID: 'amazon-bedrock',
|
|
modelID: 'amazon.nova-2-lite-v1:0',
|
|
cost: 0.01,
|
|
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-amazon-bedrock')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]).toMatchObject({
|
|
provider: 'opencode',
|
|
model: 'amazon.nova-2-lite-v1:0',
|
|
route: 'bedrock',
|
|
})
|
|
})
|
|
|
|
it('normalizes opencode MCP tool names for shared MCP reporting', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'look up the ClickUp task' })
|
|
|
|
insertMessage(db, 'msg-2', 'sess-1', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-2', 'msg-2', 'sess-1', {
|
|
type: 'tool',
|
|
tool: 'clickup_clickup_get_task',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
insertPart(db, 'part-3', 'msg-2', 'sess-1', {
|
|
type: 'tool',
|
|
tool: 'figma_get_file',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.tools).toEqual([
|
|
'mcp__clickup__clickup_get_task',
|
|
'mcp__figma__get_file',
|
|
])
|
|
})
|
|
|
|
it('preserves already-normalized MCP tool names', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
|
type: 'tool',
|
|
tool: 'mcp__github__search_code',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.tools).toEqual(['mcp__github__search_code'])
|
|
})
|
|
|
|
it('keeps extension tool names without a server prefix as regular tools', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
|
type: 'tool',
|
|
tool: 'customtool',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.tools).toEqual(['customtool'])
|
|
})
|
|
|
|
it('keeps malformed server-prefixed tool names as regular tools', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
|
type: 'tool',
|
|
tool: '_missing_server',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
insertPart(db, 'part-2', 'msg-1', 'sess-1', {
|
|
type: 'tool',
|
|
tool: 'missing_',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
insertPart(db, 'part-3', 'msg-1', 'sess-1', {
|
|
type: 'tool',
|
|
tool: '_',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.tools).toEqual([
|
|
'_missing_server',
|
|
'missing_',
|
|
'_',
|
|
])
|
|
})
|
|
|
|
it('skips zero-token messages with zero cost', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps zero-usage assistant messages when router responses contain text', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'use the configured router' })
|
|
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', { type: 'text', text: 'router response text' })
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.model).toBe('edenai/router-model')
|
|
expect(calls[0]!.inputTokens).toBe(0)
|
|
expect(calls[0]!.outputTokens).toBe(0)
|
|
expect(calls[0]!.costUSD).toBe(0)
|
|
expect(calls[0]!.userMessage).toBe('use the configured router')
|
|
})
|
|
|
|
it('keeps zero-usage assistant messages when router responses contain tool calls', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', {
|
|
type: 'tool', tool: 'bash',
|
|
state: { status: 'completed', input: { command: 'npm test' } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.tools).toEqual(['Bash'])
|
|
expect(calls[0]!.bashCommands).toEqual(['npm'])
|
|
expect(calls[0]!.costUSD).toBe(0)
|
|
})
|
|
|
|
it('deduplicates messages across parses', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const seenKeys = new Set<string>()
|
|
const calls1 = await collectCalls(provider, dbPath, 'sess-1', seenKeys)
|
|
const calls2 = await collectCalls(provider, dbPath, 'sess-1', seenKeys)
|
|
|
|
expect(calls1).toHaveLength(1)
|
|
expect(calls2).toHaveLength(0)
|
|
expect(seenKeys.has('opencode:sess-1:msg-1')).toBe(true)
|
|
})
|
|
|
|
it('falls back to pre-calculated cost for unknown models', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.costUSD).toBe(0.42)
|
|
})
|
|
|
|
it('uses calculated cost over pre-calculated for known models', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 999.99,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
|
expect(calls[0]!.costUSD).not.toBe(999.99)
|
|
})
|
|
|
|
it('handles missing tokens field gracefully', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.10,
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.inputTokens).toBe(0)
|
|
expect(calls[0]!.outputTokens).toBe(0)
|
|
expect(calls[0]!.costUSD).toBe(0.10)
|
|
})
|
|
|
|
it('uses "unknown" for missing modelID', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.model).toBe('unknown')
|
|
})
|
|
|
|
it('handles corrupt JSON in message and part data', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
|
|
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`)
|
|
.run('msg-corrupt', 'sess-1', 1700000000500, 'not valid json {]')
|
|
|
|
insertMessage(db, 'msg-valid', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
|
|
db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`)
|
|
.run('part-corrupt', 'msg-valid', 'sess-1', 'corrupt {[}')
|
|
|
|
insertPart(db, 'part-valid', 'msg-valid', 'sess-1', {
|
|
type: 'tool', tool: 'bash', state: { status: 'completed', input: {} },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.model).toBe('claude-opus-4-6')
|
|
expect(calls[0]!.tools).toEqual(['Bash'])
|
|
})
|
|
|
|
it('converts seconds-epoch timestamps to milliseconds', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.timestamp).toBe(new Date(1700000001 * 1000).toISOString())
|
|
})
|
|
|
|
it('skips non-user non-assistant roles', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'system', modelID: 'claude-opus-4-6',
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
it('returns empty for invalid db path', async () => {
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const source = { path: '/nonexistent/db.db:sess-1', project: 'test', provider: 'opencode' }
|
|
const calls: ParsedProviderCall[] = []
|
|
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
it('tracks user messages per assistant response', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
|
|
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'first question' })
|
|
|
|
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01,
|
|
tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
|
|
insertMessage(db, 'msg-u2', 'sess-1', 1700000002000, { role: 'user' })
|
|
insertPart(db, 'part-u2', 'msg-u2', 'sess-1', { type: 'text', text: 'second question' })
|
|
|
|
insertMessage(db, 'msg-a2', 'sess-1', 1700000003000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.02,
|
|
tokens: { input: 80, output: 80, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls[0]!.userMessage).toBe('first question')
|
|
expect(calls[1]!.userMessage).toBe('second question')
|
|
})
|
|
|
|
it('attributes child and grandchild session calls back to the root session', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'root')
|
|
insertSession(db, 'child', { parentId: 'root' })
|
|
insertSession(db, 'grandchild', { parentId: 'child' })
|
|
|
|
insertMessage(db, 'msg-root-user', 'root', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-root-user', 'msg-root-user', 'root', { type: 'text', text: 'root prompt' })
|
|
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.01,
|
|
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-root-tool', 'msg-root-assistant', 'root', {
|
|
type: 'tool',
|
|
tool: 'read',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
|
|
insertMessage(db, 'msg-child-user', 'child', 1700000002000, { role: 'user' })
|
|
insertPart(db, 'part-child-user', 'msg-child-user', 'child', { type: 'text', text: 'child prompt' })
|
|
insertMessage(db, 'msg-child-assistant', 'child', 1700000003000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.02,
|
|
tokens: { input: 30, output: 40, reasoning: 5, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-child-tool', 'msg-child-assistant', 'child', {
|
|
type: 'tool',
|
|
tool: 'task',
|
|
state: { status: 'completed', input: {} },
|
|
})
|
|
|
|
insertMessage(db, 'msg-grand-user', 'grandchild', 1700000004000, { role: 'user' })
|
|
insertPart(db, 'part-grand-user', 'msg-grand-user', 'grandchild', { type: 'text', text: 'grandchild prompt' })
|
|
insertMessage(db, 'msg-grand-assistant', 'grandchild', 1700000005000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.03,
|
|
tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-grand-tool', 'msg-grand-assistant', 'grandchild', {
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
state: { status: 'completed', input: { command: 'npm test' } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
|
|
|
|
expect(calls).toHaveLength(3)
|
|
expect(calls.map(call => call.sessionId)).toEqual(['root', 'root', 'root'])
|
|
expect(calls.map(call => call.deduplicationKey)).toEqual([
|
|
'opencode:root:msg-root-assistant',
|
|
'opencode:child:msg-child-assistant',
|
|
'opencode:grandchild:msg-grand-assistant',
|
|
])
|
|
expect(calls.map(call => call.userMessage)).toEqual([
|
|
'root prompt',
|
|
'child prompt',
|
|
'grandchild prompt',
|
|
])
|
|
expect(calls[0]!.tools).toEqual(['Read'])
|
|
expect(calls[1]!.tools).toEqual(['Agent'])
|
|
expect(calls[2]!.tools).toEqual(['Bash'])
|
|
expect(calls[2]!.bashCommands).toEqual(['npm'])
|
|
})
|
|
|
|
it('includes archived child sessions in the root subtree (#1362)', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'root')
|
|
insertSession(db, 'archived-child', { parentId: 'root', archived: 1700000002500 })
|
|
|
|
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.01,
|
|
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
|
|
insertMessage(db, 'msg-child-assistant', 'archived-child', 1700000003000, {
|
|
role: 'assistant',
|
|
modelID: 'claude-opus-4-6',
|
|
cost: 0.02,
|
|
tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
|
|
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls[0]!.deduplicationKey).toBe('opencode:root:msg-root-assistant')
|
|
expect(calls[1]!.deduplicationKey).toBe('opencode:archived-child:msg-child-assistant')
|
|
})
|
|
|
|
it('joins multiple text parts in user messages', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
|
|
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-a', 'msg-u1', 'sess-1', { type: 'text', text: 'hello' })
|
|
insertPart(db, 'part-b', 'msg-u1', 'sess-1', { type: 'text', text: 'world' })
|
|
|
|
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01,
|
|
tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls[0]!.userMessage).toBe('hello world')
|
|
})
|
|
|
|
it('yields nothing for session with only user messages', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
|
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'hello?' })
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
it('falls back to session-level tokens when per-message data yields nothing', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
db.exec(`ALTER TABLE session ADD COLUMN cost REAL`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_input INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_output INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_reasoning INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_read INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_write INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN model TEXT`)
|
|
|
|
insertSession(db, 'sess-1')
|
|
db.prepare(`UPDATE session SET cost = ?, tokens_input = ?, tokens_output = ?, tokens_reasoning = ?, tokens_cache_read = ?, tokens_cache_write = ?, model = ? WHERE id = ?`)
|
|
.run(0.15, 5000, 2000, 0, 3000, 1000, JSON.stringify({
|
|
providerID: 'anthropic',
|
|
id: 'claude-sonnet-4-20250514',
|
|
variant: 'high',
|
|
}), 'sess-1')
|
|
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-sonnet-4-20250514',
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.inputTokens).toBe(5000)
|
|
expect(calls[0]!.outputTokens).toBe(2000)
|
|
expect(calls[0]!.cacheReadInputTokens).toBe(3000)
|
|
expect(calls[0]!.cacheCreationInputTokens).toBe(1000)
|
|
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
|
expect(calls[0]!.model).toBe('claude-sonnet-4-20250514')
|
|
expect(calls[0]!.deduplicationKey).toBe('opencode:sess-1:session-level')
|
|
})
|
|
|
|
it('bills session-level reasoning tokens at the output rate', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
db.exec(`ALTER TABLE session ADD COLUMN cost REAL`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_input INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_output INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_reasoning INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_read INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_write INTEGER`)
|
|
db.exec(`ALTER TABLE session ADD COLUMN model TEXT`)
|
|
|
|
insertSession(db, 'sess-1')
|
|
db.prepare(`UPDATE session SET cost = ?, tokens_input = ?, tokens_output = ?, tokens_reasoning = ?, tokens_cache_read = ?, tokens_cache_write = ?, model = ? WHERE id = ?`)
|
|
.run(0, 5000, 2000, 4000, 3000, 1000, JSON.stringify({
|
|
providerID: 'anthropic',
|
|
id: 'claude-sonnet-4-20250514',
|
|
}), 'sess-1')
|
|
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-sonnet-4-20250514',
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.reasoningTokens).toBe(4000)
|
|
// OpenCode charges reasoning tokens at the output rate, and
|
|
// billableOutputTokens says the same for this provider, so the session-level
|
|
// fallback has to price output + reasoning, not output alone. (#1334)
|
|
const model = 'anthropic/claude-sonnet-4-20250514'
|
|
expect(calls[0]!.costUSD).toBeCloseTo(calculateCost(model, 5000, 2000 + 4000, 1000, 3000, 0), 10)
|
|
expect(calls[0]!.costUSD).toBeGreaterThan(calculateCost(model, 5000, 2000, 1000, 3000, 0))
|
|
})
|
|
|
|
it('bills per-message reasoning tokens at the output rate', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-sonnet-4-20250514', cost: 0,
|
|
tokens: { input: 1000, output: 200, reasoning: 500, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'done' })
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.reasoningTokens).toBe(500)
|
|
expect(calls[0]!.costUSD).toBeCloseTo(calculateCost('claude-sonnet-4-20250514', 1000, 200 + 500, 0, 0, 0), 10)
|
|
})
|
|
|
|
it('accepts role "model" as equivalent to "assistant"', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'model', modelID: 'gemini-2.5-pro', cost: 0.03,
|
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
} as any)
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.model).toBe('gemini-2.5-pro')
|
|
})
|
|
|
|
it('recognizes tool-call and tool_call part types', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6',
|
|
})
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
|
type: 'tool-call', tool: 'bash',
|
|
state: { status: 'completed', input: { command: 'ls' } },
|
|
} as any)
|
|
insertPart(db, 'part-2', 'msg-1', 'sess-1', {
|
|
type: 'tool_call', tool: 'edit',
|
|
state: { status: 'completed', input: {} },
|
|
} as any)
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.tools).toEqual(['Bash', 'Edit'])
|
|
})
|
|
|
|
it('counts reasoning/file parts as activity even without text or tool parts', async () => {
|
|
const dbPath = createTestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-1')
|
|
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
|
role: 'assistant', modelID: 'claude-opus-4-6',
|
|
})
|
|
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
|
type: 'reasoning',
|
|
} as any)
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.costUSD).toBe(0)
|
|
expect(calls[0]!.tools).toEqual([])
|
|
})
|
|
})
|
|
|
|
skipUnlessSqlite('opencode provider - env override discovery', () => {
|
|
// Builds a renamed/forked OpenCode-compatible DB at <root>/<subdir>/<prefix>.db
|
|
// (NOT under an 'opencode' subdir), mirroring a real fork like MiMoCode writing
|
|
// ~/.local/share/mimicode/mimicode.db with the same Drizzle schema.
|
|
function createForkDb(dbPath: string, sessionId: string): void {
|
|
const { DatabaseSync: Database } = require('node:sqlite')
|
|
const db = new Database(dbPath)
|
|
db.exec(`
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
|
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
|
|
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER,
|
|
time_archived INTEGER
|
|
)
|
|
`)
|
|
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
|
|
db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
|
|
db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(sessionId, 'proj-1', 'slug-1', '/home/user/mimoproject', 'MiMo Project', '1.0', 1700000000000)
|
|
db.close()
|
|
}
|
|
|
|
it('discovers a renamed fork DB via OPENCODE_DATA_DIR + OPENCODE_DB_PREFIX', async () => {
|
|
const forkDir = join(tmpDir, 'mimocode')
|
|
await mkdir(forkDir, { recursive: true })
|
|
const dbPath = join(forkDir, 'mimicode.db')
|
|
createForkDb(dbPath, 'sess-mimo')
|
|
|
|
process.env.OPENCODE_DATA_DIR = forkDir
|
|
process.env.OPENCODE_DB_PREFIX = 'mimicode'
|
|
|
|
const provider = createOpenCodeProvider() // no arg — must read env
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.provider).toBe('opencode')
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:sess-mimo`)
|
|
})
|
|
|
|
it('default discovery still finds opencode/opencode*.db via XDG_DATA_HOME when override is unset', async () => {
|
|
const dbPath = createTestDb(tmpDir) // creates tmpDir/opencode/opencode.db
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-open')
|
|
})
|
|
|
|
delete process.env.OPENCODE_DATA_DIR
|
|
delete process.env.OPENCODE_DB_PREFIX
|
|
process.env.XDG_DATA_HOME = tmpDir
|
|
|
|
const provider = createOpenCodeProvider() // no arg
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:sess-open`)
|
|
})
|
|
|
|
it('treats an empty OPENCODE_DB_PREFIX as unset, discovering opencode.db but not arbitrary other DBs', async () => {
|
|
// Regression for issue #617 follow-up: `OPENCODE_DB_PREFIX=''` (empty
|
|
// string, not undefined) must fall back to the default 'opencode' prefix.
|
|
// The default DB (matches 'opencode') carries a real session.
|
|
const dbPath = createTestDb(tmpDir) // tmpDir/opencode/opencode.db
|
|
withTestDb(dbPath, (db) => {
|
|
insertSession(db, 'sess-open')
|
|
})
|
|
|
|
// Discriminating fixture: a sibling DB whose name does NOT start with
|
|
// 'opencode' but which has a VALID opencode schema + session row. Schema
|
|
// validation cannot exclude it (the schema is valid), so only the prefix
|
|
// filter can. With the empty-prefix bug, ''.startsWith('') is true for
|
|
// every filename, so random.db would be swept into discovery alongside
|
|
// opencode.db (length 2). The default 'opencode' prefix must filter it
|
|
// out (length 1).
|
|
createForkDb(join(tmpDir, 'opencode', 'random.db'), 'sess-random')
|
|
|
|
process.env.OPENCODE_DB_PREFIX = '' // empty string, NOT undefined
|
|
delete process.env.OPENCODE_DATA_DIR
|
|
process.env.XDG_DATA_HOME = tmpDir
|
|
|
|
const provider = createOpenCodeProvider() // no arg — reads env
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:sess-open`)
|
|
})
|
|
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenCode 2.x (session_v2 + session_message) — issue #1293.
|
|
// ejwill's 2.0.3 schema: session_v2 carries the session row (with the same
|
|
// cost/tokens columns legacy had), session_message is tagged by `type` with
|
|
// the payload JSON in `data`, ordered by `seq`. Legacy tables freeze at
|
|
// upgrade; the generations are never joined.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type V2MessageFixture = {
|
|
text?: string
|
|
agent?: string
|
|
model?: { id: string; providerID: string; variant?: string }
|
|
content?: Array<{ type: string; text?: string; name?: string; state?: { status: string; input?: Record<string, unknown> } }>
|
|
cost?: number
|
|
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
|
time?: { created: number }
|
|
}
|
|
|
|
function createV2TestDb(dir: string, opts: { withLegacy?: boolean } = {}): string {
|
|
const ocDir = join(dir, 'opencode')
|
|
mkdirSync(ocDir, { recursive: true })
|
|
const dbPath = join(ocDir, 'opencode.db')
|
|
|
|
const { DatabaseSync: Database } = require('node:sqlite')
|
|
const db = new Database(dbPath)
|
|
db.exec(`
|
|
CREATE TABLE session_v2 (
|
|
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
|
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT, version TEXT NOT NULL,
|
|
cost REAL NOT NULL DEFAULT 0, tokens_input INTEGER NOT NULL DEFAULT 0,
|
|
tokens_output INTEGER NOT NULL DEFAULT 0, tokens_reasoning INTEGER NOT NULL DEFAULT 0,
|
|
tokens_cache_read INTEGER NOT NULL DEFAULT 0, tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
|
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
|
|
time_archived INTEGER
|
|
)
|
|
`)
|
|
db.exec(`
|
|
CREATE TABLE session_message (
|
|
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL,
|
|
seq INTEGER NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
|
|
data TEXT NOT NULL
|
|
)
|
|
`)
|
|
if (opts.withLegacy) {
|
|
// An in-place upgrade keeps the frozen legacy tables around, empty.
|
|
db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT, version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, time_archived INTEGER)`)
|
|
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER, data TEXT NOT NULL)`)
|
|
db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, data TEXT NOT NULL)`)
|
|
}
|
|
db.close()
|
|
return dbPath
|
|
}
|
|
|
|
function insertV2Session(
|
|
db: TestDb,
|
|
id: string,
|
|
opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null; model?: string } = {},
|
|
): void {
|
|
db.prepare(`
|
|
INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived, model)
|
|
VALUES (?, 'proj-1', ?, 'slug-1', ?, ?, '2.0.3', 1700000000000, 1700000000000, ?, ?)
|
|
`).run(id, opts.parentId ?? null, opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', opts.archived ?? null, opts.model ?? null)
|
|
}
|
|
|
|
function insertV2Message(db: TestDb, id: string, sessionId: string, type: string, seq: number, timeCreated: number, payload: V2MessageFixture): void {
|
|
db.prepare(`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(id, sessionId, type, seq, timeCreated, timeCreated, JSON.stringify(payload))
|
|
}
|
|
|
|
skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_message)', () => {
|
|
it('discovers and parses v2 sessions with tokens, cost, model, tools and user attribution', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertV2Session(db, 'ses_v2_1')
|
|
insertV2Message(db, 'msg_1', 'ses_v2_1', 'user', 1, 1700000000000, { text: 'fix the login bug' })
|
|
insertV2Message(db, 'msg_2', 'ses_v2_1', 'assistant', 2, 1700000000123, {
|
|
agent: 'build',
|
|
model: { id: 'glm-5.3-flash', providerID: 'opencode', variant: 'max' },
|
|
content: [
|
|
{ type: 'text', text: 'looking into it' },
|
|
{ type: 'tool', name: 'bash', state: { status: 'completed', input: { command: 'grep -rn login src/' } } },
|
|
],
|
|
cost: 0.0077,
|
|
tokens: { input: 21410, output: 2175, reasoning: 0, cache: { read: 112128, write: 0 } },
|
|
time: { created: 1700000000123 },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:ses_v2_1`)
|
|
expect(sessions[0]!.project).toBe('home-user-myproject')
|
|
|
|
const calls = await collectCalls(provider, dbPath, 'ses_v2_1')
|
|
expect(calls).toHaveLength(1)
|
|
const call = calls[0]!
|
|
expect(call.model).toBe('glm-5.3-flash')
|
|
expect(call.inputTokens).toBe(21410)
|
|
expect(call.outputTokens).toBe(2175)
|
|
expect(call.cacheReadInputTokens).toBe(112128)
|
|
expect(call.tools).toContain('Bash')
|
|
expect(call.bashCommands).toEqual(['grep'])
|
|
expect(call.userMessage).toBe('fix the login bug')
|
|
expect(call.costUSD).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('reads a real OpenCode 2.0.16 session (tool calls, reasoning, cache reads)', async () => {
|
|
// Rows written by `opencode run` 2.0.16 against the free opencode/big-pickle
|
|
// model; paths redacted, tool output trimmed.
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertV2Session(db, 'ses_f2cbda7f0ffevXtdfk20qxO7K9', { directory: '/home/user/proj', title: 'toolcall' })
|
|
insertV2Message(db, 'msg_0d3425814001k3LGlAKci24D6g', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'user', 5, 1790250735642, {
|
|
time: { created: 1790250735642 }, text: '"Read the file notes.txt and tell me its contents in one line."',
|
|
})
|
|
insertV2Message(db, 'msg_0d342581b001xOcuI5GMMHjSQR', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'assistant', 6, 1790250735647, {
|
|
agent: 'build',
|
|
model: { id: 'big-pickle', providerID: 'opencode', variant: 'default' },
|
|
content: [
|
|
{ type: 'reasoning', text: 'The user wants me to read notes.txt and tell them its contents in one line. Let me find and read the file.' },
|
|
{ type: 'text', text: '\n\n' },
|
|
{ type: 'tool', name: 'glob', state: { status: 'completed', input: { pattern: '**/notes.txt' } } },
|
|
],
|
|
cost: 0,
|
|
tokens: { input: 6323, output: 72, reasoning: 0, cache: { read: 256, write: 0 } },
|
|
})
|
|
insertV2Message(db, 'msg_0d342716c001bPgSpFdp38REPt', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'assistant', 17, 1790250742127, {
|
|
agent: 'build',
|
|
model: { id: 'big-pickle', providerID: 'opencode', variant: 'default' },
|
|
content: [{ type: 'tool', name: 'read', state: { status: 'completed', input: { path: '/home/user/proj/notes.txt' } } }],
|
|
cost: 0,
|
|
tokens: { input: 318, output: 100, reasoning: 0, cache: { read: 6400, write: 0 } },
|
|
})
|
|
insertV2Message(db, 'msg_0d3427704001UBCn6YD3m0MlCQ', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'assistant', 24, 1790250743559, {
|
|
agent: 'build',
|
|
model: { id: 'big-pickle', providerID: 'opencode', variant: 'default' },
|
|
content: [{ type: 'text', text: 'notes.txt contains: "hello world".' }],
|
|
cost: 0,
|
|
tokens: { input: 243, output: 10, reasoning: 0, cache: { read: 6656, write: 0 } },
|
|
})
|
|
insertV2Message(db, 'msg_0d3427a34001SSfPFcwFekJ10S', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'idle', 29, 1790250744372, {
|
|
time: { created: 1790250744372 },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toEqual([{ path: `${dbPath}:ses_f2cbda7f0ffevXtdfk20qxO7K9`, project: 'home-user-proj', provider: 'opencode' }])
|
|
|
|
const calls = await collectCalls(provider, dbPath, 'ses_f2cbda7f0ffevXtdfk20qxO7K9')
|
|
expect(calls.map((c) => c.tools)).toEqual([['Glob'], ['Read'], []])
|
|
expect(calls.every((c) => c.model === 'big-pickle' && c.costUSD === 0)).toBe(true)
|
|
expect(calls[0]!.timestamp).toBe('2026-09-24T11:52:15.647Z')
|
|
// Matches OpenCode's own session_v2 rollup for this session.
|
|
const sum = (f: (c: ParsedProviderCall) => number) => calls.reduce((t, c) => t + f(c), 0)
|
|
expect(sum((c) => c.inputTokens)).toBe(6884)
|
|
expect(sum((c) => c.outputTokens)).toBe(182)
|
|
expect(sum((c) => c.cacheReadInputTokens)).toBe(13312)
|
|
})
|
|
|
|
it('prices a v2 turn the same as the identical 1.x turn', async () => {
|
|
const tokens = { input: 12_000, output: 800, reasoning: 0, cache: { read: 4_000, write: 0 } }
|
|
const v2Path = createV2TestDb(tmpDir)
|
|
withTestDb(v2Path, (db) => {
|
|
insertV2Session(db, 'ses_v2')
|
|
insertV2Message(db, 'msg_v2', 'ses_v2', 'assistant', 1, 1700000000000, {
|
|
model: { id: 'claude-haiku-4-5', providerID: 'opencode' },
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
cost: 0,
|
|
tokens,
|
|
})
|
|
})
|
|
const [v2Call] = await collectCalls(createOpenCodeProvider(tmpDir), v2Path, 'ses_v2')
|
|
|
|
const legacyDir = join(tmpDir, 'legacy')
|
|
const legacyPath = createTestDb(legacyDir)
|
|
withTestDb(legacyPath, (db) => {
|
|
insertSession(db, 'ses_v1')
|
|
insertMessage(db, 'msg_v1', 'ses_v1', 1700000000000, {
|
|
role: 'assistant', modelID: 'claude-haiku-4-5', providerID: 'opencode', cost: 0, tokens,
|
|
})
|
|
})
|
|
const [legacyCall] = await collectCalls(createOpenCodeProvider(legacyDir), legacyPath, 'ses_v1')
|
|
|
|
expect(legacyCall!.costUSD).toBeGreaterThan(0)
|
|
expect(v2Call!.costUSD).toBe(legacyCall!.costUSD)
|
|
expect(v2Call!.model).toBe(legacyCall!.model)
|
|
})
|
|
|
|
it('preserves the OpenRouter provider field in v2 messages', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertV2Session(db, 'ses_v2_openrouter')
|
|
insertV2Message(db, 'msg_v2_openrouter', 'ses_v2_openrouter', 'assistant', 1, 1700000000123, {
|
|
model: { id: 'cohere/north-mini-code:free', providerID: 'openrouter' },
|
|
content: [{ type: 'text', text: 'route test' }],
|
|
cost: 0,
|
|
tokens: { input: 22_928, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_v2_openrouter')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]).toMatchObject({
|
|
model: 'cohere/north-mini-code:free',
|
|
route: 'openrouter',
|
|
costUSD: 0,
|
|
})
|
|
})
|
|
|
|
it('counts compaction usage and skips rows with nothing to report', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertV2Session(db, 'ses_v2_1')
|
|
insertV2Message(db, 'msg_1', 'ses_v2_1', 'model-switched', 1, 1700000000000, { model: { id: 'x', providerID: 'opencode' } })
|
|
// CompactionUsage travels on the compaction row itself; 1.x counted
|
|
// these as assistant messages, so dropping them undercounts compactions.
|
|
insertV2Message(db, 'msg_2', 'ses_v2_1', 'compaction', 2, 1700000000100, {
|
|
status: 'completed', reason: 'auto', summary: 's', recent: 'r',
|
|
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
|
cost: 0.0042,
|
|
tokens: { input: 18100, output: 900, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
// A still-running compaction carries no usage and must yield nothing.
|
|
insertV2Message(db, 'msg_3', 'ses_v2_1', 'compaction', 3, 1700000000150, {
|
|
status: 'running', reason: 'auto', summary: 's', recent: 'r',
|
|
})
|
|
insertV2Message(db, 'msg_4', 'ses_v2_1', 'idle', 4, 1700000000200, { outcome: 'succeeded' })
|
|
insertV2Message(db, 'msg_5', 'ses_v2_1', 'assistant', 5, 1700000000300, {
|
|
model: { id: 'gpt-4o', providerID: 'openai' },
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const calls = await collectCalls(provider, dbPath, 'ses_v2_1')
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls[0]!.model).toBe('glm-5.3-flash')
|
|
expect(calls[0]!.inputTokens).toBe(18100)
|
|
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
|
expect(calls[1]!.model).toBe('gpt-4o')
|
|
})
|
|
|
|
it('walks v2 child sessions through parent_id', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertV2Session(db, 'ses_parent')
|
|
insertV2Session(db, 'ses_child', { parentId: 'ses_parent' })
|
|
insertV2Message(db, 'msg_c1', 'ses_child', 'assistant', 1, 1700000000500, {
|
|
model: { id: 'm', providerID: 'opencode' },
|
|
content: [{ type: 'text', text: 'child work' }],
|
|
tokens: { input: 7, output: 3, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
// Only the root is discovered; the child is reached through the tree walk.
|
|
expect(sessions.map((s) => s.path)).toEqual([`${dbPath}:ses_parent`])
|
|
|
|
const calls = await collectCalls(provider, dbPath, 'ses_parent')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.sessionId).toBe('ses_parent')
|
|
expect(calls[0]!.inputTokens).toBe(7)
|
|
})
|
|
|
|
it('walks archived v2 child sessions too (#1362: the archive is organizational)', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertV2Session(db, 'ses_parent')
|
|
insertV2Session(db, 'ses_archived_child', { parentId: 'ses_parent', archived: 1700000009000 })
|
|
insertV2Message(db, 'msg_ac1', 'ses_archived_child', 'assistant', 1, 1700000000500, {
|
|
model: { id: 'm', providerID: 'opencode' },
|
|
content: [{ type: 'text', text: 'archived child work' }],
|
|
tokens: { input: 11, output: 4, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_parent')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.inputTokens).toBe(11)
|
|
})
|
|
|
|
it('unions legacy and v2 turns on an upgraded DB, counting each turn once (#1293)', async () => {
|
|
const dbPath = createV2TestDb(tmpDir, { withLegacy: true })
|
|
withTestDb(dbPath, (db) => {
|
|
// Live v2 session.
|
|
insertV2Session(db, 'ses_v2_live')
|
|
insertV2Message(db, 'msg_1', 'ses_v2_live', 'assistant', 1, 1700000000000, {
|
|
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
|
content: [{ type: 'text', text: 'live v2 turn' }],
|
|
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
|
|
// Three frozen pre-upgrade sessions that never migrated into session_v2.
|
|
for (const [i, id] of ['ses_frozen_1', 'ses_frozen_2', 'ses_frozen_3'].entries()) {
|
|
insertSession(db, id, { title: `frozen ${i + 1}` })
|
|
insertMessage(db, `lmsg_${id}`, id, 1690000000000 + i, {
|
|
role: 'assistant', modelID: 'opencode/old',
|
|
tokens: { input: (i + 1) * 10, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
}
|
|
|
|
// A migrated session continued on 2.x. OpenCode's migration copies each
|
|
// assistant turn under its legacy id, drops the compaction summary turn,
|
|
// and the post-upgrade turn exists only in session_message.
|
|
insertSession(db, 'ses_both', { title: 'migrated' })
|
|
insertMessage(db, 'lmsg_both', 'ses_both', 1690000000500, {
|
|
role: 'assistant', modelID: 'glm-5.3-flash', providerID: 'opencode',
|
|
tokens: { input: 999, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertMessage(db, 'lmsg_summary', 'ses_both', 1690000000600, {
|
|
role: 'assistant', modelID: 'glm-5.3-flash', providerID: 'opencode',
|
|
tokens: { input: 40, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertV2Session(db, 'ses_both')
|
|
insertV2Message(db, 'lmsg_both', 'ses_both', 'assistant', 0, 1690000000500, {
|
|
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
|
content: [{ type: 'text', text: 'migrated copy' }],
|
|
tokens: { input: 999, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertV2Message(db, 'msg_both_new', 'ses_both', 'assistant', 5, 1700000000500, {
|
|
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
|
content: [{ type: 'text', text: 'after the upgrade' }],
|
|
tokens: { input: 500, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
|
|
// Migration stopped before this session: its session_v2 row exists but
|
|
// no session_message rows were written.
|
|
insertSession(db, 'ses_stuck', { title: 'stuck' })
|
|
insertMessage(db, 'lmsg_stuck', 'ses_stuck', 1690000000700, {
|
|
role: 'assistant', modelID: 'glm-5.3-flash', providerID: 'opencode',
|
|
tokens: { input: 70, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
})
|
|
insertV2Session(db, 'ses_stuck')
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions.map((s) => s.path).sort()).toEqual(
|
|
[
|
|
`${dbPath}:ses_both`,
|
|
`${dbPath}:ses_frozen_1`,
|
|
`${dbPath}:ses_frozen_2`,
|
|
`${dbPath}:ses_frozen_3`,
|
|
`${dbPath}:ses_stuck`,
|
|
`${dbPath}:ses_v2_live`,
|
|
].sort(),
|
|
)
|
|
|
|
const seen = new Set<string>()
|
|
let totalInput = 0
|
|
let totalCalls = 0
|
|
for (const s of sessions) {
|
|
const id = s.path.split(':').pop()!
|
|
for (const call of await collectCalls(provider, dbPath, id, seen)) {
|
|
totalInput += call.inputTokens
|
|
totalCalls++
|
|
}
|
|
}
|
|
// 100 + (10 + 20 + 30) + (999 + 40 + 500) + 70
|
|
expect(totalCalls).toBe(8)
|
|
expect(totalInput).toBe(1769)
|
|
|
|
const bothCalls = await collectCalls(provider, dbPath, 'ses_both')
|
|
expect(bothCalls.map((c) => c.inputTokens)).toEqual([999, 40, 500])
|
|
expect(bothCalls.map((c) => c.model)).toEqual(['glm-5.3-flash', 'glm-5.3-flash', 'glm-5.3-flash'])
|
|
})
|
|
|
|
it('keeps fallback model prefixes normalized without accepting malformed routes', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
db.prepare(`
|
|
INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version,
|
|
cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
model, time_created, time_updated, time_archived)
|
|
VALUES ('ses_spaced_provider', 'proj-1', NULL, 'slug-1', '/home/user/myproject', 't', '2.0.3',
|
|
0, 100, 10, 0, 0, 0, ?, 1700000000000, 1700000000000, NULL)
|
|
`).run(JSON.stringify({ id: 'gpt-4o', providerID: ' openai ' }))
|
|
insertV2Message(db, 'msg_spaced_provider', 'ses_spaced_provider', 'assistant', 1, 1700000000100, {
|
|
model: { id: 'gpt-4o', providerID: ' openai ' },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_spaced_provider')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.model).toBe('gpt-4o')
|
|
expect(calls[0]!.route).toBeUndefined()
|
|
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('preserves the OpenRouter route in session-level rollup fallbacks', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
db.prepare(`
|
|
INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version,
|
|
cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
model, time_created, time_updated, time_archived)
|
|
VALUES ('ses_openrouter_roll', 'proj-1', NULL, 'slug-1', '/home/user/myproject', 't', '2.0.3',
|
|
0, 22928, 10, 0, 0, 0, ?, 1700000000000, 1700000000000, NULL)
|
|
`).run(JSON.stringify({ id: 'cohere/north-mini-code:free', providerID: 'openrouter' }))
|
|
insertV2Message(db, 'msg_openrouter_roll', 'ses_openrouter_roll', 'assistant', 1, 1700000000100, {
|
|
model: { id: 'cohere/north-mini-code:free', providerID: 'openrouter' },
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_openrouter_roll')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]).toMatchObject({
|
|
model: 'cohere/north-mini-code:free',
|
|
route: 'openrouter',
|
|
inputTokens: 22_928,
|
|
costUSD: 0,
|
|
})
|
|
})
|
|
|
|
it('prices a Vertex session-level rollup on the bare id and keeps an unpriceable recorded cost as the fallback (#1547)', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
const insert = db.prepare(`
|
|
INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version,
|
|
cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
model, time_created, time_updated, time_archived)
|
|
VALUES (?, 'proj-1', NULL, 'slug-1', '/home/user/myproject', 't', '2.0.16',
|
|
?, 1000, 100, 0, 0, 0, ?, 1700000000000, 1700000000000, NULL)
|
|
`)
|
|
insert.run('ses_vx_sonnet', 0.5, JSON.stringify({ id: 'claude-sonnet-5@default', providerID: 'google-vertex-anthropic' }))
|
|
insert.run('ses_vx_private', 0.5, JSON.stringify({ id: 'vertex-private-model-x@default', providerID: 'google-vertex' }))
|
|
insertV2Message(db, 'msg_vx_1', 'ses_vx_sonnet', 'assistant', 1, 1700000000100, { model: { id: 'claude-sonnet-5@default', providerID: 'google-vertex-anthropic' } })
|
|
insertV2Message(db, 'msg_vx_2', 'ses_vx_private', 'assistant', 1, 1700000000100, { model: { id: 'vertex-private-model-x@default', providerID: 'google-vertex' } })
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const [sonnet] = await collectCalls(provider, dbPath, 'ses_vx_sonnet')
|
|
expect(sonnet).toMatchObject({ model: 'claude-sonnet-5@default', route: 'vertex' })
|
|
expect(sonnet!.costUSD).toBeCloseTo(1000 * 2e-6 + 100 * 10e-6, 12)
|
|
expect(sonnet!.fallbackCostUSD).toBeUndefined()
|
|
|
|
const [priv] = await collectCalls(provider, dbPath, 'ses_vx_private')
|
|
expect(priv).toMatchObject({ model: 'vertex-private-model-x@default', route: 'vertex', costUSD: 0.5, fallbackCostUSD: 0.5 })
|
|
})
|
|
|
|
it('falls back to session_v2 rollups when v2 messages carry no tokens', async () => {
|
|
const dbPath = createV2TestDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
db.prepare(`
|
|
INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version,
|
|
cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
model, time_created, time_updated, time_archived)
|
|
VALUES ('ses_roll', 'proj-1', NULL, 'slug-1', '/home/user/myproject', 't', '2.0.3',
|
|
0.0077, 21410, 2175, 0, 112128, 0, ?, 1700000000000, 1700000000000, NULL)
|
|
`).run(JSON.stringify({ id: 'glm-5.3-flash', providerID: 'opencode' }))
|
|
insertV2Message(db, 'msg_1', 'ses_roll', 'user', 1, 1700000000000, { text: 'hello' })
|
|
insertV2Message(db, 'msg_2', 'ses_roll', 'assistant', 2, 1700000000100, {
|
|
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
|
})
|
|
})
|
|
|
|
const provider = createOpenCodeProvider(tmpDir)
|
|
const calls = await collectCalls(provider, dbPath, 'ses_roll')
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]!.model).toBe('glm-5.3-flash')
|
|
expect(calls[0]!.inputTokens).toBe(21410)
|
|
expect(calls[0]!.cacheReadInputTokens).toBe(112128)
|
|
expect(calls[0]!.costUSD).toBeCloseTo(0.0077, 4)
|
|
})
|
|
})
|