cost

package
v0.0.40 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 5, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

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

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

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

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

func FindSessionJSONL(home string, since time.Time) (string, error)

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

func NormalizeModel(model string) string

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

func RollupPath(projectRoot string) string

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

func SaveRollup(projectRoot string, r *Rollup) error

SaveRollup atomically writes r to RollupPath(projectRoot).

func TailIntervalFromEnv

func TailIntervalFromEnv() time.Duration

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 CacheCreation struct {
	Ephemeral5m int `json:"ephemeral_5m_input_tokens"`
	Ephemeral1h int `json:"ephemeral_1h_input_tokens"`
}

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

func LoadRollup(projectRoot string) (*Rollup, error)

LoadRollup reads RollupPath(projectRoot). Returns an empty rollup if the file doesn't exist yet.

func RebuildRollup

func RebuildRollup(projectRoot string) (*Rollup, error)

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

func (r *Rollup) FoldChat(chatID string, day time.Time, totals Totals, perModel map[string]Totals)

FoldChat mutates r to include one chat session's totals.

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

func (r *Rollup) SortedDays() []string

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

type ScanResult struct {
	Totals    Totals
	ByModel   map[string]Totals
	LastModel string
}

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

func NewTailer(path string, interval time.Duration) *Tailer

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

func (t *Tailer) LastModel() string

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

func (t *Tailer) Start(ctx context.Context)

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

func (*Tailer) Stop

func (t *Tailer) Stop()

Stop cancels the tail goroutine, waits for it to drain and release the file handle, then returns. Idempotent. Safe to call without a prior Start (no-op in that case).

func (*Tailer) Totals

func (t *Tailer) Totals() Totals

Totals returns a snapshot of the running totals. Safe to call any time.

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

func ScanLatestSession(home string, since time.Time) (totals Totals, model, path string, err error)

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

func ScanSessionJSONL(path string) (Totals, string, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL