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
1 change: 1 addition & 0 deletions docs/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ For the architectural picture, see `../architecture.md`.
| [OpenCode](opencode.md) | SQLite | `src/providers/opencode.ts` | `tests/providers/opencode.test.ts` |
| [Warp](warp.md) | SQLite | `src/providers/warp.ts` | `tests/providers/warp.test.ts` |
| [Vercel AI Gateway](vercel-gateway.md) | REST API | `src/providers/vercel-gateway.ts` | `tests/providers/vercel-gateway.test.ts` |
| [ZCode](zcode.md) | SQLite | `src/providers/zcode.ts` | `tests/providers/zcode.test.ts` |

### Shared

Expand Down
89 changes: 89 additions & 0 deletions docs/providers/zcode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ZCode

ZCode CLI coding agent (z.ai), running GLM-5.2 over the z.ai start-plan.

- **Source:** `src/providers/zcode.ts`
- **Loading:** lazy (`src/providers/index.ts`). Lazy because we read ZCode's SQLite database with `node:sqlite`.
- **Test:** `tests/providers/zcode.test.ts` (3 tests, fixture-based)

## Where it reads from

ZCode keeps a single global SQLite database for the CLI.

| Source | Path |
|---|---|
| ZCode CLI db | `~/.zcode/cli/db/db.sqlite` |

The desktop app dir (`~/Library/Application Support/ZCode`) only holds Electron runtime state, and the JSONL activity log (`~/.zcode/cli/log/*.jsonl`) redacts token counts, so neither is used.

## Storage format

SQLite. Schema verified against CLI db v0.14.8. Three tables matter:

```sql
CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT NOT NULL,
...
);

CREATE TABLE model_usage (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
turn_id TEXT,
model_id TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
completed_at INTEGER,
...
);

CREATE TABLE tool_usage (
session_id TEXT NOT NULL,
turn_id TEXT,
tool_name TEXT NOT NULL,
started_at INTEGER NOT NULL,
...
);
```

## Caching

None at the provider level.

## Deduplication

Per `zcode:<model_usage.id>` (`zcode.ts`). `model_usage.id` is the row primary key, unique per request.

## What we extract

| codeburn field | ZCode source |
|---|---|
| `inputTokens` | `model_usage.input_tokens` minus cached + created (see quirks) |
| `outputTokens` | `model_usage.output_tokens` |
| `reasoningTokens` | `model_usage.reasoning_tokens` |
| `cacheCreationInputTokens` | `model_usage.cache_creation_input_tokens` |
| `cacheReadInputTokens` | `model_usage.cache_read_input_tokens` |
| `costUSD` | computed by `calculateCost` (ZCode stores no cost) |
| `model` | `model_usage.model_id` (e.g. `GLM-5.2`) |
| `timestamp` | `model_usage.completed_at` if set, otherwise `started_at` (epoch ms) |
| `tools` | `tool_usage.tool_name` for the turn, attached to one request per turn |

## Quirks worth knowing

- **Cached tokens are folded into `input_tokens` (OpenAI-style).** The row's `input_tokens` is the full prompt size including cache reads/writes, and `provider_total_tokens = input_tokens + output_tokens`. The parser subtracts `cache_read_input_tokens` and `cache_creation_input_tokens` from `input_tokens` so fresh input bills at the input rate and cached at the cache-read rate. Confirmed against the nested Anthropic usage in `provider_metadata_json` (e.g. 100 input = 36 fresh + 64 cached).
- **No cost is stored anywhere.** GLM-5.2 runs on z.ai's `start-plan` subscription, so ZCode logs tokens only. CodeBurn computes a notional cost from the pricing table.
- **GLM-5.2 is priced via an alias.** LiteLLM does not list GLM-5.2 yet, so `GLM-5.2` maps to `glm-5p1` (GLM-5.1) in `BUILTIN_ALIASES` (`src/models.ts`). Reports therefore show the model as `glm-5p1`, the same way any aliased model displays as its priced-as target. Drop the alias once LiteLLM adds GLM-5.2.
- **Timestamps are milliseconds.** Unlike Crush (seconds), ZCode stores epoch ms; the parser passes them straight to `Date`.
- **Tools are attached per turn, not per request.** `tool_usage` links to a turn, not a specific `model_usage` row, so each turn's tools are attached to its first request to avoid double-counting. Bash command text is not stored, so `bashCommands` is always empty.

