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:
kevinpauer 2026-06-19 14:44:45 +02:00 • committed by GitHub
parent 8967311f05
commit 7c42534910
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 356 additions and 2 deletions

View file

@ -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)

View 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.

View file

@ -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
View 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()

View file

@ -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', () => {

View 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')
})
})