mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
* feat: Amp support - read the local thread mirror (#1572) Amp (Sourcegraph's coding agent) mirrors every thread it serves as one JSON document per thread at ~/.local/share/amp/threads/ (AMP_DATA_DIR honored, comma-separated). Each file is { id, messages, usageLedger?{events} }; the ledger's events are the per-request record (tokens.input/output, cache tokens joined from the billed message through toMessageId -> messages[].messageId), with a per-assistant- message usage fallback for threads without a ledger and a total-only record billing as output. Amp bills a subscription and records no charged dollars, so every call is priced from the underlying model's token rates and marked costIsEstimated; the recorded credits field is ignored (no published per-credit dollar rate). Thread files carry no cwd, so sessions group under the provider name. Registered eager; parse version usage-ledger-v1; no artwork yet (mono-initial fallback, like Codebuff). Surfaces: CLI registry + doctor probe roots, Electron sandbox read paths + menubar catalog (Amp after Cursor, matching the Swift order), Windows tray tab color + usage_guard root, GNOME prefs dropdown, macOS ProviderConnectionCatalog, docs/providers/amp.md, README tool list, CHANGELOG. * fix: address Amp provider review feedback
This commit is contained in:
parent
d3929746ae
commit
157347aa99
19 changed files with 783 additions and 6 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **Amp support.** CodeBurn reads Sourcegraph Amp's local thread mirror at `~/.local/share/amp/threads/` (`AMP_DATA_DIR` honored, comma-separated): one JSON document per thread with a per-request usage ledger (`tokens.input`/`output`, cache tokens joined from the billed message through `toMessageId`) and a per-message usage fallback for older threads. Every call is priced from the underlying model's token rates and marked estimated; Amp's own credits field is not converted; thread files carry no cwd, so sessions group under the provider name. Reported in #1572.
|
||||
- **`codeburn import cursor <file.csv>` replaces the local Cursor estimates with Cursor's own usage export.** The local database has no per-request token counts and none of the cache reads Cursor re-sends, so on one real month CodeBurn saw about 7M Cursor tokens where the dashboard showed 644M. Export CSV at cursor.com/dashboard/usage and import it: for the time the export covers, its events replace the local Cursor, Cursor Agent and (when the export holds Grok Bot events) Grok Bot estimates, and outside it the estimates stay. Dollar amounts in the export are kept as billed; included usage is priced at API rates like every other local call. Pass `--from`/`--to` with the range you exported, re-import overlapping exports without double counting, and `--remove` to go back. Stored locally, nothing is uploaded. Reported in #1545.
|
||||
|
||||
### Removed
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ That registers a local MCP server over stdio. Your agent can then answer "where
|
|||
|
||||
It reads the same files on disk that the CLI reads. The server answers from that local data and makes no network call of its own, and project names are pseudonymized unless the agent asks for them.
|
||||
|
||||
## Works with 40 tools
|
||||
## Works with 41 tools
|
||||
|
||||
CodeBurn detects the tools you already use. There is nothing to configure and no folder to point it at. If a tool is installed and has sessions on disk, it shows up. Each logo links to that tool's page.
|
||||
|
||||
|
|
@ -198,11 +198,11 @@ CodeBurn detects the tools you already use. There is nothing to configure and no
|
|||
</p>
|
||||
|
||||
<details>
|
||||
<summary><strong>All 40 tools</strong></summary>
|
||||
<summary><strong>All 41 tools</strong></summary>
|
||||
|
||||
Each page lists where that tool keeps its data, the format it uses, and the quirks CodeBurn works around.
|
||||
|
||||
[Antigravity](docs/providers/antigravity.md) · [Claude Code](docs/providers/claude.md) · [Cline](docs/providers/cline.md) · [Cline CLI](docs/providers/cline-cli.md) · [Codebuff](docs/providers/codebuff.md) · [Codex](docs/providers/codex.md) ·
|
||||
[Amp](docs/providers/amp.md) · [Antigravity](docs/providers/antigravity.md) · [Claude Code](docs/providers/claude.md) · [Cline](docs/providers/cline.md) · [Cline CLI](docs/providers/cline-cli.md) · [Codebuff](docs/providers/codebuff.md) · [Codex](docs/providers/codex.md) ·
|
||||
[CodeWhale](docs/providers/codewhale.md) · [Copilot](docs/providers/copilot.md) · [Crush](docs/providers/crush.md) · [Cursor](docs/providers/cursor.md) · [Cursor Agent](docs/providers/cursor-agent.md) · [DeepSeek Harness](docs/providers/dsh.md) ·
|
||||
[Devin](docs/providers/devin.md) · [Droid](docs/providers/droid.md) · [Forge](docs/providers/forge.md) · [Gemini CLI](docs/providers/gemini.md) · [Goose](docs/providers/goose.md) · [Grok Bot](docs/providers/grokbot.md) ·
|
||||
[Grok Build](docs/providers/grok.md) · [Hermes Agent](docs/providers/hermes.md) · [IBM Bob](docs/providers/ibm-bob.md) · [KiloCode](docs/providers/kilo-code.md) · [Kimi](docs/providers/kimi.md) · [Kimi Code](docs/providers/kimicode.md) ·
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@
|
|||
"$HOME/.local/share/opencode",
|
||||
"$HOME/.local/share/crush",
|
||||
"$HOME/.local/share/goose/sessions",
|
||||
"$HOME/.local/share/amp/threads",
|
||||
"$HOME/.local/share/kilo",
|
||||
"$HOME/.local/share/zerostack/sessions"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ type Provider = {
|
|||
|
||||
`src/providers/index.ts` registers providers across two tiers:
|
||||
|
||||
- **Eager**: `claude`, `cline`, `cline-cli`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `dsh`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `kimicode`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `openclaude`, `open-design`, `pi`, `omp`, `qwen`, `quickdesk`, `zerostack`, `grok`. Imported at module load.
|
||||
- **Eager**: `claude`, `cline`, `cline-cli`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `dsh`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `kimicode`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `openclaude`, `open-design`, `pi`, `omp`, `qwen`, `quickdesk`, `zerostack`, `grok`, `grokbot`, `amp`. Imported at module load.
|
||||
- **Lazy**: `antigravity`, `forge`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`, `warp`, `vercel-gateway`, `zcode`, `zed`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf, network clients) do not touch users who do not have those tools installed.
|
||||
|
||||
Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
|
||||
| Provider | Storage | Source | Test |
|
||||
|---|---|---|---|
|
||||
| [Amp](amp.md) | JSON (per-thread) | `src/providers/amp.ts` | `tests/providers/amp.test.ts` |
|
||||
| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) |
|
||||
| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` |
|
||||
| [Cline CLI](cline-cli.md) | JSON | `src/providers/cline-cli.ts` | `tests/providers/cline-cli.test.ts` |
|
||||
|
|
|
|||
48
docs/providers/amp.md
Normal file
48
docs/providers/amp.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Amp
|
||||
|
||||
Amp, Sourcegraph's coding agent ([ampcode.com](https://ampcode.com)), as a CLI or editor extension. Reported in #1572.
|
||||
|
||||
- **Source:** `src/providers/amp.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/amp.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$AMP_DATA_DIR/threads/` (or `~/.local/share/amp/threads/`). `AMP_DATA_DIR` is Amp's own override; CodeBurn accepts a comma-separated list the way Amp does. One JSON document per thread, discovered recursively under `threads/**/*.json`.
|
||||
|
||||
## Storage format
|
||||
|
||||
A thread file is `{ id, messages: [...], usageLedger?: { events: [...] } }`. `usageLedger.events` is the per-request record: `{ id, timestamp, model, tokens: { input, output, total }, toMessageId, credits }`. Cache tokens are not on the event - they live on the thread message the event billed for, joined through `toMessageId` -> `messages[].messageId` -> `usage.cacheCreationInputTokens` / `usage.cacheReadInputTokens`. Threads without a ledger fall back to per-assistant-message usage blocks (`{ inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens, totalTokens, timestamp, model }`, with `timestamp` and `model` allowed on the message itself). The format is mapped against ccusage's amp adapter, which parses real thread files.
|
||||
|
||||
## Token model
|
||||
|
||||
**Ledger-first.** When a thread carries a usage ledger, each event is one call. Any positive remainder of `tokens.total` after subtracting input, output, and both cache-token counts is added to output. A total-only `tokens` record (`{"total": N}`) without cache tokens therefore bills as `N` output tokens. Cache tokens come from the billed message via `toMessageId`; a record whose `toMessageId` does not resolve prices without cache. No reasoning field exists, so `reasoningTokens` is 0.
|
||||
|
||||
**Fallback.** Without a ledger, every assistant message with a usable usage block is one call.
|
||||
|
||||
A record with all four token fields empty or malformed contributes nothing; untrusted numbers are clamped to finite non-negative before they can reach the aggregate.
|
||||
|
||||
## Pricing
|
||||
|
||||
Every call is priced from the model's token rates in `src/models.ts` and carries `costIsEstimated`; Amp's own `credits` field is not converted. Amp's model ids are the underlying OpenAI/Anthropic/Google ids and price through the standard catalog.
|
||||
|
||||
## Caching
|
||||
|
||||
Cache-read and cache-creation counts arrive through the ledger join (or the message's usage block). Token-priced costs are not cached; the cached call is re-priced on read, so a pricing update reaches history.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `amp:<thread-id>:<event-id>` from the ledger, or `amp:<thread-id>:<timestamp>:<model>` on the message fallback path.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No project attribution.** Thread files carry no cwd, so Amp sessions group under the provider name (`Amp`) instead of a repository.
|
||||
- **No tool capture.** Thread files expose no tool-call records CodeBurn could read, so `tools` and `bashCommands` are empty.
|
||||
- **Estimated costs by design.** Every call is marked `costIsEstimated` (see Pricing). Token counts themselves are provider-recorded.
|
||||
- **`credits` is not converted.** CodeBurn estimates cost from tokens rather than summing Amp's recorded credits.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Discovery: check the recursive `threads/**/*.json` walk, the `.json` extension filter (without reading file contents), and the `AMP_DATA_DIR` resolution (comma-separated).
|
||||
2. Token accounting: see `parseThreadFile` (ledger join through `toMessageId` -> `messages[].messageId`, then the per-message fallback).
|
||||
3. Add a fixture-format thread under `tests/providers/amp.test.ts`; do not mock the filesystem.
|
||||
|
|
@ -7,6 +7,7 @@ const PROVIDERS = [
|
|||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'copilot', label: 'Copilot' },
|
||||
{ id: 'amp', label: 'Amp' },
|
||||
{ id: 'cursor', label: 'Cursor' },
|
||||
{ id: 'devin', label: 'Devin' },
|
||||
{ id: 'droid', label: 'Droid' },
|
||||
|
|
|
|||
|
|
@ -117,6 +117,13 @@ enum UsageDataChangeGuard {
|
|||
add(path(xdgData, "kilo"), scanFirstLevelDirectories: false)
|
||||
add(expand(environment["GOOSE_PATH_ROOT"] ?? path(xdgData, "goose"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
||||
add(expand(environment["CRUSH_GLOBAL_DATA"] ?? path(xdgData, "crush"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
||||
let ampDataDirectories = (environment["AMP_DATA_DIR"] ?? "")
|
||||
.split(separator: ",")
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
for ampDataDirectory in ampDataDirectories.isEmpty ? [path(homeDirectory, ".local", "share", "amp")] : ampDataDirectories {
|
||||
add(ampDataDirectory)
|
||||
}
|
||||
// Warp's default location is never watched. Its database lives in
|
||||
// ~/Library/Group Containers/2BBY89MBSN.dev.warp (src/providers/warp.ts),
|
||||
// and that tree is behind the "access data from other apps" consent: a
|
||||
|
|
|
|||
|
|
@ -100,6 +100,41 @@ struct UsageDataChangeGuardTests {
|
|||
#expect(!set.modificationDates.keys.contains { $0.contains("Group Containers") })
|
||||
}
|
||||
|
||||
@Test("Amp watches the default root and comma-separated overrides")
|
||||
func ampDataRootsAreWatched() throws {
|
||||
let home = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("CodeBurnMenubarTests.\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
let defaultRoot = home.appendingPathComponent(".local/share/amp")
|
||||
let first = home.appendingPathComponent("amp-a")
|
||||
let second = home.appendingPathComponent("amp-b")
|
||||
for root in [defaultRoot, first, second] {
|
||||
try FileManager.default.createDirectory(
|
||||
at: root.appendingPathComponent("threads"), withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
let environment = ["CLAUDE_CONFIG_DIR": home.path]
|
||||
let unset = UsageDataChangeGuard.snapshot(environment: environment, homeDirectory: home.path)
|
||||
#expect(unset.modificationDates[defaultRoot.path] != nil)
|
||||
#expect(unset.modificationDates[defaultRoot.appendingPathComponent("threads").path] != nil)
|
||||
|
||||
let set = UsageDataChangeGuard.snapshot(
|
||||
environment: environment.merging(["AMP_DATA_DIR": " \(first.path), ,\(second.path), "]) { _, new in new },
|
||||
homeDirectory: home.path
|
||||
)
|
||||
for root in [first, second] {
|
||||
#expect(set.modificationDates[root.path] != nil)
|
||||
#expect(set.modificationDates[root.appendingPathComponent("threads").path] != nil)
|
||||
}
|
||||
#expect(set.modificationDates[defaultRoot.path] == nil)
|
||||
|
||||
let empty = UsageDataChangeGuard.snapshot(
|
||||
environment: environment.merging(["AMP_DATA_DIR": " , , "]) { _, new in new },
|
||||
homeDirectory: home.path
|
||||
)
|
||||
#expect(empty.modificationDates[defaultRoot.path] != nil)
|
||||
}
|
||||
|
||||
private func makeSnapshot(_ seconds: TimeInterval) -> UsageDataSnapshot {
|
||||
UsageDataSnapshot(modificationDates: ["provider-root": Date(timeIntervalSince1970: seconds)])
|
||||
}
|
||||
|
|
|
|||
331
src/providers/amp.ts
Normal file
331
src/providers/amp.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
// Amp (Sourcegraph's coding agent, ampcode.com) mirrors every thread it
|
||||
// serves as one JSON document per thread at <amp-data>/threads/<id>.json,
|
||||
// where amp-data is $AMP_DATA_DIR (comma-separated for extra roots) or
|
||||
// ~/.local/share/amp. Mapped against the format ccusage's amp adapter reads
|
||||
// (rust/adapters/amp/src/parser.rs) - the same layout its own tests fixture:
|
||||
//
|
||||
// { id, messages: [...], usageLedger?: { events: [...] } }
|
||||
//
|
||||
// usageLedger.events is the authoritative per-request record: { id, timestamp,
|
||||
// model, tokens: { input, output, total }, toMessageId, credits }. Cache
|
||||
// tokens are not on the event - they live on the thread message the event
|
||||
// billed for (toMessageId -> message.messageId ->
|
||||
// usage.cacheCreationInputTokens / usage.cacheReadInputTokens). Threads
|
||||
// without a ledger fall back to the per-assistant-message usage blocks
|
||||
// ({ inputTokens, outputTokens, cacheCreationInputTokens,
|
||||
// cacheReadInputTokens, totalTokens, timestamp, model }). When a record
|
||||
// publishes only tokens.total, the total becomes the output count.
|
||||
//
|
||||
// Costs are priced from tokens; Amp's own credits field is not converted.
|
||||
// Every cost uses CodeBurn's model pricing (models.ts) and carries
|
||||
// costIsEstimated. Thread files carry no project/cwd, so sessions group under
|
||||
// the provider name. Amp's model ids are the underlying OpenAI/Anthropic/
|
||||
// Google ids and price through the standard catalog.
|
||||
|
||||
// Amp's data dir can be relocated via AMP_DATA_DIR (its own env var,
|
||||
// comma-separated for multiple roots, matching Amp's semantics).
|
||||
function ampDataDirs(): string[] {
|
||||
const override = process.env['AMP_DATA_DIR']
|
||||
if (override) {
|
||||
const dirs = override.split(',').map(part => part.trim()).filter(Boolean)
|
||||
if (dirs.length > 0) return [...new Set(dirs)]
|
||||
}
|
||||
return [join(homedir(), '.local/share/amp')]
|
||||
}
|
||||
|
||||
function threadsDirFor(ampDataDir: string): string {
|
||||
return join(ampDataDir, 'threads')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// Thread files are third-party input that moves with Amp releases. Keep the
|
||||
// check local so bad usage fields become absent rather than leaking NaN,
|
||||
// negative tokens, or a throwing arithmetic operation into the aggregate.
|
||||
function finiteNonNegative(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return Math.min(value, Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, field: string): number {
|
||||
return finiteNonNegative(record[field]) ?? 0
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
// User text feeds the `userMessage` column. Content may be a plain string or
|
||||
// a block array; only the text blocks are usable.
|
||||
function messageText(message: Record<string, unknown>): string {
|
||||
const content = message['content']
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(isRecord)
|
||||
.map(block => nonEmptyString(block['text']) ?? '')
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
return nonEmptyString(message['text']) ?? ''
|
||||
}
|
||||
|
||||
type AmpCallRecord = {
|
||||
model: string
|
||||
timestamp: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationInputTokens: number
|
||||
cacheReadInputTokens: number
|
||||
// Ledger-only: the event id dedups re-reads of the same thread file.
|
||||
eventId: string | undefined
|
||||
// Index into the thread's message array the record billed for (ledger
|
||||
// toMessageId, or the message's own index on the fallback path) - anchors
|
||||
// userMessage attribution.
|
||||
messageIndex: number | undefined
|
||||
}
|
||||
|
||||
type ParsedThread = {
|
||||
threadId: string
|
||||
calls: AmpCallRecord[]
|
||||
userMessages: { index: number; text: string }[]
|
||||
}
|
||||
|
||||
function parseThreadFile(content: string): ParsedThread | null {
|
||||
let thread: Record<string, unknown>
|
||||
try {
|
||||
thread = JSON.parse(content) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isRecord(thread)) return null
|
||||
const threadId = nonEmptyString(thread['id'])
|
||||
if (!threadId) return null
|
||||
|
||||
const messages = Array.isArray(thread['messages']) ? thread['messages'].filter(isRecord) : []
|
||||
const userMessages = messages
|
||||
.map((message, index) => ({ index, text: messageText(message) }))
|
||||
.filter(entry => entry.text.length > 0)
|
||||
|
||||
// Cache tokens per billed message id: ledger events join
|
||||
// toMessageId -> messages[].messageId -> usage.cache*InputTokens. The
|
||||
// message's array index travels with it to anchor userMessage attribution.
|
||||
const cacheTokensByMessageId = new Map<number, { index: number; creation: number; read: number }>()
|
||||
messages.forEach((message, index) => {
|
||||
if (message['role'] !== 'assistant') return
|
||||
const usage = message['usage']
|
||||
if (!isRecord(usage)) return
|
||||
const messageId = message['messageId']
|
||||
if (typeof messageId !== 'number' || !Number.isInteger(messageId)) return
|
||||
cacheTokensByMessageId.set(messageId, {
|
||||
index,
|
||||
creation: readNumber(usage, 'cacheCreationInputTokens'),
|
||||
read: readNumber(usage, 'cacheReadInputTokens'),
|
||||
})
|
||||
})
|
||||
|
||||
const calls: AmpCallRecord[] = []
|
||||
|
||||
// Preferred path: the usage ledger, one event per billed request.
|
||||
const ledger = thread['usageLedger']
|
||||
const events = isRecord(ledger) && Array.isArray(ledger['events']) ? ledger['events'] : []
|
||||
for (const raw of events) {
|
||||
if (!isRecord(raw)) continue
|
||||
const timestamp = nonEmptyString(raw['timestamp'])
|
||||
const model = nonEmptyString(raw['model'])
|
||||
const tokens = raw['tokens']
|
||||
if (!timestamp || !model || !isRecord(tokens)) continue
|
||||
|
||||
const inputTokens = readNumber(tokens, 'input')
|
||||
const outputTokens = readNumber(tokens, 'output')
|
||||
const total = finiteNonNegative(tokens['total'])
|
||||
const toMessageId = raw['toMessageId']
|
||||
const billed = typeof toMessageId === 'number' && Number.isInteger(toMessageId)
|
||||
? cacheTokensByMessageId.get(toMessageId)
|
||||
: undefined
|
||||
// Attribute any positive remainder to output, after accounting for the
|
||||
// explicit input/output counts and both joined cache-token counts.
|
||||
const accountedTokens = inputTokens + outputTokens + (billed?.creation ?? 0) + (billed?.read ?? 0)
|
||||
const billedOutput = outputTokens + Math.max(0, (total ?? 0) - accountedTokens)
|
||||
|
||||
calls.push({
|
||||
model,
|
||||
timestamp,
|
||||
inputTokens,
|
||||
outputTokens: billedOutput,
|
||||
cacheCreationInputTokens: billed?.creation ?? 0,
|
||||
cacheReadInputTokens: billed?.read ?? 0,
|
||||
eventId: nonEmptyString(raw['id']) ?? (typeof raw['id'] === 'number' ? String(raw['id']) : undefined),
|
||||
messageIndex: billed?.index,
|
||||
})
|
||||
}
|
||||
|
||||
if (calls.length > 0) return { threadId, calls, userMessages }
|
||||
|
||||
// Fallback: per-assistant-message usage blocks (threads without a ledger).
|
||||
messages.forEach((message, index) => {
|
||||
if (message['role'] !== 'assistant') return
|
||||
const usage = message['usage']
|
||||
if (!isRecord(usage)) return
|
||||
const timestamp = nonEmptyString(usage['timestamp']) ?? nonEmptyString(message['timestamp'])
|
||||
const model = nonEmptyString(usage['model']) ?? nonEmptyString(message['model'])
|
||||
if (!timestamp || !model) return
|
||||
|
||||
const inputTokens = readNumber(usage, 'inputTokens')
|
||||
const outputTokens = readNumber(usage, 'outputTokens')
|
||||
const cacheCreationInputTokens = readNumber(usage, 'cacheCreationInputTokens')
|
||||
const cacheReadInputTokens = readNumber(usage, 'cacheReadInputTokens')
|
||||
const total = finiteNonNegative(usage['totalTokens'])
|
||||
const hasParts = inputTokens > 0 || outputTokens > 0 || cacheCreationInputTokens > 0 || cacheReadInputTokens > 0
|
||||
const billedOutput = hasParts ? outputTokens : total ?? 0
|
||||
|
||||
calls.push({
|
||||
model,
|
||||
timestamp,
|
||||
inputTokens,
|
||||
outputTokens: billedOutput,
|
||||
cacheCreationInputTokens,
|
||||
cacheReadInputTokens,
|
||||
eventId: undefined,
|
||||
messageIndex: index,
|
||||
})
|
||||
})
|
||||
|
||||
return { threadId, calls, userMessages }
|
||||
}
|
||||
|
||||
function userMessageFor(parsed: ParsedThread, record: AmpCallRecord): string {
|
||||
// Nearest user message at or before the billed message; the thread's first
|
||||
// user text when the record carries no anchor.
|
||||
const anchor = record.messageIndex ?? Number.MAX_SAFE_INTEGER
|
||||
for (let i = parsed.userMessages.length - 1; i >= 0; i--) {
|
||||
if (parsed.userMessages[i].index < anchor) return parsed.userMessages[i].text
|
||||
}
|
||||
return parsed.userMessages[0]?.text ?? ''
|
||||
}
|
||||
|
||||
function hasPositiveTokens(record: AmpCallRecord): boolean {
|
||||
return record.inputTokens > 0
|
||||
|| record.outputTokens > 0
|
||||
|| record.cacheCreationInputTokens > 0
|
||||
|| record.cacheReadInputTokens > 0
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const content = await readSessionFile(source.path)
|
||||
if (content === null) return
|
||||
const parsed = parseThreadFile(content)
|
||||
if (!parsed) return
|
||||
|
||||
const threadId = parsed.threadId
|
||||
|
||||
for (const record of parsed.calls) {
|
||||
if (!hasPositiveTokens(record)) continue
|
||||
|
||||
const deduplicationKey = record.eventId
|
||||
? `${source.provider}:${threadId}:${record.eventId}`
|
||||
: `${source.provider}:${threadId}:${record.timestamp}:${record.model}:${record.inputTokens}x${record.outputTokens}`
|
||||
if (seenKeys.has(deduplicationKey)) continue
|
||||
seenKeys.add(deduplicationKey)
|
||||
|
||||
yield {
|
||||
provider: source.provider,
|
||||
model: record.model,
|
||||
inputTokens: record.inputTokens,
|
||||
outputTokens: record.outputTokens,
|
||||
cacheCreationInputTokens: record.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: record.cacheReadInputTokens,
|
||||
cachedInputTokens: record.cacheReadInputTokens,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: calculateCost(
|
||||
record.model,
|
||||
record.inputTokens,
|
||||
record.outputTokens,
|
||||
record.cacheCreationInputTokens,
|
||||
record.cacheReadInputTokens,
|
||||
0,
|
||||
),
|
||||
// Priced from tokens; Amp's own credits field is not converted.
|
||||
costIsEstimated: true,
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: record.timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey,
|
||||
userMessage: userMessageFor(parsed, record),
|
||||
sessionId: threadId,
|
||||
project: source.project,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverThreads(roots: string[]): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
for (const root of roots) {
|
||||
const threadsDir = threadsDirFor(root)
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(threadsDir, { recursive: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
// Discovery only walks names. The parser reads and validates each thread
|
||||
// once, skipping malformed JSON and files without a thread id.
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith('.json')) continue
|
||||
sources.push({ path: join(threadsDir, name), project: 'Amp', provider: 'amp' })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
export function createAmpProvider(overrideDirs?: string[]): Provider {
|
||||
const roots = overrideDirs ?? ampDataDirs()
|
||||
|
||||
return {
|
||||
name: 'amp',
|
||||
displayName: 'Amp',
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return roots.map(root => ({ path: threadsDirFor(root), label: 'threads' }))
|
||||
},
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return getShortModelName(model)
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverThreads(roots)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const amp = createAmpProvider()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { amp } from './amp.js'
|
||||
import { claude } from './claude.js'
|
||||
import { cline } from './cline.js'
|
||||
import { clineCli } from './cline-cli.js'
|
||||
|
|
@ -194,7 +195,7 @@ async function loadZed(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, zerostack, grok, grokbot]
|
||||
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, zerostack, grok, grokbot, amp]
|
||||
|
||||
// Lazily loaded providers, listed by name so --provider validation works even
|
||||
// when an optional module fails to load. Must stay in sync with getAllProviders.
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ const UNREFERENCED_SHARD_MAX_AGE_MS = 60 * 60 * 1000
|
|||
// readable. Hashing the policy here would instead discard the rows that make a
|
||||
// shutdown/restart cycle lossless and force a full 9P re-parse after re-enable.
|
||||
export const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
||||
amp: ['AMP_DATA_DIR'],
|
||||
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'APPDATA', 'LOCALAPPDATA'],
|
||||
'cline-cli': ['CLINE_SESSION_DATA_DIR', 'CLINE_DATA_DIR', 'CLINE_DIR'],
|
||||
codebuff: ['CODEBUFF_DATA_DIR'],
|
||||
|
|
@ -354,6 +355,9 @@ const FULL_LOAD_PROVIDER_NAMES: ReadonlySet<string> = new Set(['hermes', 'quickd
|
|||
// re-parse, which lands the flag too, and durable orphans now survive
|
||||
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
|
||||
export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
||||
// usage-ledger-v2: include positive ledger total remainders as output after
|
||||
// subtracting input, output, and cache tokens joined through toMessageId.
|
||||
amp: 'usage-ledger-v2',
|
||||
// rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call
|
||||
// LOC deltas / interruptions / userModified / toolErrors, and session-level
|
||||
// title / prLinks / isSidechain. Forces one re-parse so cached sessions gain
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { getAllProviders } from '../src/providers/index.js'
|
|||
// providers). A file that contains env reads and is missing here fails the
|
||||
// guard: add it, with the provider(s) the reads serve.
|
||||
const FILE_PROVIDERS: Record<string, string[]> = {
|
||||
'amp.ts': ['amp'],
|
||||
'claude.ts': ['claude'],
|
||||
'cline-cli.ts': ['cline-cli'],
|
||||
'codebuff.ts': ['codebuff'],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { createClineProvider, getClineDataPath } from '../src/providers/cline.js'
|
||||
import { createKiloCodeProvider } from '../src/providers/kilo-code.js'
|
||||
import { createAmpProvider } from '../src/providers/amp.js'
|
||||
import { createGrokProvider } from '../src/providers/grok.js'
|
||||
import { createPiProvider, createOmpProvider } from '../src/providers/pi.js'
|
||||
import { createKimiProvider } from '../src/providers/kimi.js'
|
||||
|
|
@ -83,6 +84,15 @@ describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('amp reports exactly its resolved threads dir', async () => {
|
||||
expect(await createAmpProvider(['/tmp/amp-a']).probeRoots!()).toEqual([
|
||||
{ path: join('/tmp/amp-a', 'threads'), label: 'threads' },
|
||||
])
|
||||
expect(await createAmpProvider().probeRoots!()).toEqual([
|
||||
{ path: join(homedir(), '.local', 'share', 'amp', 'threads'), label: 'threads' },
|
||||
])
|
||||
})
|
||||
|
||||
it('pi and omp each report their own sessions dir', async () => {
|
||||
expect(await createPiProvider('/tmp/pi-a').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/pi-a', label: 'sessions' },
|
||||
|
|
|
|||
|
|
@ -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', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'zerostack', 'grok', 'grokbot'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'zerostack', 'grok', 'grokbot', 'amp'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
|
|||
331
tests/providers/amp.test.ts
Normal file
331
tests/providers/amp.test.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createAmpProvider } from '../../src/providers/amp.js'
|
||||
import { calculateCost } from '../../src/models.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'amp-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Mirrors the real on-disk layout: <amp-data>/threads/<thread-id>.json, one
|
||||
// JSON document per thread. The shapes follow ccusage's amp adapter fixtures
|
||||
// (rust/adapters/amp/src/parser.rs), which parse real Amp thread files.
|
||||
async function writeThread(id: string, thread: unknown): Promise<string> {
|
||||
const dir = join(tmpDir, 'threads')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const path = join(dir, `${id}.json`)
|
||||
await writeFile(path, JSON.stringify(thread), 'utf-8')
|
||||
return path
|
||||
}
|
||||
|
||||
const LEDGER_THREAD = {
|
||||
id: 'T-abc123',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Fix the flaky test in parser.ts' },
|
||||
{
|
||||
role: 'assistant',
|
||||
messageId: 11,
|
||||
usage: {
|
||||
model: 'claude-opus-4-6',
|
||||
inputTokens: 10,
|
||||
outputTokens: 178,
|
||||
cacheCreationInputTokens: 986,
|
||||
cacheReadInputTokens: 11372,
|
||||
timestamp: '2026-09-20T11:42:10.652Z',
|
||||
},
|
||||
},
|
||||
{ role: 'user', content: 'Now make it fast' },
|
||||
{
|
||||
role: 'assistant',
|
||||
messageId: 14,
|
||||
usage: {
|
||||
model: 'claude-opus-4-6',
|
||||
inputTokens: 5,
|
||||
outputTokens: 42,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 12000,
|
||||
timestamp: '2026-09-20T11:43:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
usageLedger: {
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
timestamp: '2026-09-20T11:42:12.000Z',
|
||||
model: 'claude-opus-4-6',
|
||||
tokens: { input: 12368, output: 178 },
|
||||
toMessageId: 11,
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
timestamp: '2026-09-20T11:43:02.000Z',
|
||||
model: 'claude-opus-4-6',
|
||||
tokens: { input: 12005, output: 42 },
|
||||
toMessageId: 14,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async function parseAll(source: Awaited<ReturnType<ReturnType<typeof createAmpProvider>['discoverSessions']>[number]>, seenKeys = new Set<string>()) {
|
||||
const provider = createAmpProvider([tmpDir])
|
||||
const calls = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('amp provider', () => {
|
||||
it('reports its identity', () => {
|
||||
const provider = createAmpProvider([tmpDir])
|
||||
expect(provider.name).toBe('amp')
|
||||
expect(provider.displayName).toBe('Amp')
|
||||
expect(provider.modelDisplayName('claude-opus-4-6')).toBeTruthy()
|
||||
expect(provider.toolDisplayName('some_tool')).toBe('some_tool')
|
||||
})
|
||||
|
||||
it('probes the threads dir it discovers', async () => {
|
||||
const provider = createAmpProvider([tmpDir])
|
||||
expect(await provider.probeRoots()).toEqual([{ path: join(tmpDir, 'threads'), label: 'threads' }])
|
||||
})
|
||||
|
||||
it('resolves the default root from AMP_DATA_DIR, comma-separated', async () => {
|
||||
const first = await mkdtemp(join(tmpdir(), 'amp-root-a-'))
|
||||
const second = await mkdtemp(join(tmpdir(), 'amp-root-b-'))
|
||||
try {
|
||||
process.env['AMP_DATA_DIR'] = `${first}, ${second}`
|
||||
const provider = createAmpProvider()
|
||||
expect(await provider.probeRoots()).toEqual([
|
||||
{ path: join(first, 'threads'), label: 'threads' },
|
||||
{ path: join(second, 'threads'), label: 'threads' },
|
||||
])
|
||||
} finally {
|
||||
delete process.env['AMP_DATA_DIR']
|
||||
await rm(first, { recursive: true, force: true })
|
||||
await rm(second, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('discovers nothing when the data dir is absent', async () => {
|
||||
const provider = createAmpProvider([join(tmpDir, 'missing')])
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers JSON candidates without parsing and skips invalid threads during parsing', async () => {
|
||||
await writeThread('T-good', LEDGER_THREAD)
|
||||
await mkdir(join(tmpDir, 'threads'), { recursive: true })
|
||||
await writeFile(join(tmpDir, 'threads', 'notes.txt'), 'not a thread', 'utf-8')
|
||||
await writeFile(join(tmpDir, 'threads', 'T-broken.json'), '{not json', 'utf-8')
|
||||
await writeFile(join(tmpDir, 'threads', 'T-noid.json'), JSON.stringify({ messages: [] }), 'utf-8')
|
||||
|
||||
const provider = createAmpProvider([tmpDir])
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources.map(source => source.path).sort()).toEqual([
|
||||
join(tmpDir, 'threads', 'T-broken.json'),
|
||||
join(tmpDir, 'threads', 'T-good.json'),
|
||||
join(tmpDir, 'threads', 'T-noid.json'),
|
||||
])
|
||||
for (const source of sources) {
|
||||
expect(source.provider).toBe('amp')
|
||||
expect(source.project).toBe('Amp')
|
||||
expect(await parseAll(source)).toHaveLength(source.path.endsWith('T-good.json') ? 2 : 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('discovers and parses nested threads', async () => {
|
||||
const dir = join(tmpDir, 'threads', 'sub', 'nested')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const path = join(dir, 'T-2.json')
|
||||
await writeFile(path, JSON.stringify({ ...LEDGER_THREAD, id: 'T-2' }), 'utf-8')
|
||||
await writeFile(join(dir, 'notes.txt'), 'not a thread', 'utf-8')
|
||||
|
||||
const sources = await createAmpProvider([tmpDir]).discoverSessions()
|
||||
expect(sources).toEqual([{ path, project: 'Amp', provider: 'amp' }])
|
||||
const calls = await parseAll(sources[0])
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0].sessionId).toBe('T-2')
|
||||
})
|
||||
|
||||
it('parses ledger events with cache tokens joined from the billed message', async () => {
|
||||
const path = await writeThread('T-abc123', LEDGER_THREAD)
|
||||
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
|
||||
expect(calls[0].model).toBe('claude-opus-4-6')
|
||||
expect(calls[0].inputTokens).toBe(12368)
|
||||
expect(calls[0].outputTokens).toBe(178)
|
||||
expect(calls[0].cacheCreationInputTokens).toBe(986)
|
||||
expect(calls[0].cacheReadInputTokens).toBe(11372)
|
||||
expect(calls[0].cachedInputTokens).toBe(11372)
|
||||
expect(calls[0].reasoningTokens).toBe(0)
|
||||
expect(calls[0].timestamp).toBe('2026-09-20T11:42:12.000Z')
|
||||
expect(calls[0].sessionId).toBe('T-abc123')
|
||||
expect(calls[0].deduplicationKey).toBe('amp:T-abc123:evt-1')
|
||||
expect(calls[0].costIsEstimated).toBe(true)
|
||||
expect(calls[0].costUSD).toBe(calculateCost('claude-opus-4-6', 12368, 178, 986, 11372, 0))
|
||||
expect(calls[0].costUSD).toBeGreaterThan(0)
|
||||
// The ledger event carries no text; attribution falls back to the
|
||||
// thread's first user message.
|
||||
expect(calls[0].userMessage).toBe('Fix the flaky test in parser.ts')
|
||||
|
||||
expect(calls[1].deduplicationKey).toBe('amp:T-abc123:evt-2')
|
||||
expect(calls[1].cacheReadInputTokens).toBe(12000)
|
||||
})
|
||||
|
||||
it('bills a total-only token record as output', async () => {
|
||||
await writeThread('T-total', {
|
||||
id: 'T-total',
|
||||
messages: [],
|
||||
usageLedger: {
|
||||
events: [
|
||||
{ id: 'evt-t', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: { total: 345 } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const path = join(tmpDir, 'threads', 'T-total.json')
|
||||
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].inputTokens).toBe(0)
|
||||
expect(calls[0].outputTokens).toBe(345)
|
||||
expect(calls[0].costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ input: 10, output: 20, creation: 30, read: 40, total: 150, expectedOutput: 70 },
|
||||
{ input: 10, output: 20, creation: 30, read: 40, total: 100, expectedOutput: 20 },
|
||||
{ input: 10, output: 20, creation: 30, read: 40, total: 50, expectedOutput: 20 },
|
||||
{ input: 10, output: 20, creation: 0, read: 0, total: 50, expectedOutput: 40 },
|
||||
{ input: 0, output: 0, creation: 30, read: 40, total: 100, expectedOutput: 30 },
|
||||
])('accounts for ledger total remainders: %j', async ({ input, output, creation, read, total, expectedOutput }) => {
|
||||
const path = await writeThread('T-remainder', {
|
||||
id: 'T-remainder',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
messageId: 1,
|
||||
usage: { cacheCreationInputTokens: creation, cacheReadInputTokens: read },
|
||||
}],
|
||||
usageLedger: {
|
||||
events: [{
|
||||
id: 'evt-remainder',
|
||||
timestamp: '2026-09-20T12:00:00.000Z',
|
||||
model: 'claude-opus-4-6',
|
||||
tokens: { input, output, total },
|
||||
toMessageId: 1,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].inputTokens).toBe(input)
|
||||
expect(calls[0].outputTokens).toBe(expectedOutput)
|
||||
expect(calls[0].cacheCreationInputTokens).toBe(creation)
|
||||
expect(calls[0].cacheReadInputTokens).toBe(read)
|
||||
expect(calls[0].costUSD).toBe(calculateCost('claude-opus-4-6', input, expectedOutput, creation, read, 0))
|
||||
})
|
||||
|
||||
it('falls back to per-message usage when the thread has no ledger', async () => {
|
||||
await writeThread('T-msgs', {
|
||||
id: 'T-msgs',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi' },
|
||||
{
|
||||
role: 'assistant',
|
||||
messageId: 3,
|
||||
usage: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
inputTokens: 10,
|
||||
outputTokens: 178,
|
||||
cacheCreationInputTokens: 986,
|
||||
cacheReadInputTokens: 11372,
|
||||
timestamp: '2026-09-19T11:42:10.652Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
// Model and timestamp may live on the message when the usage block
|
||||
// omits them.
|
||||
model: 'gpt-5.6-codex',
|
||||
timestamp: '2026-09-19T11:43:00.000Z',
|
||||
usage: { inputTokens: 5, outputTokens: 42 },
|
||||
},
|
||||
],
|
||||
})
|
||||
const path = join(tmpDir, 'threads', 'T-msgs.json')
|
||||
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0].model).toBe('claude-haiku-4-5-20251001')
|
||||
expect(calls[0].inputTokens).toBe(10)
|
||||
expect(calls[0].outputTokens).toBe(178)
|
||||
expect(calls[0].cacheCreationInputTokens).toBe(986)
|
||||
expect(calls[0].cacheReadInputTokens).toBe(11372)
|
||||
expect(calls[0].timestamp).toBe('2026-09-19T11:42:10.652Z')
|
||||
expect(calls[0].userMessage).toBe('hi')
|
||||
expect(calls[1].model).toBe('gpt-5.6-codex')
|
||||
expect(calls[1].timestamp).toBe('2026-09-19T11:43:00.000Z')
|
||||
})
|
||||
|
||||
it('skips records with no usable tokens', async () => {
|
||||
await writeThread('T-empty', {
|
||||
id: 'T-empty',
|
||||
messages: [],
|
||||
usageLedger: {
|
||||
events: [
|
||||
{ id: 'evt-e', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: {} },
|
||||
{ timestamp: '2026-09-20T12:00:01.000Z', model: 'gpt-5.6-codex', tokens: { input: 1 } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const path = join(tmpDir, 'threads', 'T-empty.json')
|
||||
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].inputTokens).toBe(1)
|
||||
})
|
||||
|
||||
it('deduplicates through seenKeys', async () => {
|
||||
const path = await writeThread('T-abc123', LEDGER_THREAD)
|
||||
const source = { path, project: 'Amp', provider: 'amp' }
|
||||
|
||||
const seenKeys = new Set<string>()
|
||||
const first = await parseAll(source, seenKeys)
|
||||
expect(first).toHaveLength(2)
|
||||
|
||||
const second = await parseAll(source, seenKeys)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clamps malformed token fields instead of leaking NaN', async () => {
|
||||
await writeThread('T-bad', {
|
||||
id: 'T-bad',
|
||||
messages: [],
|
||||
usageLedger: {
|
||||
events: [
|
||||
{ id: 'evt-n', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: { input: -5, output: 'many' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const path = join(tmpDir, 'threads', 'T-bad.json')
|
||||
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
|
||||
|
||||
// Every field malformed or non-positive -> nothing usable, no call.
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is registered in the provider registry', async () => {
|
||||
const { getAllProviders } = await import('../../src/providers/index.js')
|
||||
const providers = await getAllProviders()
|
||||
expect(providers.find(p => p.name === 'amp')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -22,6 +22,7 @@ export const REDIRECTED = [
|
|||
|
||||
export const CLEARED = [
|
||||
// Provider session-discovery dirs
|
||||
'AMP_DATA_DIR',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'CLAUDE_CONFIG_DIRS',
|
||||
'CLINE_DIR',
|
||||
|
|
|
|||
|
|
@ -333,6 +333,9 @@ fn roots() -> Vec<Root> {
|
|||
roots.push(leaf(
|
||||
env_path("CRUSH_GLOBAL_DATA").unwrap_or_else(|| xdg_data().join("crush")),
|
||||
));
|
||||
roots.push(leaf(
|
||||
env_path("AMP_DATA_DIR").unwrap_or_else(|| xdg_data().join("amp")),
|
||||
));
|
||||
roots.push(leaf(
|
||||
env_path("ZS_DATA_DIR").unwrap_or_else(|| xdg_data().join("zerostack")),
|
||||
));
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||
pi: '#B26B3D',
|
||||
qwen: '#615EEB',
|
||||
omp: '#8B5CB0',
|
||||
amp: '#3A3A3C',
|
||||
crush: '#E06C9F',
|
||||
antigravity: '#FF7A45',
|
||||
goose: '#B78D52',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue