mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-17 04:24:22 +00:00
Merge pull request #905 from ozymandiashh/feat/213-openclaude-provider
feat(openclaude): OpenClaude CLI provider (#213)
This commit is contained in:
commit
60a8b29103
7 changed files with 779 additions and 2 deletions
33
docs/providers/openclaude.md
Normal file
33
docs/providers/openclaude.md
Normal 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.
|
||||
|
|
@ -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
328
src/providers/openclaude.ts
Normal 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()
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
413
tests/providers/openclaude.test.ts
Normal file
413
tests/providers/openclaude.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue