Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ Cross-package release notes for relayburn. Package changelogs contain package-le
### Breaking

- `burn ingest` syncs the relayhistory store (`$AI_HIST_DB`, else `~/.local/share/ai-hist/ai-history.db`, created on first use; the `ai-hist` CLI is not required) and appends the sessions its change feed reports since the ledger's watermark. The first run on a 4.x ledger rebuilds every session once (minutes on a large history); matching turn identities keep it from duplicating any. Later runs with nothing new finish in well under a second.
- `BURN_CLAUDE_PROJECTS_DIR` is removed; harness locations follow relayhistory (`HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DB`).
- `BURN_CLAUDE_PROJECTS_DIR` is removed; harness locations follow relayhistory (`HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DB`, `COPILOT_HOME`).
- `relayburn-sdk`: `IngestRoots` / `IngestOptions.roots` are replaced by `IngestOptions.store: HistoryStoreOptions`; `start_watch_loop` and its option/sink types by `watch_ingest` + `WatchIngestOptions`; `RawIngestOptions` is `IngestOptions`; `ingest_claude_session`, `ingest_codex_sessions`, `ingest_opencode_sessions`, `default_session_roots`, `count_subagents_under`, `discover_subagents` and `pair_subagents_to_main` are removed. `SubagentTranscript` drops `records`.
- `@relayburn/sdk` `ingest()` takes `{ ledgerHome, storeDbPath, home }`; the ignored `sessionId` / `harness` fields are removed.
- `burn state rebuild` clears the ingest watermark with the derived rows, so the next ingest rebuilds every session from the store.

### Added

- GitHub Copilot CLI sessions (`~/.copilot/session-state/`, or `$COPILOT_HOME`) are ingested and billed, retroactively and with no setup.
- Claude Code subagent spend is billed: every turn of a subagent transcript, nested subagents included, is a turn of the session that spawned it. Ledger totals for sessions that delegated work go up by exactly that spend.
- OpenCode sessions in the SQLite store (`opencode.db`, current OpenCode releases) are ingested, as well as the `storage/` JSON tree.
- A Codex subagent thread is ingested as a session of its own.
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
![relayburn](./burn-readme-banner.png)

Understand how you're spending tokens in agent CLIs. Burn reads Claude Code,
Codex, and OpenCode sessions through
Codex, OpenCode, and GitHub Copilot CLI sessions through
[relayhistory](https://github.com/AgentWorkforce/relayhistory) into a local
ledger, then shows cost by model, provider, tool, file, workflow, agent,
session, and overhead file. Subagent spend is billed to the session that
Expand Down Expand Up @@ -341,9 +341,21 @@ Run `burn summary --by-provider` to discover model IDs present in your ledger.

## `burn ingest`

`burn ingest` syncs the relayhistory store (Claude Code, Codex, and OpenCode
sessions) and appends every session it changed since the ledger's last ingest.
Reporting commands that refresh the ledger run the same step.
`burn ingest` syncs the relayhistory store (Claude Code, Codex, OpenCode, and
GitHub Copilot CLI sessions) and appends every session it changed since the
ledger's last ingest. Reporting commands that refresh the ledger run the same
step.

GitHub Copilot CLI needs no setup: its session journals under
`~/.copilot/session-state/` (or `$COPILOT_HOME`) are read retroactively. Copilot
records output tokens per model call but input and cache tokens only as
per-model running totals when a session shuts down, so each `copilot-cli` turn
carries its output tokens and each shutdown adds one `per-session-aggregate`
turn per model with the input, cache, and remaining output it accounts for. A
model call that names no model bills nothing itself ($0); the shutdown's
per-model aggregates carry its output, so every token is billed once under a
known model. A session that has not shut down yet shows the output tokens of
the model calls that name their model.

| Option | What it does |
|---|---|
Expand Down Expand Up @@ -665,7 +677,7 @@ session.

### What does `burn ingest` do?

Each harness (Claude Code, Codex, OpenCode) writes its own session transcripts
Each harness (Claude Code, Codex, OpenCode, GitHub Copilot CLI) writes its own session transcripts
to disk in its own format. relayhistory reads those into one session store;
`burn ingest` syncs that store, derives burn's turns, costs, and activity from
what changed, and writes them into burn's local SQLite ledger so the query
Expand Down
56 changes: 51 additions & 5 deletions crates/relayburn-sdk/src/analyze/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::borrow::Cow;

use serde::{Deserialize, Serialize};

use crate::reader::{SourceKind, TurnRecord, Usage};
use crate::reader::{SourceKind, TurnRecord, Usage, UsageGranularity};

use crate::analyze::pricing::{ModelCost, ModelCostTier, PricingTable, ReasoningMode};
use crate::analyze::provider_reattribution::{resolve_provider, strip_provider_prefix};
Expand Down Expand Up @@ -39,6 +39,10 @@ pub struct CostBreakdown {
#[derive(Debug, Clone, Copy, Default)]
pub struct CostForUsageOptions {
pub reasoning_mode: Option<ReasoningMode>,
/// The usage sums requests whose sizes are unknown (a per-session
/// aggregate), so no request is known to cross a context tier: the
/// model's base rates apply.
pub base_tier: bool,
}

/// Tokens-per-million divisor shared by every per-token cost calc in the
Expand Down Expand Up @@ -70,7 +74,12 @@ pub(crate) fn cost_for_usage(
options: CostForUsageOptions,
) -> Option<CostBreakdown> {
let rate = lookup_model_rate(model, pricing)?;
let effective = effective_model_rate(usage, rate);
let tier = if options.base_tier {
None
} else {
context_tier_for_usage(usage, rate)
};
let effective = rate_at_tier(rate, tier);
let mode = options.reasoning_mode.unwrap_or(rate.reasoning_mode);
let input = (usage.input as f64 / PER_MILLION) * effective.input;
let output = (usage.output as f64 / PER_MILLION) * effective.output;
Expand Down Expand Up @@ -105,6 +114,10 @@ pub fn cost_for_turn(turn: &TurnRecord, pricing: &PricingTable) -> Option<CostBr
}
let opts = CostForUsageOptions {
reasoning_mode: reasoning_mode_for_source(turn.source),
base_tier: turn
.fidelity
.as_ref()
.is_some_and(|f| f.granularity == UsageGranularity::PerSessionAggregate),
};
cost_for_usage(&turn.usage, &turn.model, pricing, opts)
}
Expand Down Expand Up @@ -151,7 +164,10 @@ fn context_tier_for_usage<'a>(usage: &Usage, rate: &'a ModelCost) -> Option<&'a
}

pub(crate) fn effective_model_rate(usage: &Usage, rate: &ModelCost) -> EffectiveModelRate {
let tier = context_tier_for_usage(usage, rate);
rate_at_tier(rate, context_tier_for_usage(usage, rate))
}

fn rate_at_tier(rate: &ModelCost, tier: Option<&ModelCostTier>) -> EffectiveModelRate {
EffectiveModelRate {
input: tier.map_or(rate.input, |tier| tier.input),
output: tier.map_or(rate.output, |tier| tier.output),
Expand Down Expand Up @@ -184,15 +200,22 @@ fn reasoning_cost(
/// Per-source reasoning-billing semantics override. Returning `None` means
/// "defer to the model's `reasoning_mode`".
///
/// - Codex: `output_tokens` already includes reasoning; never bill it on top.
/// - Codex and Copilot CLI: `output_tokens` / `outputTokens` already include
/// reasoning; never bill it on top.
/// - Everyone else: defer to the model.
pub(crate) fn reasoning_mode_for_source(source: SourceKind) -> Option<ReasoningMode> {
match source {
SourceKind::Codex => Some(ReasoningMode::IncludedInOutput),
SourceKind::Codex | SourceKind::CopilotCli => Some(ReasoningMode::IncludedInOutput),
Comment thread
willwashburn marked this conversation as resolved.
_ => None,
}
}

/// Whether `source`'s reasoning tokens are already counted inside its
/// output tokens.
pub(crate) fn reasoning_in_output(source: SourceKind) -> bool {
reasoning_mode_for_source(source) == Some(ReasoningMode::IncludedInOutput)
}

/// Shared lookup: direct match → synthetic reattribution → generic
/// `provider/model` strip. Used by `cost_for_usage` and (later) by the
/// hotspots / claude-md attribution paths so synthetic-routed turns price
Expand Down Expand Up @@ -651,6 +674,7 @@ mod tests {
&p,
CostForUsageOptions {
reasoning_mode: Some(ReasoningMode::IncludedInOutput),
..Default::default()
},
)
.expect("skipped priced");
Expand Down Expand Up @@ -892,6 +916,28 @@ mod tests {
t
}

/// A per-session aggregate sums requests whose sizes are unknown: three
/// 100k-token gpt-5.4 calls total 300k, past the 272k tier, yet none of
/// them crossed it, so the aggregate prices at the base rates.
#[test]
fn a_session_aggregate_prices_at_the_base_tier() {
let p = load_builtin_pricing();
let usage = Usage {
input: 300_000,
..Usage::default()
};
let mut aggregate = turn("gpt-5.4", usage.clone(), SourceKind::CopilotCli);
aggregate.fidelity = Some(Fidelity::new(
UsageGranularity::PerSessionAggregate,
Coverage::EMPTY,
));
let per_turn = with_coverage(turn("gpt-5.4", usage, SourceKind::CopilotCli), true, true);
let base = cost_for_turn(&aggregate, &p).expect("priced").input;
let tiered = cost_for_turn(&per_turn, &p).expect("priced").input;
assert!((base - 0.75).abs() < 1e-9, "{base}");
assert!((tiered - 1.50).abs() < 1e-9, "{tiered}");
}

#[test]
fn measured_zero_token_turns_cost_nothing_even_without_a_tariff() {
let p = load_builtin_pricing();
Expand Down
85 changes: 46 additions & 39 deletions crates/relayburn-sdk/src/analyze/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ fn unpriced_usage_by_session<'a>(
}
let usage = by_session.entry(&turn.session_id).or_default();
usage.turns += 1;
let separately_reported_reasoning = if matches!(turn.source, SourceKind::Codex) {
let separately_reported_reasoning = if super::cost::reasoning_in_output(turn.source) {
0
} else {
turn.usage.reasoning
Expand Down Expand Up @@ -1211,44 +1211,51 @@ mod tests {
}

#[test]
fn unpriced_codex_volume_does_not_double_count_reasoning_inside_output() {
let turn = TurnRecord {
v: 1,
source: SourceKind::Codex,
session_id: "codex-unknown".to_string(),
session_path: None,
message_id: "m1".to_string(),
turn_index: 0,
request_count: None,
ts: "2026-08-01T00:00:00.000Z".to_string(),
model: "future-unpriced-model".to_string(),
project: None,
project_key: None,
usage: crate::reader::Usage {
input: 400_000,
output: 20_000,
reasoning: 10_000,
cache_read: 13_000,
cache_create_5m: 0,
cache_create_1h: 0,
},
tool_calls: Vec::new(),
files_touched: None,
subagent: None,
stop_reason: None,
activity: None,
retries: None,
has_edits: None,
fidelity: None,
reasoning: None,
};

let findings = unpriced_usage_findings(&[turn], &PricingTable::new());

assert_eq!(
findings[0].estimated_savings.tokens_per_session,
Some(433_000)
);
fn unpriced_volume_does_not_double_count_reasoning_inside_output() {
for source in [SourceKind::Codex, SourceKind::CopilotCli] {
let turn = TurnRecord {
v: 1,
source,
session_id: "codex-unknown".to_string(),
session_path: None,
message_id: "m1".to_string(),
turn_index: 0,
request_count: None,
ts: "2026-08-01T00:00:00.000Z".to_string(),
model: "future-unpriced-model".to_string(),
project: None,
project_key: None,
usage: crate::reader::Usage {
input: 400_000,
output: 20_000,
reasoning: 10_000,
cache_read: 13_000,
cache_create_5m: 0,
cache_create_1h: 0,
},
tool_calls: Vec::new(),
files_touched: None,
subagent: None,
stop_reason: None,
activity: None,
retries: None,
has_edits: None,
fidelity: None,
reasoning: None,
};

let findings = unpriced_usage_findings(&[turn], &PricingTable::new());

assert_eq!(
findings[0].estimated_savings.tokens_per_session,
Some(433_000),
"{source:?}"
);
assert_eq!(
findings[0].title, "Unpriced usage: 433,000 tokens across 1 turn",
"{source:?}"
);
}
}

#[test]
Expand Down
8 changes: 4 additions & 4 deletions crates/relayburn-sdk/src/analyze/hotspots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use std::collections::HashMap;

use crate::reader::{
BashParse, ContentKind, ContentRecord, ToolResultEventRecord, TurnRecord, UserTurnBlockKind,
UserTurnRecord,
normalize_tool_name, BashParse, ContentKind, ContentRecord, ToolResultEventRecord, TurnRecord,
UserTurnBlockKind, UserTurnRecord,
};
use indexmap::IndexMap;
use phf::phf_set;
Expand Down Expand Up @@ -620,13 +620,13 @@ fn accumulate_output_bytes(
}

/// Roll up file-touching tool attributions (`Read | Edit | Write |
/// NotebookEdit`) by their target path. Rows missing or with an empty target
/// NotebookEdit`, under any harness's name for them) by their target path. Rows missing or with an empty target
/// are skipped. Output is sorted by `total_cost` descending.
pub(crate) fn aggregate_by_file(attributions: &[ToolAttribution]) -> Vec<FileAggregation> {
aggregate(
attributions,
|a| {
if !FILE_TOOLS.contains(a.tool_name.as_str()) {
if !FILE_TOOLS.contains(normalize_tool_name(&a.tool_name)) {
return None;
}
match a.target.as_ref() {
Expand Down
28 changes: 28 additions & 0 deletions crates/relayburn-sdk/src/analyze/hotspots_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1598,3 +1598,31 @@ fn persistence_rides_only_non_empty_results_the_cache_read_covers() {
.collect();
assert_eq!(rides, [(0, 0.0), (1, 100.0), (0, 0.0)]);
}

/// A harness's own name for a file tool (Copilot's `view`, `read`, `edit`,
/// `create`) rolls up by file like the canonical one; a non-file tool does
/// not.
#[test]
fn file_rollup_reads_every_harness_name_for_a_file_tool() {
let named = |tool: &str, path: &str| ToolAttribution {
tool_name: tool.into(),
..bash_attribution(path, tool, 1.0, 0.0, 0.0, 0)
};
let attributions = [
named("view", "a.rs"),
named("read", "a.rs"),
named("edit", "b.rs"),
named("create", "c.rs"),
named("Read", "c.rs"),
named("bash", "ls"),
];
let mut files: Vec<(String, u64)> = aggregate_by_file(&attributions)
.into_iter()
.map(|f| (f.path, f.tool_call_count))
.collect();
files.sort();
assert_eq!(
files,
[("a.rs".into(), 2), ("b.rs".into(), 1), ("c.rs".into(), 2)]
);
}
1 change: 1 addition & 0 deletions crates/relayburn-sdk/src/analyze/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ fn provider_from_source(source: SourceKind) -> String {
SourceKind::Codex | SourceKind::OpenaiApi => "openai".into(),
SourceKind::GeminiApi => "google".into(),
SourceKind::Opencode => "opencode".into(),
SourceKind::CopilotCli => "github-copilot".into(),
}
}

Expand Down
4 changes: 3 additions & 1 deletion crates/relayburn-sdk/src/ingest/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ fn resolve_stamps(
let harness = match evidence.session.source {
Source::Claude => PendingStampHarness::Claude,
Source::Codex => PendingStampHarness::Codex,
_ => PendingStampHarness::Opencode,
Source::OpenCode => PendingStampHarness::Opencode,
// No launcher stamps a session it cannot name before spawn.
_ => return 0,
};
let candidate = PendingStampSessionCandidate {
harness,
Expand Down
Loading
Loading