Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced
at 2x base input (previously used the 5-minute 1.25x rate for all writes).
Daily cache bumped to v6 so stale totals are recomputed. Closes #276.
- **OpenCode MCP usage now counted.** OpenCode stores MCP tool calls as
`<server>_<tool>` names, which the shared MCP pipeline did not recognize.
The provider now normalizes these to the canonical `mcp__<server>__<tool>`
form so MCP breakdowns and `optimize` work correctly. Closes #308.

## 0.9.8 - 2026-05-10

Expand Down
14 changes: 9 additions & 5 deletions docs/providers/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ OpenCode (sst/opencode).

- **Source:** `src/providers/opencode.ts`
- **Loading:** lazy (`src/providers/index.ts:59-75`)
- **Test:** `tests/providers/opencode.test.ts` (558 lines, the largest provider test)
- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test)

## Where it reads from

Expand All @@ -20,14 +20,18 @@ None.

## Deduplication

Per `<sessionId>:<messageId>` (`opencode.ts:242`).
Per `<sessionId>:<messageId>`.

## Quirks

- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects (`opencode.ts:104-131`). This is the right behavior; do not silently swallow these.
- Source paths are encoded as `<dbPath>:<sessionId>` (`opencode.ts:147-150`).
- Each message's `parts` are indexed (`opencode.ts:177-191`); preserving the order matters for reasoning-token correctness.
- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these.
- Source paths are encoded as `<dbPath>:<sessionId>`.
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
- External MCP tools are stored as `<server>_<tool>` names (for example
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP
panels and `optimize` findings count OpenCode usage.

## When fixing a bug here

Expand Down
21 changes: 20 additions & 1 deletion src/providers/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ const toolNameMap: Record<string, string> = {
patch: 'Patch',
}

function normalizeToolName(rawTool?: string): string {
if (!rawTool) return ''
if (rawTool.startsWith('mcp__')) return rawTool

const builtIn = toolNameMap[rawTool]
if (builtIn) return builtIn

// OpenCode stores MCP calls as `<server>_<tool>` with no separate server field.
// Built-ins are handled above, and server ids are assumed not to contain `_`.
const serverSeparator = rawTool.indexOf('_')
if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) {
const server = rawTool.slice(0, serverSeparator)
const tool = rawTool.slice(serverSeparator + 1)
return `mcp__${server}__${tool}`
}

return rawTool
}

function sanitize(dir: string): string {
return dir.replace(/^\//, '').replace(/\//g, '-')
}
Expand Down Expand Up @@ -232,7 +251,7 @@ function createParser(
const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool')
const tools = toolParts
.map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '')
.map((p) => normalizeToolName(p.tool))
.filter(Boolean)

const bashCommands = toolParts
Expand Down
118 changes: 118 additions & 0 deletions tests/providers/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,124 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2')
})

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) => {
Expand Down
Loading