## When fixing a bug here

1. Confirm the schema against a real ZCode install; copy `~/.zcode/cli/db/db.sqlite` to a temp file before querying so you do not lock the live db.
2. If costs are $0, check that `GLM-5.2` (or the current model id) still resolves through `BUILTIN_ALIASES` to a priced model.
3. If tokens look ~8x too high, someone likely removed the cache-subtraction in the input normalization; the row's `input_tokens` already includes cached tokens.
4. New fixtures go under the inline schema in `tests/providers/zcode.test.ts`.
3 changes: 3 additions & 0 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@ const BUILTIN_ALIASES: Record<string, string> = {
'gemini-3-pro': 'gemini-3-pro-preview',
'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview',
'gemini-3.1-flash-lite': 'gemini-3.1-flash-lite-preview',
// ZCode runs GLM-5.2 through z.ai's start-plan subscription; it isn't in
// LiteLLM yet. Price as the nearest released sibling (GLM-5.1) until it is.
'GLM-5.2': 'glm-5p1',
}

let userAliases: Record<string, string> = {}
Expand Down
26 changes: 23 additions & 3 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,26 @@ async function loadCrush(): Promise<Provider | null> {
}
}

let zcodeProvider: Provider | null = null
let zcodeLoadAttempted = false

async function loadZcode(): Promise<Provider | null> {
if (zcodeLoadAttempted) return zcodeProvider
zcodeLoadAttempted = true
try {
const { zcode } = await import('./zcode.js')
zcodeProvider = zcode
return zcode
} catch {
return null
}
}

const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack, grok]

// Lazily loaded providers, listed by name so --provider validation works even
// when an optional module fails to load. Must stay in sync with getAllProviders.
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway']
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode']

// Canonical set of every provider name (core + lazy), used to validate the
// --provider CLI flag. Computed lazily so importing this module never depends on
Expand All @@ -173,8 +188,8 @@ export function allProviderNames(): readonly string[] {
}

export async function getAllProviders(): Promise<Provider[]> {
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw] = await Promise.all([
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(),
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc] = await Promise.all([
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(),
])
const all = [...coreProviders]
if (ag) all.push(ag)
Expand All @@ -186,6 +201,7 @@ export async function getAllProviders(): Promise<Provider[]> {
if (crush) all.push(crush)
if (warp) all.push(warp)
if (vercelGw) all.push(vercelGw)
if (zc) all.push(zc)
return all
}

Expand Down Expand Up @@ -241,5 +257,9 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
const vg = await loadVercelGateway()
return vg ?? undefined
}
if (name === 'zcode') {
const z = await loadZcode()
return z ?? undefined
}
return coreProviders.find(p => p.name === name)
}
227 changes: 227 additions & 0 deletions src/providers/zcode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { join } from 'path'
import { homedir } from 'os'

import { calculateCost } from '../models.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

/// ZCode (CLI v0.14.x) records usage in a single SQLite database at
/// ~/.zcode/cli/db/db.sqlite. We read it because the other on-disk sources are
/// unusable for billing: the JSONL activity log redacts token counts, and no
/// source stores a dollar cost (GLM-5.2 runs on z.ai's start-plan subscription).
/// Tokens are exact; cost is computed from the pricing table. Schema verified
/// against db v0.14.8 on 2026-06-20.

type SessionRow = {
id: string
directory: string
}

type UsageRow = {
id: string
turn_id: string | null
model_id: string
input_tokens: number
output_tokens: number
reasoning_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
started_at: number
completed_at: number | null
}

type ToolRow = {
turn_id: string | null
tool_name: string
}

function getDbPath(override?: string): string {
return override ?? join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite')
}

function sanitizeProject(path: string): string {
return path.replace(/^\//, '').replace(/\//g, '-')
}

function epochMsToIso(ms: number | null): string {
if (ms === null || !Number.isFinite(ms) || ms <= 0) return new Date(0).toISOString()
return new Date(ms).toISOString()
}

function validateSchema(db: SqliteDatabase): boolean {
try {
db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM model_usage LIMIT 1')
db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM session LIMIT 1')
return true
} catch {
return false
}
}

function discover(dbPath: string): SessionSource[] {
let db: SqliteDatabase
try {
db = openDatabase(dbPath)
} catch {
return []
}
try {
if (!validateSchema(db)) return []
const rows = db.query<SessionRow>(
`SELECT DISTINCT s.id as id, s.directory as directory
FROM session s
JOIN model_usage m ON m.session_id = s.id
WHERE m.input_tokens > 0 OR m.output_tokens > 0 OR m.reasoning_tokens > 0
OR m.cache_read_input_tokens > 0 OR m.cache_creation_input_tokens > 0`,
)
return rows.map(row => ({
path: `${dbPath}:${row.id}`,
project: sanitizeProject(row.directory),
provider: 'zcode',
}))
} catch {
return []
} finally {
db.close()
}
}

function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
if (!isSqliteAvailable()) {
process.stderr.write(getSqliteLoadError() + '\n')
return
}

// Source paths are `<dbPath>:<sessionId>`. Split from the right so a colon
// in the path (Windows drive letter) doesn't corrupt the session id.
const segments = source.path.split(':')
const sessionId = segments[segments.length - 1]!
const dbPath = segments.slice(0, -1).join(':')

let db: SqliteDatabase
try {
db = openDatabase(dbPath)
} catch (err) {
process.stderr.write(
`codeburn: cannot open ZCode database: ${err instanceof Error ? err.message : err}\n`,
)
return
}

try {
if (!validateSchema(db)) return

// model_usage rows don't link to individual tool calls, only to a turn,
// so collect each turn's tools and attach them to one request per turn
// (below) to avoid double-counting across a turn's multiple requests.
const toolRows = db.query<ToolRow>(
`SELECT turn_id, tool_name FROM tool_usage
WHERE session_id = ? AND turn_id IS NOT NULL
ORDER BY started_at ASC`,
[sessionId],
)
const toolsByTurn = new Map<string, string[]>()
for (const tool of toolRows) {
if (!tool.turn_id) continue
const list = toolsByTurn.get(tool.turn_id) ?? []
list.push(tool.tool_name)
toolsByTurn.set(tool.turn_id, list)
}

const rows = db.query<UsageRow>(
`SELECT id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens,
cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at
FROM model_usage WHERE session_id = ?
ORDER BY started_at ASC`,
[sessionId],
)

const turnsWithToolsEmitted = new Set<string>()

for (const row of rows) {
const cacheRead = row.cache_read_input_tokens ?? 0
const cacheCreation = row.cache_creation_input_tokens ?? 0
const output = row.output_tokens ?? 0
const reasoning = row.reasoning_tokens ?? 0
// ZCode folds cached tokens into input_tokens (OpenAI-style). Split
// them back out so fresh input bills at the input rate and cached at
// the cache-read rate, matching the pricing table's Anthropic-style
// semantics.
const freshInput = Math.max(0, (row.input_tokens ?? 0) - cacheRead - cacheCreation)

if (freshInput === 0 && output === 0 && reasoning === 0 && cacheRead === 0 && cacheCreation === 0) {
continue
}

const dedupKey = `zcode:${row.id}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)

let tools: string[] = []
if (row.turn_id && !turnsWithToolsEmitted.has(row.turn_id)) {
const turnTools = toolsByTurn.get(row.turn_id)
if (turnTools && turnTools.length > 0) {
tools = turnTools
turnsWithToolsEmitted.add(row.turn_id)
}
}

const model = row.model_id
const costUSD = calculateCost(model, freshInput, output, cacheCreation, cacheRead, 0)

yield {
provider: 'zcode',
model,
inputTokens: freshInput,
outputTokens: output,
cacheCreationInputTokens: cacheCreation,
cacheReadInputTokens: cacheRead,
cachedInputTokens: 0,
reasoningTokens: reasoning,
webSearchRequests: 0,
costUSD,
tools,
bashCommands: [],
timestamp: epochMsToIso(row.completed_at ?? row.started_at),
speed: 'standard',
deduplicationKey: dedupKey,
turnId: row.turn_id ?? undefined,
userMessage: '',
sessionId,
}
}
} finally {
db.close()
}
},
}
}

export function createZcodeProvider(dbPathOverride?: string): Provider {
const dbPath = getDbPath(dbPathOverride)
return {
name: 'zcode',
displayName: 'ZCode',

modelDisplayName(model: string): string {
return model
},

toolDisplayName(rawTool: string): string {
return rawTool
},

async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []
return discover(dbPath)
},

createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}

export const zcode = createZcodeProvider()
Loading