Documentation
¶
Overview ¶
Package cost wires Claude session JSONL → per-step USD totals → project rollup file. PLAN-5 / C7.
Data path varies by mode:
--eval mode `result` event in stream-json stdout (existing
PLAN-3 path, unchanged; renamed from --print).
web / --tui Per-assistant-message `usage` blocks in
~/.claude/projects/<hash>/<sid>.jsonl. This package
tails the symlink that runlog drops under
<run-dir>/transcripts/.
`ape costs` exposes today / this week / total rollups; the per-run detail comes from the existing PLAN-3 manifest.yaml.
Index ¶
- Constants
- Variables
- func FindSessionJSONL(home string, since time.Time) (string, error)
- func LoadOverridesFrom(path string) (map[string]ModelPrice, error)
- func NormalizeModel(model string) string
- func RollupPath(projectRoot string) string
- func SaveOverrides(prices map[string]ModelPrice) error
- func SaveRollup(projectRoot string, r *Rollup) error
- func TailIntervalFromEnv() time.Duration
- func TurnCost(u UsageBlock, p ModelPrice) float64
- type AssistantLine
- type Bucket
- type CacheCreation
- type ChatSession
- type ModelPrice
- type Rollup
- func (r *Rollup) FoldChat(chatID string, day time.Time, totals Totals, perModel map[string]Totals)
- func (r *Rollup) FoldPipelineRun(pipelineName, runID string, day time.Time, totals Totals, ...)
- func (r *Rollup) FoldTaskRun(skill, runID string, day time.Time, totals Totals, perModel map[string]Totals)
- func (r *Rollup) SortedDays() []string
- type RunManifest
- type ScanResult
- type Tailer
- type Totals
- type UsageBlock
Constants ¶
const ( CacheCreationEphemeral5mMul = 1.25 CacheCreationEphemeral1hMul = 2.00 CacheReadMul = 0.10 )
Cache-multiplier constants from design doc §11. Cache-creation pricing is a fixed multiple of base input; cache-read is also a multiple of base input (the "read while cached" path is cheaper than fresh input).
const DefaultTailInterval = 200 * time.Millisecond
DefaultTailInterval is the polling cadence between EOF probes. 200ms matches PLAN-5 / C7. Override via APE_COST_TAIL_INTERVAL_MS (undocumented debug knob) before calling NewTailer.
Variables ¶
var Prices = map[string]ModelPrice{
"claude-fable-5": {BaseInput: 10.00, Output: 50.00},
"claude-mythos-5": {BaseInput: 10.00, Output: 50.00},
"claude-sonnet-5": {BaseInput: 3.00, Output: 15.00},
"claude-opus-4-8": {BaseInput: 5.00, Output: 25.00},
"claude-opus-4-7": {BaseInput: 5.00, Output: 25.00},
"claude-opus-4-6": {BaseInput: 5.00, Output: 25.00},
"claude-opus-4-5": {BaseInput: 5.00, Output: 25.00},
"claude-opus-4-1": {BaseInput: 15.00, Output: 75.00},
"claude-opus-4": {BaseInput: 15.00, Output: 75.00},
"claude-sonnet-4-6": {BaseInput: 3.00, Output: 15.00},
"claude-sonnet-4-5": {BaseInput: 3.00, Output: 15.00},
"claude-sonnet-4": {BaseInput: 3.00, Output: 15.00},
"claude-haiku-4-5": {BaseInput: 1.00, Output: 5.00},
"claude-haiku-3-5": {BaseInput: 0.80, Output: 4.00},
}
Prices is the hand-curated table keyed by the `model` field on each assistant-line `usage` block in the session JSONL. Unknown models cost $0 with the manifest carrying a `cost_note` (see Tracker for that wiring). PLAN-5 / C7.
Source: https://platform.claude.com/docs/en/about-claude/pricing fetched 2026-07-02. Per-million-tokens, USD. The 1.25× / 2.00× / 0.10× cache multipliers live in formula.go and apply on top of the BaseInput rate here.
NOTE: Opus 4.5+ uses **half** the input rate and **one-third** the output rate of Opus 4 / 4.1. A future model bump must update this table — there is no API to fetch live prices. `ape costs update --from <yaml>` persists overrides to ~/.ape/prices.yaml.
Functions ¶
func FindSessionJSONL ¶
FindSessionJSONL globs ~/.claude/projects/*/*.jsonl and returns the path of the file whose mtime is newest AND >= since. Returns empty string + nil error when nothing matches — the caller treats that as "no session file was written" (typical of `--mock` runs).
Best-effort discovery. A future PR could pass `--session <id>` to claude on spawn and look up the exact file, but Claude Code does not document a stable --session flag today, so the mtime heuristic is what we have. PLAN-5 / C7.
func LoadOverridesFrom ¶
func LoadOverridesFrom(path string) (map[string]ModelPrice, error)
LoadOverridesFrom reads a price-override YAML file and parses it into a map. Used by `ape costs update --from <file>` to validate before persisting. PLAN-5 / C7.
func NormalizeModel ¶ added in v0.0.28
NormalizeModel canonicalizes a model identifier for price lookup and per-model attribution:
- strips a `[...]` context-window suffix — the spawn-time forms `opus[1m]` / `claude-opus-4-8[1m]` bill at the base model's rate (no 1M-context surcharge on current models);
- resolves claude's short spawn aliases (`opus`, `sonnet`, …) to the model id the current CLI resolves them to. The transcript records the full resolved id, so the alias hop only matters for callers that log the alias form (e.g. a spec's `model:` field).
func RollupPath ¶
RollupPath returns <project>/_output/ape/cost-rollup.json.
func SaveOverrides ¶
func SaveOverrides(prices map[string]ModelPrice) error
SaveOverrides writes prices to ~/.ape/prices.yaml. Subsequent Lookup calls see the new values until process exit. PLAN-5 / C7.
func SaveRollup ¶
SaveRollup atomically writes r to RollupPath(projectRoot).
func TailIntervalFromEnv ¶
TailIntervalFromEnv reads APE_COST_TAIL_INTERVAL_MS and returns it as a time.Duration, falling back to DefaultTailInterval. Used by the orchestrator when starting per-step tailers.
func TurnCost ¶
func TurnCost(u UsageBlock, p ModelPrice) float64
TurnCost returns USD for one assistant turn given its usage block and the model's price record. PLAN-5 / C7 formula:
turn_cost = BaseInput × input_tokens
+ BaseInput × 1.25 × cache_creation.ephemeral_5m_input_tokens
+ BaseInput × 2.00 × cache_creation.ephemeral_1h_input_tokens
+ BaseInput × 0.10 × cache_read_input_tokens
+ Output × output_tokens
All terms divided by 1M so the per-million-token price table can be used directly. Unknown models (zero ModelPrice) yield $0.00 with no error — Tracker decides whether to stamp a `cost_note`.
Types ¶
type AssistantLine ¶
type AssistantLine struct {
Type string `json:"type"`
Message struct {
ID string `json:"id"`
Model string `json:"model"`
Usage UsageBlock `json:"usage"`
} `json:"message"`
}
AssistantLine is the minimal shape Tailer extracts from each JSONL row. Lines that don't have `type:"assistant"` are skipped.
Message.ID is the dedupe key. Claude logs the same assistant message multiple times under distinct top-level `uuid` values when a tool turn re-renders or the conversation tree branches (each duplicate shares the same `message.id` but has a different `uuid`). Cost / token totals must count each ID once or the result is 2–4× over-counted; ScanSessionJSONL and Tailer.consumeLines both filter by ID.
type Bucket ¶
type Bucket struct {
Totals Totals `json:"totals"`
Runs map[string]Totals `json:"runs,omitempty"`
PerModel map[string]Totals `json:"per_model,omitempty"`
}
Bucket totals one pipeline name (or all chats) over the lifetime of the project. Runs is the per-run-id breakdown; PerModel is the per-model breakdown summed across the bucket's runs (PLAN-10 D5).
type CacheCreation ¶
type ChatSession ¶ added in v0.0.36
type ChatSession struct {
ChatID string `json:"chat_id"`
Totals Totals `json:"totals"`
Started time.Time `json:"started_at"`
}
ChatSession is one chat session's cost summary, read by `ape costs chat <id>`. PLAN-10 D5.
func FindChatSession ¶ added in v0.0.36
func FindChatSession(projectRoot, chatID string) (ChatSession, bool)
FindChatSession reads _output/ape/chats/<chatID>/session.yaml. ok=false when the session is absent. PLAN-10 D5.
type ModelPrice ¶
type ModelPrice struct {
// BaseInput is the input price per 1M tokens, USD.
BaseInput float64
// Output is the output price per 1M tokens, USD.
Output float64
}
ModelPrice is the per-million-tokens USD cost for one model. The formula in formula.go consumes these values directly.
IMPORTANT: these defaults are starting points and need confirmation before the cost-tracking PR merges. See PLAN-5 "When to stop and ask" — "Cost-table values." The plan deliberately deferred them from the plan body to the implementation PR. Surface this table to the user for review before shipping.
func Lookup ¶
func Lookup(model string) (ModelPrice, bool)
Lookup returns the price for model, plus a flag indicating whether the model was known. The model id is normalized first (context- window suffix stripped, spawn aliases resolved) so `opus[1m]` and `claude-opus-4-8` resolve to the same entry. Unknown models return zero price (caller may stamp a note on the affected step's manifest record).
Lookup consults ~/.ape/prices.yaml first (PLAN-5 / C7 — `ape costs update --from <file>` persists overrides there); the built-in Prices map is the fallback. Overrides are cached after the first Lookup of a process; SaveOverrides drops the cache.
type Rollup ¶
type Rollup struct {
UpdatedAt time.Time `json:"updated_at"`
Pipelines map[string]Bucket `json:"pipelines,omitempty"`
// Tasks aggregates `ape task` runs, keyed by skill name
// (manifests under _output/tasks/<skill>/<run-id>/). PLAN-11.
Tasks map[string]Bucket `json:"tasks,omitempty"`
Chats Bucket `json:"chats"`
ByDay map[string]Totals `json:"by_day,omitempty"` // YYYY-MM-DD → totals
// PerModel is the project-wide per-model breakdown, keyed by
// normalized model id (see NormalizeModel). PLAN-10 D5.
PerModel map[string]Totals `json:"per_model,omitempty"`
}
Rollup is the on-disk shape of <project>/_output/ape/cost-rollup.json. Aggregates pipeline runs + chat sessions per name / per date bucket. PLAN-5 / C7.
func LoadRollup ¶
LoadRollup reads RollupPath(projectRoot). Returns an empty rollup if the file doesn't exist yet.
func RebuildRollup ¶
RebuildRollup walks <project>/_output/pipelines/<name>/<run-id>/manifest.yaml, <project>/_output/tasks/<skill>/<run-id>/manifest.yaml (PLAN-11), and <project>/_output/ape/chats/<chat-id>/session.yaml, folds every row into a fresh Rollup, and saves it. Used by `ape costs roll`. PLAN-5 / C7.
Best-effort: parse errors on individual artefacts are skipped (so a half-written manifest doesn't abort the walk). The rollup is rebuilt from scratch, not merged with the existing file — that's what makes it a "roll" (resync the cache from the durable record).
func (*Rollup) FoldPipelineRun ¶
func (r *Rollup) FoldPipelineRun(pipelineName, runID string, day time.Time, totals Totals, perModel map[string]Totals)
FoldPipelineRun mutates r to include one pipeline run's totals plus its per-model breakdown (PLAN-10 D5; perModel may be nil).
func (*Rollup) FoldTaskRun ¶ added in v0.0.27
func (r *Rollup) FoldTaskRun(skill, runID string, day time.Time, totals Totals, perModel map[string]Totals)
FoldTaskRun mutates r to include one `ape task` run's totals, keyed by skill name. PLAN-11 / PLAN-10 D5.
func (*Rollup) SortedDays ¶
SortedDays returns the ByDay keys in ascending order. Useful for the `ape costs` human renderer.
type RunManifest ¶ added in v0.0.36
type RunManifest struct {
Name string `json:"name"` // pipeline name or task skill
RunID string `json:"run_id"`
Kind string `json:"kind"` // "pipeline" or "task"
Totals Totals `json:"totals"`
PerModel map[string]Totals `json:"per_model,omitempty"`
}
RunManifest is one run's cost summary, read by `ape costs run <id>`. PLAN-10 D5.
func FindRunManifest ¶ added in v0.0.36
func FindRunManifest(projectRoot, runID string) (RunManifest, bool)
FindRunManifest locates a run by run-id under _output/pipelines/ and _output/tasks/ and returns its cost summary. ok=false when no manifest with that run-id exists. PLAN-10 D5 (restores `ape costs run`).
type ScanResult ¶ added in v0.0.28
ScanResult is the full outcome of scanning one session transcript: the aggregate totals, the per-model breakdown (keyed by normalized model id — see NormalizeModel), and the last model seen.
func ScanSession ¶ added in v0.0.28
func ScanSession(path string) (ScanResult, error)
ScanSession reads a Claude Code session JSONL file once and returns the aggregated cost / token totals plus a per-model breakdown.
Unlike Tailer (which polls a live file), this is a one-shot reader for files that are already complete. Malformed lines are skipped. Tokens and NumTurns accumulate price-independently — an unpriced model still yields non-zero tokens/turns with CostUSD 0.
type Tailer ¶
type Tailer struct {
// contains filtered or unexported fields
}
Tailer is a polling-based reader of one session JSONL. Stop closes the reader and the goroutine. Concurrent Read / Stop is safe.
func NewTailer ¶
NewTailer opens path lazily — the file does not need to exist at construction time. Use TailIntervalFromEnv() to honour APE_COST_TAIL_INTERVAL_MS for debugging.
func (*Tailer) LastModel ¶
LastModel returns the most recent model name seen on an assistant line. Empty if no lines yet. Useful for stamping the chat session.yaml.
func (*Tailer) Lines ¶
func (t *Tailer) Lines() <-chan AssistantLine
Lines returns the channel of parsed assistant lines. Closes when the tailer exits.
func (*Tailer) Start ¶
Start kicks off the tail goroutine. Run until ctx cancels or Stop is called. The first time path appears on disk, the reader opens it at byte 0 (PLAN-5 / C7 — do NOT seek to end; the file may already have content by the time we start).
type Totals ¶
type Totals struct {
CostUSD float64 `json:"cost_usd"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens"`
// CacheCreation5mTokens / CacheCreation1hTokens are the ephemeral
// cache-write split (PLAN-10 D1). CacheCreationTokens stays the sum
// of the two, so existing consumers are unaffected; the granular
// fields are additive. The 5m/1h tiers price differently (1.25× vs
// 2.00× base input — see the multiplier constants).
CacheCreation5mTokens int `json:"cache_creation_5m_tokens"`
CacheCreation1hTokens int `json:"cache_creation_1h_tokens"`
NumTurns int `json:"num_turns"`
}
Totals aggregates multiple TurnCosts plus their token counts so the caller can populate PLAN-3's v2 manifest fields (cost_usd, tokens_*). NumTurns is incremented once per Add call so callers can fill the num_turns manifest field from transcript scans (the stream-json terminal `result` event has the same count for one-shot `claude -p` spawns; transcript scans of an interactive session need the explicit counter).
func ScanLatestSession ¶
ScanLatestSession is a convenience that runs FindSessionJSONL + ScanSessionJSONL in sequence. Used by `ape chat` exit. Returns (Totals{}, "", "", nil) when no file matches (no error — just nothing to fold in).
func ScanSessionJSONL ¶
ScanSessionJSONL is the aggregate-only wrapper around ScanSession, kept for callers that don't need the per-model breakdown. Used by `ape chat` (post-run) to populate session.yaml. PLAN-5 / C7.
func (*Totals) Add ¶
func (t *Totals) Add(u UsageBlock, p ModelPrice)
Add folds one turn's usage + cost into the running totals.
type UsageBlock ¶
type UsageBlock struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
CacheCreation CacheCreation `json:"cache_creation"`
}
UsageBlock mirrors the `usage` field on an assistant message line in the Claude session JSONL. Fields that may be absent in older session captures default to zero — that is the same shape PLAN-3 already records via the stream-json `result` path.