mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Add zerostack provider (#519)
* Add zerostack provider scaffold Register zerostack (gi-dellav/zerostack) as a core provider reading plain-text sessions from $XDG_DATA_HOME/zerostack/sessions/. Parser is modeled on pi.ts; on-disk schema is unverified and must be confirmed against real sessions before a PR (see docs/providers/zerostack.md). * Rework zerostack provider against real session schema Replace the initial scaffold (modeled on pi.ts JSONL) with the verified format from a real local run and the zerostack source: - Sessions are one JSON file per session under the platform data dir (~/Library/Application Support/zerostack/sessions on macOS, $XDG_DATA_HOME/zerostack/sessions on Linux; ZS_DATA_DIR overrides). - Tokens are cumulative session totals, not per-call, so emit one ParsedProviderCall per session from total_input/output_tokens. - Recompute cost via LiteLLM (calculateCost); OpenRouter ids arrive route-prefixed and resolve through canonical names. - zerostack persists only final assistant text, so tools/bash are empty. Verified against a real session (DeepSeek v4 Pro via OpenRouter): 34.1K in / 961 out / $0.016, matching the recorded total_cost. Adds a fixture-based test and rewrites the provider doc to match.
This commit is contained in:
parent
8967311f05
commit
7c42534910
6 changed files with 356 additions and 2 deletions
|
|
@ -27,6 +27,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |
|
||||
| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none |
|
||||
| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` |
|
||||
| [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` |
|
||||
|
||||
### Lazy (loaded on first call)
|
||||
|
||||
|
|
|
|||
48
docs/providers/zerostack.md
Normal file
48
docs/providers/zerostack.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Zerostack
|
||||
|
||||
Zerostack (gi-dellav/zerostack) — a minimal Rust coding agent. Wraps OpenRouter (default), OpenAI, Anthropic, Gemini, and Ollama.
|
||||
|
||||
- **Source:** `src/providers/zerostack.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/zerostack.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
The platform data dir + `zerostack/sessions/`, mirroring Rust's `dirs::data_dir` (`src/session/storage.rs` in zerostack):
|
||||
|
||||
| OS | Path |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/zerostack/sessions/` |
|
||||
| Linux | `$XDG_DATA_HOME/zerostack/sessions/` or `~/.local/share/zerostack/sessions/` |
|
||||
|
||||
`ZS_DATA_DIR` overrides the whole data dir (sessions live directly under it). A directory argument to `createZerostackProvider(dir)` overrides the sessions dir outright (used by tests).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSON — one `<uuid>.json` file per session. Each file is a single `Session` object: a `messages[]` array (`role`, `content`, `estimated_tokens`) plus session-level metadata.
|
||||
|
||||
## Token model
|
||||
|
||||
**Cumulative, not per-call.** Real billable tokens exist only as session totals — `total_input_tokens`, `total_output_tokens` — alongside `model`, `provider`, and `working_dir`. Individual messages carry only a rough `estimated_tokens`. So the parser emits **one `ParsedProviderCall` per session** from the totals; cost is recomputed with `calculateCost` (LiteLLM), not taken from the session's own `total_cost`.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `zerostack:<path>:<updated_at>:<id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No cache breakdown.** zerostack's `Usage` only carries `input_tokens` and `output_tokens` (`src/agent/runner.rs`); it folds any cached prompt tokens into the input count and discards the split before writing the session. So cache fields are always `0` and the cache % reads 0. Cost is re-priced at LiteLLM's standard input rate, which can slightly overestimate when caching was active — consistent with zerostack's own flat `input_token_cost`.
|
||||
- **No tool data.** zerostack persists only final assistant text, not tool-call or bash records, so `tools` and `bashCommands` are always empty.
|
||||
- **OpenRouter model ids are prefixed** (e.g. `deepseek/deepseek-v4-pro`). `modelDisplayName` strips the route prefix before resolving; `calculateCost` resolves the prefixed id via LiteLLM's canonical-name handling.
|
||||
- **Whole-session timestamp.** All spend is attributed to `updated_at`, since cumulative totals can't be split across days.
|
||||
- Unknown/local models (Ollama, custom) price at `$0`, which is expected.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm whether the bug is in **discovery** (session files not picked up — check the data-dir resolution against `src/session/storage.rs`) or **parsing** (totals mapped wrong).
|
||||
2. The session struct is `Session` in zerostack's `src/session/mod.rs`. If a field is renamed upstream, update `ZerostackSession` to match.
|
||||
3. Add a fixture-format session to `tests/providers/zerostack.test.ts`; do not mock the filesystem.
|
||||
|
|
@ -16,6 +16,7 @@ import { openclaw } from './openclaw.js'
|
|||
import { pi, omp } from './pi.js'
|
||||
import { qwen } from './qwen.js'
|
||||
import { rooCode } from './roo-code.js'
|
||||
import { zerostack } from './zerostack.js'
|
||||
import type { Provider, SessionSource } from './types.js'
|
||||
|
||||
let antigravityProvider: Provider | null = null
|
||||
|
|
@ -152,7 +153,7 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode]
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack]
|
||||
|
||||
// Lazily loaded providers, listed by name so --provider validation works even
|
||||
// when an optional module fails to load. Must stay in sync with getAllProviders.
|
||||
|
|
|
|||
162
src/providers/zerostack.ts
Normal file
162
src/providers/zerostack.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { readdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir, platform } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
// zerostack (https://github.com/gi-dellav/zerostack) is a minimal Rust coding
|
||||
// agent. Each session is a single JSON file under <dataDir>/zerostack/sessions/.
|
||||
// Token counts are stored as CUMULATIVE session totals (total_input_tokens,
|
||||
// total_output_tokens, total_cost) — there is no per-call breakdown — so we emit
|
||||
// one ParsedProviderCall per session.
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
edit: 'Edit',
|
||||
grep: 'Grep',
|
||||
glob: 'Glob',
|
||||
fetch: 'WebFetch',
|
||||
search: 'WebSearch',
|
||||
task: 'Agent',
|
||||
}
|
||||
|
||||
type ZerostackMessage = {
|
||||
role?: string
|
||||
content?: string | Array<{ text?: string }>
|
||||
}
|
||||
|
||||
type ZerostackSession = {
|
||||
id?: string
|
||||
messages?: ZerostackMessage[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
total_input_tokens?: number
|
||||
total_output_tokens?: number
|
||||
model?: string
|
||||
provider?: string
|
||||
working_dir?: string
|
||||
}
|
||||
|
||||
// zerostack uses the platform data dir (Rust `dirs::data_dir`): macOS maps to
|
||||
// ~/Library/Application Support, everything else to $XDG_DATA_HOME or
|
||||
// ~/.local/share, then a `zerostack` subdir. ZS_DATA_DIR overrides the whole
|
||||
// data dir (sessions live directly under it). Matches src/session/storage.rs.
|
||||
function defaultSessionsDir(): string {
|
||||
const override = process.env['ZS_DATA_DIR']
|
||||
if (override) return join(override, 'sessions')
|
||||
const base =
|
||||
platform() === 'darwin'
|
||||
? join(homedir(), 'Library', 'Application Support')
|
||||
: process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share')
|
||||
return join(base, 'zerostack', 'sessions')
|
||||
}
|
||||
|
||||
function firstUserMessage(messages: ZerostackMessage[]): string {
|
||||
const msg = messages.find(m => m.role === 'user')
|
||||
if (!msg) return ''
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
return (msg.content ?? []).map(c => c.text ?? '').filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
async function readSession(path: string): Promise<ZerostackSession | null> {
|
||||
const content = await readSessionFile(path)
|
||||
if (content === null) return null
|
||||
try {
|
||||
return JSON.parse(content) as ZerostackSession
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const session = await readSession(source.path)
|
||||
if (!session) return
|
||||
|
||||
const input = session.total_input_tokens ?? 0
|
||||
const output = session.total_output_tokens ?? 0
|
||||
if (input === 0 && output === 0) return
|
||||
|
||||
const timestamp = session.updated_at ?? session.created_at ?? ''
|
||||
const sessionId = session.id ?? basename(source.path, '.json')
|
||||
const dedupKey = `${source.provider}:${source.path}:${timestamp}:${sessionId}`
|
||||
if (seenKeys.has(dedupKey)) return
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const model = session.model ?? ''
|
||||
|
||||
yield {
|
||||
provider: source.provider,
|
||||
model,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: calculateCost(model, input, output, 0, 0, 0),
|
||||
// zerostack persists only final assistant text, not tool-call records,
|
||||
// so there is nothing to extract here.
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: firstUserMessage(session.messages ?? []),
|
||||
sessionId,
|
||||
project: source.project,
|
||||
projectPath: session.working_dir,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createZerostackProvider(sessionsDir?: string): Provider {
|
||||
const dir = sessionsDir ?? defaultSessionsDir()
|
||||
|
||||
return {
|
||||
name: 'zerostack',
|
||||
displayName: 'Zerostack',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
// OpenRouter routes arrive prefixed (e.g. "deepseek/deepseek-v4-pro").
|
||||
return getShortModelName(model.replace(/^[^/]+\//, ''))
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const sources: SessionSource[] = []
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue
|
||||
const path = join(dir, file)
|
||||
const session = await readSession(path)
|
||||
if (!session) continue
|
||||
const project = session.working_dir ? basename(session.working_dir) : basename(file, '.json')
|
||||
sources.push({ path, project, provider: 'zerostack' })
|
||||
}
|
||||
return sources
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const zerostack = createZerostackProvider()
|
||||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index.
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
|
|||
142
tests/providers/zerostack.test.ts
Normal file
142
tests/providers/zerostack.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createZerostackProvider } from '../../src/providers/zerostack.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'zerostack-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Mirrors the real on-disk format: one JSON file per session with cumulative
|
||||
// token totals (see src/session/mod.rs in zerostack).
|
||||
function session(opts: {
|
||||
id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
workingDir?: string
|
||||
input?: number
|
||||
output?: number
|
||||
updatedAt?: string
|
||||
} = {}) {
|
||||
return JSON.stringify({
|
||||
id: opts.id ?? 'sess-001',
|
||||
name: '',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello, what is this repo about?', estimated_tokens: 7 },
|
||||
{ role: 'assistant', content: 'It is a minimal coding agent in Rust.', estimated_tokens: 92 },
|
||||
],
|
||||
compactions: [],
|
||||
created_at: '2026-06-19T11:33:34.022836+00:00',
|
||||
updated_at: opts.updatedAt ?? '2026-06-19T11:34:14.140631+00:00',
|
||||
total_input_tokens: opts.input ?? 34119,
|
||||
total_output_tokens: opts.output ?? 961,
|
||||
total_cost: 0.015677835,
|
||||
total_estimated_tokens: 446,
|
||||
model: opts.model ?? 'deepseek/deepseek-v4-pro',
|
||||
provider: opts.provider ?? 'openrouter',
|
||||
working_dir: opts.workingDir ?? '/Users/test/myproject',
|
||||
permission_allowlist: [],
|
||||
})
|
||||
}
|
||||
|
||||
async function write(filename: string, content: string) {
|
||||
const path = join(tmpDir, filename)
|
||||
await writeFile(path, content)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('zerostack provider - discovery', () => {
|
||||
it('discovers each session json and derives project from working_dir', async () => {
|
||||
await write('sess-001.json', session({ workingDir: '/Users/test/myproject' }))
|
||||
const sessions = await createZerostackProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('zerostack')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('skips non-json files and unparseable files', async () => {
|
||||
await write('notes.txt', 'not a session')
|
||||
await write('broken.json', '{ not valid json')
|
||||
const sessions = await createZerostackProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent directory', async () => {
|
||||
const sessions = await createZerostackProvider('/nope/does/not/exist').discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('zerostack provider - parsing', () => {
|
||||
async function parse(path: string, seen = new Set<string>()) {
|
||||
const provider = createZerostackProvider(tmpDir)
|
||||
const source = { path, project: 'myproject', provider: 'zerostack' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it('emits one cumulative call per session with a resolved cost', async () => {
|
||||
const path = await write('sess-abc.json', session({ id: 'sess-abc' }))
|
||||
const calls = await parse(path)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('deepseek/deepseek-v4-pro')
|
||||
expect(call.inputTokens).toBe(34119)
|
||||
expect(call.outputTokens).toBe(961)
|
||||
expect(call.sessionId).toBe('sess-abc')
|
||||
expect(call.userMessage).toBe('hello, what is this repo about?')
|
||||
expect(call.timestamp).toBe('2026-06-19T11:34:14.140631+00:00')
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toContain('zerostack:')
|
||||
})
|
||||
|
||||
it('skips sessions with zero tokens', async () => {
|
||||
const path = await write('empty.json', session({ input: 0, output: 0 }))
|
||||
expect(await parse(path)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates across repeated parses', async () => {
|
||||
const path = await write('dup.json', session())
|
||||
const seen = new Set<string>()
|
||||
expect(await parse(path, seen)).toHaveLength(1)
|
||||
expect(await parse(path, seen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('prices unknown local models at zero without throwing', async () => {
|
||||
const path = await write('local.json', session({ model: 'my-local-model', provider: 'ollama' }))
|
||||
const calls = await parse(path)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zerostack provider - display names', () => {
|
||||
const provider = createZerostackProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('zerostack')
|
||||
expect(provider.displayName).toBe('Zerostack')
|
||||
})
|
||||
|
||||
it('strips the openrouter route prefix from model ids', () => {
|
||||
expect(provider.modelDisplayName('deepseek/deepseek-v4-pro')).toBe('DeepSeek v4 Pro')
|
||||
})
|
||||
|
||||
it('normalizes tool names', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue