Merge pull request #905 from ozymandiashh/feat/213-openclaude-provider

feat(openclaude): OpenClaude CLI provider (#213)
This commit is contained in:
Resham Joshi 2026-08-10 02:49:44 -07:00 committed by GitHub
commit 60a8b29103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 779 additions and 2 deletions

View file

@ -0,0 +1,33 @@
# OpenClaude
OpenClaude (npm `@gitlawb/openclaude`) is a Claude Code fork that runs the same
agent loop against any LLM backend (DeepSeek, OpenAI-compatible endpoints,
Gemini, Ollama, ...). Because it is a fork, its transcripts are Claude-Code
schema and its tool names are already codeburn-canonical.
## Storage layout
```
~/.openclaude/projects/<project-slug>/<uuid>.jsonl transcript
~/.openclaude/projects/<project-slug>/<uuid>.replay.json replay state (skipped)
```
`CODEBURN_OPENCLAUDE_DIR` overrides the root (projects live under
`<root>/projects`).
## Quirks
- Only `assistant` lines carrying `message.usage` become calls; the
`queue-operation` / `last-prompt` bookkeeping lines and user lines are
skipped.
- Usage is Anthropic-shaped; there is NO cost field, so every call is priced
through the shared tables and always carries `costIsEstimated: true`.
- `isSidechain: true` lines are subagent traffic inside the same transcript
and are counted: their usage is real spend.
- The model id is whatever the routed backend reports (e.g. `deepseek-chat`),
so pricing accuracy tracks the shared litellm tables.
- Tool attribution is partial by construction: streamed responses can split
tool_use blocks across assistant events, and only usage-bearing events are
parsed. Cost and token accounting are exact; tool breakdowns are a floor.
- The project name prefers the basename of the first `cwd` seen in the
transcript; the project-slug directory is the fallback.

View file

@ -18,6 +18,7 @@ import { lingtaiTui } from './lingtai-tui.js'
import { mistralVibe } from './mistral-vibe.js'
import { mux } from './mux.js'
import { openclaw } from './openclaw.js'
import { openclaude } from './openclaude.js'
import { openDesign } from './open-design.js'
import { pi, omp } from './pi.js'
import { qwen } from './qwen.js'
@ -191,7 +192,7 @@ async function loadZed(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok]
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, 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.

328
src/providers/openclaude.ts Normal file
View file

@ -0,0 +1,328 @@
// OpenClaude (npm `@gitlawb/openclaude`) is a Claude Code fork that runs the
// same agent loop against any LLM (DeepSeek, OpenAI, etc.). Each session is a
// Claude-Code-schema JSONL transcript under a project slug:
//
// ~/.openclaude/projects/<project-slug>/<uuid>.jsonl
//
// Every transcript has a sibling `<uuid>.replay.json` that only carries replay
// state; discovery only picks up `.jsonl` files, so the replay files are
// skipped. Subagent (sidechain) traffic is interleaved in the same transcript
// with `isSidechain: true`; those lines are real spend and are counted like
// any other assistant line. The transcript reports no cost field, so every
// call's cost is computed from token counts via calculateCost and always
// flagged costIsEstimated: true.
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { basename, join } from 'path'
import { extractBashCommands } from '../bash-utils.js'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import type { ToolCall } from '../types.js'
import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js'
const PROVIDER_NAME = 'openclaude'
const DISPLAY_NAME = 'OpenClaude'
const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000
// Mirrors the CLI's own resolution chain, each level individually overridable:
// root := CODEBURN_OPENCLAUDE_DIR ?? ~/.openclaude
// projects := <root>/projects
function openClaudeRootDir(): string {
return process.env['CODEBURN_OPENCLAUDE_DIR']?.trim() || join(homedir(), '.openclaude')
}
export function getOpenClaudeProjectsDir(): string {
return join(openClaudeRootDir(), 'projects')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function safeNonNegativeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
}
function safeTokenCount(value: unknown): number {
return Math.floor(Math.min(safeNonNegativeNumber(value), Number.MAX_SAFE_INTEGER))
}
// The transcript only ever carries ISO timestamps, but keep the epoch-millis
// branch so an exotic export can never silently land in 1970, matching the
// guard cline-cli.ts uses on the same hazard.
function isoTimestamp(value: unknown, fallback: string): string {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
// Promote seconds-resolution values to milliseconds, then reject anything
// that stays implausible.
const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value
const date = new Date(ms)
if (!Number.isNaN(date.getTime()) && date.getTime() >= MIN_REASONABLE_TIMESTAMP_MS) {
return date.toISOString()
}
}
const parsed = nonEmptyString(value)
if (parsed) {
const date = new Date(parsed)
if (!Number.isNaN(date.getTime())) return date.toISOString()
}
return fallback
}
function firstString(input: unknown, keys: string[]): string | undefined {
if (!isRecord(input)) return undefined
for (const key of keys) {
const value = nonEmptyString(input[key])
if (value) return value
}
return undefined
}
type CollectedTools = {
tools: string[]
bashCommands: string[]
toolSequence: ToolCall[][]
skills: string[]
subagentTypes: string[]
}
// Tool names are already codeburn-canonical (Write, Read, Bash, Grep, Edit,
// Task, Skill, WebFetch, ...) because OpenClaude is a Claude Code fork, so no
// mapping table is needed: the raw name is used as-is.
function collectTools(content: unknown): CollectedTools {
const collected: CollectedTools = {
tools: [], bashCommands: [], toolSequence: [], skills: [], subagentTypes: [],
}
if (!Array.isArray(content)) return collected
const turnTools: ToolCall[] = []
for (const block of content) {
if (!isRecord(block) || block['type'] !== 'tool_use') continue
const name = nonEmptyString(block['name'])
if (!name) continue
const input = block['input']
const toolCall: ToolCall = { tool: name }
const file = firstString(input, ['path', 'file_path', 'paths', 'file'])
if (file) toolCall.file = file
if (name === 'Bash') {
const command = firstString(input, ['command'])
if (command) {
toolCall.command = command
collected.bashCommands.push(...extractBashCommands(command))
}
}
if (name === 'Skill') {
const skill = firstString(input, ['name', 'skill'])
if (skill) collected.skills.push(skill)
}
if (name === 'Task') {
const subagentType = firstString(input, ['subagent_type'])
if (subagentType) collected.subagentTypes.push(subagentType)
}
collected.tools.push(name)
turnTools.push(toolCall)
}
if (turnTools.length > 0) collected.toolSequence.push(turnTools)
return collected
}
function textFromContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
for (const block of content) {
if (!isRecord(block) || block['type'] !== 'text') continue
const text = nonEmptyString(block['text'])
if (text) return text
}
return ''
}
// The session's prompt is the first user line carrying real text. Tool results
// also arrive as user lines but as blocks with no text block, so they are
// skipped here.
function firstUserMessage(lines: unknown[]): string {
for (const line of lines) {
if (!isRecord(line) || line['type'] !== 'user') continue
const message = line['message']
if (!isRecord(message)) continue
const text = textFromContent(message['content'])
if (text) return text
}
return ''
}
function projectFromCwd(cwd: string): string | undefined {
const parts = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean)
return parts.at(-1)
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const raw = await readSessionFile(source.path)
if (raw === null) return
// Decode the file once, skipping blank and corrupt lines, so the
// timestamp, cwd, and user-message scans below share a single parse.
const parsedLines: unknown[] = []
for (const line of raw.split('\n')) {
if (!line.trim()) continue
try {
parsedLines.push(JSON.parse(line) as unknown)
} catch {
// Corrupt line - skip it.
}
}
// File-level fallback timestamp: the first valid ISO timestamp in the
// file, so a line missing one still lands inside the session's window.
let fileTimestamp = new Date(0).toISOString()
for (const line of parsedLines) {
if (!isRecord(line)) continue
const ts = nonEmptyString(line['timestamp'])
if (!ts) continue
const date = new Date(ts)
if (Number.isNaN(date.getTime())) continue
fileTimestamp = date.toISOString()
break
}
const userMessage = firstUserMessage(parsedLines)
// The project is the basename of the first cwd seen in the transcript
// (what the user recognizes); the discovery-time slug is the fallback.
let firstSeenCwd: string | undefined
for (const line of parsedLines) {
if (!isRecord(line)) continue
const cwd = nonEmptyString(line['cwd'])
if (!cwd) continue
firstSeenCwd = cwd
break
}
const project = firstSeenCwd
? (projectFromCwd(firstSeenCwd) ?? source.project)
: source.project
for (const [index, line] of parsedLines.entries()) {
if (!isRecord(line) || line['type'] !== 'assistant') continue
const message = line['message']
if (!isRecord(message)) continue
// Only assistant lines carrying a usage record become calls; the rest
// of the transcript (queue-operation, last-prompt, user lines) is
// skipped silently.
const usage = message['usage']
if (!isRecord(usage)) continue
const inputTokens = safeTokenCount(usage['input_tokens'])
const outputTokens = safeTokenCount(usage['output_tokens'])
const cacheWriteTokens = safeTokenCount(usage['cache_creation_input_tokens'])
const cacheReadTokens = safeTokenCount(usage['cache_read_input_tokens'])
const webSearchRequests = safeTokenCount(
isRecord(usage['server_tool_use']) ? usage['server_tool_use']['web_search_requests'] : undefined,
)
const model = nonEmptyString(message['model']) ?? 'unknown'
const sessionId = nonEmptyString(line['sessionId']) ?? basename(source.path).replace(/\.jsonl$/, '')
const messageId = nonEmptyString(message['id']) ?? nonEmptyString(line['uuid']) ?? String(index)
const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:${messageId}`
if (seenKeys.has(deduplicationKey)) continue
seenKeys.add(deduplicationKey)
const { tools, bashCommands, toolSequence, skills, subagentTypes } = collectTools(message['content'])
yield {
provider: PROVIDER_NAME,
model,
inputTokens,
outputTokens,
cacheCreationInputTokens: cacheWriteTokens,
cacheReadInputTokens: cacheReadTokens,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests,
costUSD: calculateCost(model, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, 0),
costIsEstimated: true,
tools,
bashCommands,
skills: skills.length > 0 ? skills : undefined,
subagentTypes: subagentTypes.length > 0 ? subagentTypes : undefined,
timestamp: isoTimestamp(line['timestamp'], fileTimestamp),
speed: 'standard',
deduplicationKey,
turnId: `${sessionId}:${messageId}`,
toolSequence: toolSequence.length > 0 ? toolSequence : undefined,
userMessage,
sessionId,
project,
projectPath: firstSeenCwd,
workingDirectory: nonEmptyString(line['cwd']),
}
}
},
}
}
export function createOpenClaudeProvider(overrideProjectsDir?: string): Provider {
const projectsDir = (): string => overrideProjectsDir ?? getOpenClaudeProjectsDir()
return {
name: PROVIDER_NAME,
displayName: DISPLAY_NAME,
modelDisplayName(model: string): string {
return getShortModelName(model)
},
toolDisplayName(rawTool: string): string {
// OpenClaude tool names are already codeburn-canonical - pass through.
return rawTool
},
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: projectsDir(), label: 'projects' }]
},
async discoverSessions(): Promise<SessionSource[]> {
const dir = projectsDir()
const projectEntries = await readdir(dir, { withFileTypes: true }).catch(() => [])
const sources: SessionSource[] = []
for (const projectEntry of projectEntries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!projectEntry.isDirectory()) continue
const projectDir = join(dir, projectEntry.name)
const fileEntries = await readdir(projectDir, { withFileTypes: true }).catch(() => [])
for (const fileEntry of fileEntries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!fileEntry.isFile()) continue
// Only transcripts; the sibling `<uuid>.replay.json` files end in
// `.json` and never match.
if (!fileEntry.name.endsWith('.jsonl')) continue
sources.push({
path: join(projectDir, fileEntry.name),
// The whole slug is the discovery-time fallback; parse-time
// project naming prefers the basename of the first cwd seen in
// the transcript.
project: projectEntry.name,
provider: PROVIDER_NAME,
})
}
}
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const openclaude = createOpenClaudeProvider()

View file

@ -203,6 +203,7 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
// kept: removing it would force a re-parse to fix nothing.
'cursor-agent': ['XDG_DATA_HOME'],
'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'],
openclaude: ['CODEBURN_OPENCLAUDE_DIR'],
opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'],
goose: ['XDG_DATA_HOME', 'GOOSE_PATH_ROOT'],
grok: ['GROK_HOME'],

View file

@ -40,6 +40,7 @@ const FILE_PROVIDERS: Record<string, string[]> = {
'cursor.ts': ['cursor'],
// The ENV_DIR const (open-design.ts:10) resolves to CODEBURN_OPEN_DESIGN_DIR.
'open-design.ts': ['open-design'],
'openclaude.ts': ['openclaude'],
'opencode.ts': ['opencode'],
'goose.ts': ['goose'],
'grok.ts': ['grok'],

View file

@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
})
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {

View file

@ -0,0 +1,413 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { openclaude, createOpenClaudeProvider, getOpenClaudeProjectsDir } from '../../src/providers/openclaude.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
import { calculateCost, loadPricing } from '../../src/models.js'
let tmpDir: string
let uuidSeq: number
let msgSeq: number
function nextUuid(): string {
uuidSeq += 1
return `00000000-0000-4000-8000-${String(uuidSeq).padStart(12, '0')}`
}
function nextMessageId(): string {
msgSeq += 1
return `msg_${msgSeq}`
}
type UsageSpec = {
inputTokens?: number
outputTokens?: number
cacheCreationInputTokens?: number
cacheReadInputTokens?: number
webSearchRequests?: number
}
type ContentBlock = Record<string, unknown>
type MessageSpec = {
role: 'user' | 'assistant'
id?: string
model?: string
text?: string
content?: ContentBlock[]
usage?: UsageSpec | null
}
type EventSpec = {
type: 'user' | 'assistant' | 'queue-operation' | 'last-prompt'
sessionId?: string
timestamp?: string
uuid?: string
cwd?: string
slug?: string
isSidechain?: boolean
message?: MessageSpec
payload?: Record<string, unknown>
}
const DEFAULT_TIMESTAMP = '2026-08-04T00:44:18.625Z'
function buildMessage(spec: MessageSpec): Record<string, unknown> {
const message: Record<string, unknown> = {
id: spec.id ?? nextMessageId(),
role: spec.role,
content: [],
}
if (spec.model) message['model'] = spec.model
if (spec.content) {
message['content'] = spec.content
} else if (spec.text !== undefined) {
message['content'] = spec.role === 'user' ? spec.text : [{ type: 'text', text: spec.text }]
}
if (spec.usage !== undefined) {
message['usage'] = spec.usage === null ? null : {
input_tokens: spec.usage?.inputTokens ?? 0,
output_tokens: spec.usage?.outputTokens ?? 0,
cache_creation_input_tokens: spec.usage?.cacheCreationInputTokens ?? 0,
cache_read_input_tokens: spec.usage?.cacheReadInputTokens ?? 0,
server_tool_use: {
web_search_requests: spec.usage?.webSearchRequests ?? 0,
web_fetch_requests: 0,
},
}
}
return message
}
function buildEvent(spec: EventSpec, sessionId: string): Record<string, unknown> {
const event: Record<string, unknown> = { type: spec.type, ...(spec.payload ?? {}) }
event['sessionId'] = spec.sessionId ?? sessionId
if (spec.type !== 'last-prompt') event['timestamp'] = spec.timestamp ?? DEFAULT_TIMESTAMP
if (spec.type === 'user' || spec.type === 'assistant') {
event['uuid'] = spec.uuid ?? nextUuid()
if (spec.cwd) event['cwd'] = spec.cwd
if (spec.slug) event['slug'] = spec.slug
if (spec.isSidechain !== undefined) event['isSidechain'] = spec.isSidechain
if (spec.message) event['message'] = buildMessage(spec.message)
}
return event
}
/** Write one REAL-schema OpenClaude transcript at <projectsDir>/<slug>/<fileUuid>.jsonl. */
async function writeSession(projectsDir: string, slug: string, fileUuid: string, opts?: {
sessionId?: string
cwd?: string
events?: EventSpec[]
}): Promise<string> {
const dir = join(projectsDir, slug)
await mkdir(dir, { recursive: true })
const path = join(dir, `${fileUuid}.jsonl`)
const sessionId = opts?.sessionId ?? fileUuid
const events = (opts?.events ?? []).map((spec) => {
const event = buildEvent(spec, sessionId)
if ((spec.type === 'user' || spec.type === 'assistant') && opts?.cwd && event['cwd'] === undefined) {
event['cwd'] = opts.cwd
}
return event
})
await writeFile(path, events.map((event) => JSON.stringify(event)).join('\n') + '\n')
return path
}
async function collect(projectsDir: string): Promise<ParsedProviderCall[]> {
const provider = createOpenClaudeProvider(projectsDir)
const sources = await provider.discoverSessions()
const seenKeys = new Set<string>()
const calls: ParsedProviderCall[] = []
for (const source of sources) {
for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
}
return calls
}
beforeAll(async () => {
await loadPricing()
})
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'openclaude-test-'))
uuidSeq = 0
msgSeq = 0
delete process.env['CODEBURN_OPENCLAUDE_DIR']
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
describe('openclaude provider - identity', () => {
it('registers under its own provider name', () => {
expect(openclaude.name).toBe('openclaude')
expect(openclaude.displayName).toBe('OpenClaude')
})
it('passes tool names through unchanged (Claude Code canonical)', () => {
expect(openclaude.toolDisplayName('Write')).toBe('Write')
expect(openclaude.toolDisplayName('Read')).toBe('Read')
expect(openclaude.toolDisplayName('Bash')).toBe('Bash')
expect(openclaude.toolDisplayName('Grep')).toBe('Grep')
expect(openclaude.toolDisplayName('Edit')).toBe('Edit')
expect(openclaude.toolDisplayName('WebFetch')).toBe('WebFetch')
expect(openclaude.toolDisplayName('future_tool_xyz')).toBe('future_tool_xyz')
})
})
describe('openclaude provider - projects dir resolution', () => {
it('defaults to ~/.openclaude/projects', () => {
expect(getOpenClaudeProjectsDir()).toBe(join(process.env['HOME'] ?? '', '.openclaude', 'projects'))
})
it('honors the CODEBURN_OPENCLAUDE_DIR override', () => {
process.env['CODEBURN_OPENCLAUDE_DIR'] = '/tmp/oc-projects'
expect(getOpenClaudeProjectsDir()).toBe(join('/tmp/oc-projects', 'projects'))
})
})
describe('openclaude provider - probe roots', () => {
it('reports the projects dir with the projects label', async () => {
const provider = createOpenClaudeProvider('/tmp/x')
expect(await provider.probeRoots!()).toEqual([{ path: '/tmp/x', label: 'projects' }])
})
it('defaults to the standard projects dir for the exported provider', async () => {
expect(await openclaude.probeRoots!()).toEqual([
{ path: join(process.env['HOME'] ?? '', '.openclaude', 'projects'), label: 'projects' },
])
})
})
describe('openclaude provider - discovery', () => {
it('finds one source per .jsonl transcript, nested under project slugs', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'demo-proj', '11111111-1111-1111-1111-111111111111')
await writeSession(projects, 'demo-proj', '22222222-2222-2222-2222-222222222222')
await writeSession(projects, 'other-proj', '33333333-3333-3333-3333-333333333333')
const provider = createOpenClaudeProvider(projects)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(3)
expect(sources.map((source) => source.project).sort()).toEqual(['demo-proj', 'demo-proj', 'other-proj'])
})
it('skips .replay.json siblings, non-jsonl files and non-directories', async () => {
const projects = join(tmpDir, 'projects')
const sessionDir = join(projects, 'demo-proj')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.jsonl'), '{"type":"assistant","sessionId":"s1","message":{"id":"m1","role":"assistant","model":"deepseek-chat","usage":{"input_tokens":100,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}\n')
await writeFile(join(sessionDir, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.replay.json'), '{"replay":true}')
await writeFile(join(sessionDir, 'notes.txt'), 'not a transcript')
await writeFile(join(projects, 'loose-file.jsonl'), '{"type":"assistant"}\n')
const provider = createOpenClaudeProvider(projects)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(1)
expect(sources[0]?.path).toBe(join(sessionDir, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.jsonl'))
})
it('returns [] when the projects dir is missing', async () => {
const provider = createOpenClaudeProvider(join(tmpDir, 'does-not-exist'))
expect(await provider.discoverSessions()).toEqual([])
})
})
describe('openclaude provider - parsing', () => {
it('emits one call per assistant line with usage, mapping token fields, model and web searches', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'demo-proj', 'e317d7f4-file', {
sessionId: 'e317d7f4-7921-400e-9cc5-38fe0b81cd52',
cwd: '/Users/husamsoboh/scratch/openclaude-real/demo-proj',
events: [
{ type: 'queue-operation', payload: { operation: 'enqueue' } },
{ type: 'user', message: { role: 'user', text: 'Write a fibonacci function' } },
{
type: 'assistant',
message: {
role: 'assistant',
id: 'msg_32979ac8b65848e4903c37cd533475a1',
model: 'deepseek-chat',
usage: { inputTokens: 16217, outputTokens: 125 },
},
},
{
type: 'assistant',
message: {
role: 'assistant',
id: 'msg_0c0dd9d7a75b43b0b2cb3735ebc2cac7',
model: 'deepseek-chat',
content: [{ type: 'tool_use', id: 'call_00_Kd4Zg8NLsX0L9rTnWrwq1798', name: 'Bash', input: { command: 'python3 -c "from fib import fib"' } }],
usage: { inputTokens: 500, outputTokens: 60, webSearchRequests: 2 },
},
},
{ type: 'last-prompt', payload: { lastPrompt: 'Write a fibonacci function' } },
],
})
const calls = await collect(projects)
expect(calls).toHaveLength(2)
const first = calls[0]!
const second = calls[1]!
expect(first.provider).toBe('openclaude')
expect(first.sessionId).toBe('e317d7f4-7921-400e-9cc5-38fe0b81cd52')
expect(first.model).toBe('deepseek-chat')
expect(first.inputTokens).toBe(16217)
expect(first.outputTokens).toBe(125)
expect(first.cacheCreationInputTokens).toBe(0)
expect(first.cacheReadInputTokens).toBe(0)
expect(first.webSearchRequests).toBe(0)
expect(first.userMessage).toBe('Write a fibonacci function')
expect(first.timestamp).toBe(DEFAULT_TIMESTAMP)
expect(first.deduplicationKey).toBe('openclaude:e317d7f4-7921-400e-9cc5-38fe0b81cd52:msg_32979ac8b65848e4903c37cd533475a1')
expect(second.webSearchRequests).toBe(2)
expect(second.tools).toEqual(['Bash'])
expect(second.bashCommands.length).toBeGreaterThan(0)
})
it('ignores queue-operation, last-prompt, user and usage-less assistant lines', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'demo-proj', 'file-1', {
events: [
{ type: 'queue-operation', payload: { operation: 'enqueue' } },
{ type: 'user', message: { role: 'user', text: 'hello' } },
{ type: 'assistant', message: { role: 'assistant', id: 'no-usage', text: 'thinking only' } },
{ type: 'assistant', message: { role: 'assistant', id: 'null-usage', usage: null, text: 'hi' } },
{ type: 'last-prompt', payload: { lastPrompt: 'hello' } },
{ type: 'assistant', message: { role: 'assistant', id: 'real', usage: { inputTokens: 100, outputTokens: 10 } } },
],
})
const calls = await collect(projects)
expect(calls).toHaveLength(1)
expect(calls[0]?.deduplicationKey).toBe('openclaude:file-1:real')
expect(calls[0]?.userMessage).toBe('hello')
})
it('skips corrupt lines without killing the file', async () => {
const projects = join(tmpDir, 'projects')
const sessionDir = join(projects, 'demo-proj')
await mkdir(sessionDir, { recursive: true })
const path = join(sessionDir, 'file-1.jsonl')
await writeFile(path, [
'{"type":"user","sessionId":"s1","message":{"role":"user","content":"prompt"}}',
'{definitely not json',
'{"type":"assistant","sessionId":"s1","message":{"id":"m1","role":"assistant","model":"deepseek-chat","usage":{"input_tokens":100,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
'{"type":"assistant","sessionId":"s1","message":{"id":"m2","role":"assistant","usage":{"input_tokens":50,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
].join('\n') + '\n')
const calls = await collect(projects)
expect(calls).toHaveLength(2)
expect(calls.map((call) => call.deduplicationKey)).toEqual(['openclaude:s1:m1', 'openclaude:s1:m2'])
expect(calls[0]?.userMessage).toBe('prompt')
expect(calls[1]?.model).toBe('unknown')
})
it('takes the first user line with non-empty text as the user message (tool results ignored)', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'demo-proj', 'file-1', {
events: [
{
type: 'user',
message: { role: 'user', content: [{ tool_use_id: 'call_00_x', type: 'tool_result', content: 'File created' }] },
},
{ type: 'user', message: { role: 'user', text: 'Write a fibonacci function' } },
{ type: 'user', message: { role: 'user', text: 'Second prompt, ignored' } },
{ type: 'assistant', message: { role: 'assistant', id: 'a', usage: { inputTokens: 100, outputTokens: 10 } } },
],
})
const calls = await collect(projects)
expect(calls).toHaveLength(1)
expect(calls[0]?.userMessage).toBe('Write a fibonacci function')
})
})
describe('openclaude provider - dedup', () => {
it('counts the same message.id within one sessionId once across files', async () => {
const projects = join(tmpDir, 'projects')
const usage = { inputTokens: 100, outputTokens: 10 }
await writeSession(projects, 'proj-a', 'file-1', {
sessionId: 'shared-session',
events: [{ type: 'assistant', message: { role: 'assistant', id: 'dup-1', usage } }],
})
await writeSession(projects, 'proj-b', 'file-2', {
sessionId: 'shared-session',
events: [{ type: 'assistant', message: { role: 'assistant', id: 'dup-1', usage } }],
})
const calls = await collect(projects)
expect(calls).toHaveLength(1)
expect(calls[0]?.deduplicationKey).toBe('openclaude:shared-session:dup-1')
})
it('counts repeated message.id inside one file once (multi-block assistant messages)', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'proj-a', 'file-1', {
sessionId: 's1',
events: [
{ type: 'assistant', message: { role: 'assistant', id: 'msg_x', text: 'text first', usage: { inputTokens: 100, outputTokens: 10 } } },
{ type: 'assistant', message: { role: 'assistant', id: 'msg_x', usage: { inputTokens: 40, outputTokens: 5 } } },
],
})
const calls = await collect(projects)
expect(calls).toHaveLength(1)
})
})
describe('openclaude provider - sidechain', () => {
it('counts isSidechain assistant lines as real spend', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'demo-proj', 'file-1', {
events: [
{ type: 'assistant', isSidechain: true, message: { role: 'assistant', id: 'sub-1', usage: { inputTokens: 100, outputTokens: 10 } } },
{ type: 'assistant', isSidechain: false, message: { role: 'assistant', id: 'main-1', usage: { inputTokens: 100, outputTokens: 10 } } },
],
})
const calls = await collect(projects)
expect(calls).toHaveLength(2)
expect(calls.map((call) => call.deduplicationKey)).toEqual(['openclaude:file-1:sub-1', 'openclaude:file-1:main-1'])
})
})
describe('openclaude provider - project resolution', () => {
it('uses basename of cwd, else the project slug dir', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'slug-dir', 'file-1', {
cwd: '/Users/dev/work/real-project',
events: [{ type: 'assistant', message: { role: 'assistant', id: 'a', usage: { inputTokens: 100, outputTokens: 10 } } }],
})
await writeSession(projects, 'slug-dir', 'file-2', {
events: [{ type: 'assistant', slug: 'line-slug', message: { role: 'assistant', id: 'b', usage: { inputTokens: 100, outputTokens: 10 } } }],
})
await writeSession(projects, 'slug-dir', 'file-3', {
events: [{ type: 'assistant', message: { role: 'assistant', id: 'c', usage: { inputTokens: 100, outputTokens: 10 } } }],
})
const calls = await collect(projects)
expect(calls).toHaveLength(3)
const byId = new Map(calls.map((call) => [call.deduplicationKey, call.project]))
expect(byId.get('openclaude:file-1:a')).toBe('real-project')
expect(byId.get('openclaude:file-2:b')).toBe('slug-dir')
expect(byId.get('openclaude:file-3:c')).toBe('slug-dir')
})
})
describe('openclaude provider - cost', () => {
it('computes an estimated cost for every call (transcripts carry no cost field)', async () => {
const projects = join(tmpDir, 'projects')
await writeSession(projects, 'demo-proj', 'file-1', {
events: [
{ type: 'assistant', message: { role: 'assistant', id: 'a', model: 'deepseek-chat', usage: { inputTokens: 16217, outputTokens: 125 } } },
{ type: 'assistant', message: { role: 'assistant', id: 'b', usage: { inputTokens: 50, outputTokens: 5 } } },
{ type: 'assistant', isSidechain: true, message: { role: 'assistant', id: 'c', usage: { inputTokens: 10, outputTokens: 2 } } },
],
})
const calls = await collect(projects)
expect(calls).toHaveLength(3)
for (const call of calls) {
expect(call.costIsEstimated).toBe(true)
expect(Number.isFinite(call.costUSD)).toBe(true)
}
// Priced through the shared tables, self-consistent with calculateCost.
expect(calls[0]?.costUSD).toBeGreaterThan(0)
expect(calls[0]?.costUSD).toBeCloseTo(calculateCost('deepseek-chat', 16217, 125, 0, 0, 0), 10)
expect(calls[1]?.costUSD).toBe(0)
})
})