mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
feat(providers): add Zed agent provider (#480)
Reads Zed's agent threads from the single threads.db SQLite store: each row's zstd-compressed JSON carries the thread model and per-request Anthropic-shaped token usage, so calls are emitted per request with exact input/output/cache fields and priced through the existing engine. In-progress threads with an empty per-request map fall back to the cumulative counter. zstd comes from node:zlib, which ships it from 22.15; on older Nodes the provider skips with a notice instead of failing. On-disk format documented by @chatzinikolakisk in #480 and the schema verified against a real Zed install.
This commit is contained in:
parent
9b20dfd7dc
commit
1f45c1541b
3 changed files with 424 additions and 3 deletions
|
|
@ -171,11 +171,26 @@ async function loadZcode(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
let zedProvider: Provider | null = null
|
||||
let zedLoadAttempted = false
|
||||
|
||||
async function loadZed(): Promise<Provider | null> {
|
||||
if (zedLoadAttempted) return zedProvider
|
||||
zedLoadAttempted = true
|
||||
try {
|
||||
const { zed } = await import('./zed.js')
|
||||
zedProvider = zed
|
||||
return zed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, openDesign, 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', 'zcode']
|
||||
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed']
|
||||
|
||||
// Canonical set of every provider name (core + lazy), used to validate the
|
||||
// --provider CLI flag. Computed lazily so importing this module never depends on
|
||||
|
|
@ -190,8 +205,8 @@ export function allProviderNames(): readonly string[] {
|
|||
}
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
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 [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc, zd] = await Promise.all([
|
||||
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), loadZed(),
|
||||
])
|
||||
const all = [...coreProviders]
|
||||
if (ag) all.push(ag)
|
||||
|
|
@ -204,6 +219,7 @@ export async function getAllProviders(): Promise<Provider[]> {
|
|||
if (warp) all.push(warp)
|
||||
if (vercelGw) all.push(vercelGw)
|
||||
if (zc) all.push(zc)
|
||||
if (zd) all.push(zd)
|
||||
return all
|
||||
}
|
||||
|
||||
|
|
@ -263,5 +279,9 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
|
|||
const z = await loadZcode()
|
||||
return z ?? undefined
|
||||
}
|
||||
if (name === 'zed') {
|
||||
const z = await loadZed()
|
||||
return z ?? undefined
|
||||
}
|
||||
return coreProviders.find(p => p.name === name)
|
||||
}
|
||||
|
|
|
|||
209
src/providers/zed.ts
Normal file
209
src/providers/zed.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
// Zed's built-in agent stores one row per thread in a single SQLite database;
|
||||
// the `data` blob is zstd-compressed JSON carrying `request_token_usage`
|
||||
// (per-request Anthropic-shaped token counts) and the thread's model.
|
||||
// Format documented in issue #480.
|
||||
|
||||
// zstd landed in node:zlib in 22.15 / 23.8; the package floor is 22.13, so the
|
||||
// provider degrades with a notice instead of assuming the export exists.
|
||||
const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync
|
||||
|
||||
function getZedThreadsDbPath(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return join(homedir(), 'AppData', 'Local', 'Zed', 'threads', 'threads.db')
|
||||
}
|
||||
return join(homedir(), '.local', 'share', 'zed', 'threads', 'threads.db')
|
||||
}
|
||||
|
||||
const THREADS_QUERY = `
|
||||
SELECT id, summary, updated_at, data_type, data
|
||||
FROM threads
|
||||
ORDER BY updated_at ASC
|
||||
`
|
||||
|
||||
type ThreadRow = {
|
||||
id: string
|
||||
summary: string | null
|
||||
updated_at: string | null
|
||||
data_type: string | null
|
||||
data: Uint8Array | null
|
||||
}
|
||||
|
||||
type TokenUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
}
|
||||
|
||||
type ThreadJson = {
|
||||
model?: { provider?: string; model?: string }
|
||||
request_token_usage?: Record<string, TokenUsage>
|
||||
cumulative_token_usage?: TokenUsage
|
||||
}
|
||||
|
||||
function num(value: number | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
||||
}
|
||||
|
||||
function usageIsEmpty(usage: TokenUsage): boolean {
|
||||
return (
|
||||
num(usage.input_tokens) === 0 &&
|
||||
num(usage.output_tokens) === 0 &&
|
||||
num(usage.cache_creation_input_tokens) === 0 &&
|
||||
num(usage.cache_read_input_tokens) === 0
|
||||
)
|
||||
}
|
||||
|
||||
function buildCall(opts: {
|
||||
threadId: string
|
||||
requestKey: string
|
||||
usage: TokenUsage
|
||||
model: string
|
||||
timestamp: string
|
||||
userMessage: string
|
||||
}): ParsedProviderCall {
|
||||
const input = num(opts.usage.input_tokens)
|
||||
const output = num(opts.usage.output_tokens)
|
||||
const cacheWrite = num(opts.usage.cache_creation_input_tokens)
|
||||
const cacheRead = num(opts.usage.cache_read_input_tokens)
|
||||
return {
|
||||
provider: 'zed',
|
||||
model: opts.model,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: cacheWrite,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: cacheRead,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: calculateCost(opts.model, input, output, cacheWrite, cacheRead, 0),
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: opts.timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: `zed:${opts.threadId}:${opts.requestKey}`,
|
||||
userMessage: opts.userMessage,
|
||||
sessionId: opts.threadId,
|
||||
}
|
||||
}
|
||||
|
||||
function parseThreads(db: SqliteDatabase, seenKeys: Set<string>): ParsedProviderCall[] {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
let skipped = 0
|
||||
|
||||
let rows: ThreadRow[]
|
||||
try {
|
||||
rows = db.query<ThreadRow>(THREADS_QUERY)
|
||||
} catch {
|
||||
return calls
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
if (!row.id || !row.data || row.data_type !== 'zstd') {
|
||||
if (row.data != null) skipped++
|
||||
continue
|
||||
}
|
||||
const parsedAt = new Date(row.updated_at ?? '')
|
||||
if (Number.isNaN(parsedAt.getTime())) continue
|
||||
const timestamp = parsedAt.toISOString()
|
||||
|
||||
const thread = JSON.parse(zstdDecompress!(Buffer.from(row.data)).toString('utf-8')) as ThreadJson
|
||||
const model = thread.model?.model || 'unknown'
|
||||
const userMessage = row.summary ?? ''
|
||||
|
||||
const requests = Object.entries(thread.request_token_usage ?? {}).filter(([, usage]) => usage != null && !usageIsEmpty(usage))
|
||||
// In-progress threads can have an empty per-request map while the
|
||||
// cumulative counter already carries tokens; fall back so they count.
|
||||
const entries: Array<[string, TokenUsage]> = requests.length > 0
|
||||
? requests
|
||||
: thread.cumulative_token_usage && !usageIsEmpty(thread.cumulative_token_usage)
|
||||
? [['cumulative', thread.cumulative_token_usage]]
|
||||
: []
|
||||
|
||||
for (const [requestKey, usage] of entries) {
|
||||
const call = buildCall({ threadId: row.id, requestKey, usage, model, timestamp, userMessage })
|
||||
if (seenKeys.has(call.deduplicationKey)) continue
|
||||
seenKeys.add(call.deduplicationKey)
|
||||
calls.push(call)
|
||||
}
|
||||
} catch {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
process.stderr.write(`codeburn: skipped ${skipped} unreadable Zed threads\n`)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return
|
||||
}
|
||||
if (!zstdDecompress) {
|
||||
process.stderr.write('codeburn: Zed threads need Node >= 22.15 (zstd support); skipping Zed usage.\n')
|
||||
return
|
||||
}
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(source.path)
|
||||
} catch (err) {
|
||||
process.stderr.write(`codeburn: cannot open Zed database: ${err instanceof Error ? err.message : err}\n`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
for (const call of parseThreads(db, seenKeys)) {
|
||||
yield call
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createZedProvider(dbPathOverride?: string): Provider {
|
||||
return {
|
||||
name: 'zed',
|
||||
displayName: 'Zed',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return model
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const dbPath = dbPathOverride ?? getZedThreadsDbPath()
|
||||
if (!existsSync(dbPath)) return []
|
||||
return [{ path: dbPath, project: 'zed', provider: 'zed' }]
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const zed = createZedProvider()
|
||||
192
tests/providers/zed.test.ts
Normal file
192
tests/providers/zed.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createRequire } from 'node:module'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { createZedProvider } from '../../src/providers/zed.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
const zstd = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync
|
||||
|
||||
const skipReason = !isSqliteAvailable()
|
||||
? 'node:sqlite not available — needs Node 22+; skipping'
|
||||
: !zstd
|
||||
? 'zlib zstd not available — needs Node 22.15+; skipping'
|
||||
: null
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'zed-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function buildDb(fn: (db: {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}) => void): string {
|
||||
const dbPath = join(tmpDir, 'threads.db')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
data_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
parent_id TEXT, folder_paths TEXT, folder_paths_order TEXT, created_at TEXT
|
||||
)`)
|
||||
fn(db)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function insertThread(db: {
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
}, opts: {
|
||||
id: string
|
||||
summary?: string
|
||||
updatedAt?: string
|
||||
dataType?: string
|
||||
thread?: unknown
|
||||
rawData?: Buffer
|
||||
}): void {
|
||||
const data = opts.rawData ?? zstd!(Buffer.from(JSON.stringify(opts.thread ?? {})))
|
||||
db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)').run(
|
||||
opts.id,
|
||||
opts.summary ?? 'a thread',
|
||||
opts.updatedAt ?? '2026-06-20T10:00:00Z',
|
||||
opts.dataType ?? 'zstd',
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
async function collectCalls(dbPath: string, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createZedProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sources) {
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(skipReason !== null)('zed provider (#480)', () => {
|
||||
it('emits one call per request with exact Anthropic-shaped token fields', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-1',
|
||||
summary: 'refactor the parser',
|
||||
updatedAt: '2026-06-21T09:30:00Z',
|
||||
thread: {
|
||||
model: { provider: 'anthropic', model: 'claude-opus-4-8' },
|
||||
request_token_usage: {
|
||||
'req-1': { input_tokens: 1200, output_tokens: 300, cache_creation_input_tokens: 5000, cache_read_input_tokens: 90000 },
|
||||
'req-2': { input_tokens: 800, output_tokens: 150, cache_creation_input_tokens: 0, cache_read_input_tokens: 95000 },
|
||||
},
|
||||
cumulative_token_usage: { input_tokens: 2000, output_tokens: 450 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(2)
|
||||
const first = calls.find(c => c.deduplicationKey === 'zed:thread-1:req-1')
|
||||
expect(first).toBeDefined()
|
||||
expect(first!.inputTokens).toBe(1200)
|
||||
expect(first!.outputTokens).toBe(300)
|
||||
expect(first!.cacheCreationInputTokens).toBe(5000)
|
||||
expect(first!.cacheReadInputTokens).toBe(90000)
|
||||
expect(first!.model).toBe('claude-opus-4-8')
|
||||
expect(first!.costUSD).toBeGreaterThan(0)
|
||||
expect(first!.sessionId).toBe('thread-1')
|
||||
expect(first!.userMessage).toBe('refactor the parser')
|
||||
expect(first!.timestamp).toBe('2026-06-21T09:30:00.000Z')
|
||||
// Cumulative must not be counted on top of per-request entries.
|
||||
expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(2000)
|
||||
})
|
||||
|
||||
it('falls back to cumulative_token_usage when the per-request map is empty', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-2',
|
||||
thread: {
|
||||
model: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||
request_token_usage: {},
|
||||
cumulative_token_usage: { input_tokens: 5400, output_tokens: 900, cache_read_input_tokens: 20000 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.deduplicationKey).toBe('zed:thread-2:cumulative')
|
||||
expect(calls[0]!.inputTokens).toBe(5400)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(20000)
|
||||
})
|
||||
|
||||
it('skips non-zstd rows and malformed blobs without dropping healthy threads', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, { id: 'bad-type', dataType: 'json', rawData: Buffer.from('{}') })
|
||||
insertThread(db, { id: 'bad-blob', rawData: Buffer.from('not zstd at all') })
|
||||
insertThread(db, {
|
||||
id: 'good',
|
||||
thread: {
|
||||
model: { model: 'claude-opus-4-8' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 10, output_tokens: 5 } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.sessionId).toBe('good')
|
||||
})
|
||||
|
||||
it('dedupes across repeat parses via the shared seenKeys set', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-3',
|
||||
thread: {
|
||||
model: { model: 'claude-opus-4-8' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const seen = new Set<string>()
|
||||
expect((await collectCalls(dbPath, seen)).length).toBe(1)
|
||||
expect((await collectCalls(dbPath, seen)).length).toBe(0)
|
||||
})
|
||||
|
||||
it('skips threads whose usage is entirely zero instead of emitting empty calls', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-4',
|
||||
thread: {
|
||||
model: { model: 'claude-opus-4-8' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 0, output_tokens: 0 } },
|
||||
cumulative_token_usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect((await collectCalls(dbPath)).length).toBe(0)
|
||||
})
|
||||
|
||||
it('discovers nothing when the database does not exist', async () => {
|
||||
const provider = createZedProvider(join(tmpDir, 'missing.db'))
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue