mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-20 22:44:26 +00:00
feat(providers): add ZCode (z.ai GLM-5.2) usage provider (#537)
Reads ZCode CLI usage from ~/.zcode/cli/db/db.sqlite. The model_usage table has exact per-request tokens; cost is computed from the pricing table since ZCode stores none (GLM-5.2 runs on the z.ai start-plan subscription). - Split cached tokens out of input_tokens (OpenAI-style) so fresh input and cache reads price correctly - Attach each turn's tool calls to one request to avoid double-counting - Map GLM-5.2 to glm-5p1 (GLM-5.1 rate) until LiteLLM lists it - Register as a lazy SQLite provider; add test and provider doc
This commit is contained in:
parent
16d2f7eb42
commit
9ce649809a
6 changed files with 484 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
89
docs/providers/zcode.md
Normal file
89
docs/providers/zcode.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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> = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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
src/providers/zcode.ts
Normal file
227
src/providers/zcode.ts
Normal file
|
|
@ -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()
|
||||
141
tests/providers/zcode.test.ts
Normal file
141
tests/providers/zcode.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { createZcodeProvider } from '../../src/providers/zcode.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'zcode-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Minimal subset of the real ZCode schema (db v0.14.8) covering only the
|
||||
// columns the provider reads.
|
||||
function createZcodeDb(dir: string): string {
|
||||
const dbPath = join(dir, 'db.sqlite')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
directory TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
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
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE tool_usage (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
turn_id TEXT,
|
||||
tool_name TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// Seeds one session with a single GLM-5.2 request whose 9125 input tokens
|
||||
// include 8064 cached, plus two tool calls in the same turn.
|
||||
function seed(dbPath: string): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
db.prepare('INSERT INTO session (id, directory) VALUES (?, ?)').run('sess-1', '/Users/me/proj')
|
||||
db.prepare(
|
||||
`INSERT INTO model_usage
|
||||
(id, session_id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens,
|
||||
cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run('mu-1', 'sess-1', 'turn-1', 'GLM-5.2', 9125, 27, 0, 0, 8064, 1781981181862, 1781981202412)
|
||||
db.prepare(
|
||||
'INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('tu-1', 'sess-1', 'turn-1', 'Bash', 1781981299176)
|
||||
db.prepare(
|
||||
'INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('tu-2', 'sess-1', 'turn-1', 'Read', 1781981315829)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
|
||||
const out: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) out.push(call)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('zcode provider', () => {
|
||||
it('discovers sessions that have usage', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]?.provider).toBe('zcode')
|
||||
expect(sessions[0]?.project).toBe('Users-me-proj')
|
||||
})
|
||||
|
||||
it('splits cached tokens out of input and prices via the GLM-5.2 alias', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
const [source] = await provider.discoverSessions()
|
||||
const calls = await collect(provider.createSessionParser(source!, new Set<string>()))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('GLM-5.2')
|
||||
expect(call.inputTokens).toBe(1061) // 9125 - 8064 cached
|
||||
expect(call.cacheReadInputTokens).toBe(8064)
|
||||
expect(call.outputTokens).toBe(27)
|
||||
expect(call.tools).toEqual(['Bash', 'Read'])
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not re-emit rows already in the seen set', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
const [source] = await provider.discoverSessions()
|
||||
const seen = new Set<string>()
|
||||
|
||||
const first = await collect(provider.createSessionParser(source!, seen))
|
||||
const second = await collect(provider.createSessionParser(source!, seen))
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue