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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le
- A Claude assistant turn still being written (no `stop_reason` yet) is billed once it settles, not early.
- A Claude subagent turn that follows a tool result inside its sidechain keeps the subagent's agent id instead of starting a new subagent.
- Pricing bills Claude 1-hour cache writes at 2x the input tariff on every host (Anthropic, Vertex AI, Bedrock; 5-minute writes stay at the `cache_write` tariff), so `summary`, `hotspots`, `analyze`, and every cost report stop undercounting sessions that use 1-hour prompt caching. `ModelCost` / `ModelCostTier` gain `cache_write_1h`, and a ledger-home `models.dev.json` entry can set `cache_write_1h` explicitly; non-Claude models keep a single cache-write tariff.
- Pricing resolves a model id that several providers list without consulting file order: the owning lab's own tariff (named by models.dev `canonical_model_id`, e.g. Moonshot for `kimi-*`), else a first-party vendor's, else the tariff most reseller listings agree on; a free-tier ($0/$0) listing prices a model only when no listing is paid. `kimi-k3` stops billing cache reads at the input rate, and 410 of 3,796 bundled model ids move to their owner's or the consensus tariff. Copilot CLI turns price at GitHub Copilot's own tariff wherever it lists the model.
- Turns whose usage was measured as zero price at $0 regardless of model, so Claude Code's `<synthetic>` messages no longer list as unpriced in `summary` or strip the dollar estimate from every hotspots finding in their session; unmeasured usage stays unpriced. `burn compare` shows cost per turn only for fully priced cells.

### Analysis and quality
Expand Down
5 changes: 4 additions & 1 deletion crates/relayburn-sdk/src/analyze/cache_expiry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,10 @@ fn recreated_prefix(prev: &Usage, cur: &Usage) -> Option<u64> {
/// Extra USD `turn` paid to re-write `recreated` tokens versus reading them
/// from a warm cache. `None` when the model is unpriced.
fn expiry_penalty(turn: &TurnRecord, recreated: u64, pricing: &PricingTable) -> Option<f64> {
let rate = effective_model_rate(&turn.usage, lookup_model_rate(&turn.model, pricing)?);
let rate = effective_model_rate(
&turn.usage,
lookup_model_rate(&turn.model, Some(turn.source), pricing)?,
);
let created = turn.usage.cache_create_5m + turn.usage.cache_create_1h;
let recreated_write = rate.cache_create_cost(&turn.usage) * recreated as f64 / created as f64;
let warm_read = recreated as f64 / PER_MILLION * rate.cache_read;
Expand Down
2 changes: 1 addition & 1 deletion crates/relayburn-sdk/src/analyze/claude_md.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ pub(crate) fn attribute_claude_md_refs(
// or not; only priced turns add cost.
let rides = t.usage.cache_read >= total_tokens;
riding_turns += u64::from(rides);
let Some(rate) = lookup_model_rate(&t.model, pricing) else {
let Some(rate) = lookup_model_rate(&t.model, Some(t.source), pricing) else {
continue;
};
*model_counts.entry(t.model.clone()).or_insert(0) += 1;
Expand Down
4 changes: 3 additions & 1 deletion crates/relayburn-sdk/src/analyze/context_delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,9 @@ fn attributed_cost(delta_tokens: i64, model: &str, pricing: &PricingTable) -> f6
if delta_tokens <= 0 {
return 0.0;
}
let Some(rate) = crate::analyze::cost::lookup_model_rate(model, pricing) else {
// Span trees carry no harness; their Claude and Codex turns take the
// resolved tariff either way.
let Some(rate) = crate::analyze::cost::lookup_model_rate(model, None, pricing) else {
return 0.0;
};
// Charge at cache_read because cache_read is what every *future*
Expand Down
13 changes: 13 additions & 0 deletions crates/relayburn-sdk/src/analyze/context_delta_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,16 @@ fn zero_delta_with_compaction_is_not_a_compaction_row() {
);
assert!(deltas.is_empty(), "{deltas:?}");
}

#[test]
fn attributed_cost_charges_growth_at_the_cache_read_tariff() {
let pricing = crate::analyze::pricing::parse_pricing(
r#"{ "acme": { "models": {
"m": { "cost": { "input": 3, "output": 15, "cache_read": 0.25 } }
} } }"#,
)
.unwrap();
assert_eq!(attributed_cost(2_000_000, "m", &pricing), 0.5);
assert_eq!(attributed_cost(-5, "m", &pricing), 0.0);
assert_eq!(attributed_cost(2_000_000, "unpriced", &pricing), 0.0);
}
89 changes: 71 additions & 18 deletions crates/relayburn-sdk/src/analyze/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use serde::{Deserialize, Serialize};

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

use crate::analyze::pricing::{ModelCost, ModelCostTier, PricingTable, ReasoningMode};
use crate::analyze::pricing::{
billing_provider, ModelCost, ModelCostTier, PricingTable, ReasoningMode,
};
use crate::analyze::provider_reattribution::{resolve_provider, strip_provider_prefix};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -43,6 +45,9 @@ pub struct CostForUsageOptions {
/// aggregate), so no request is known to cross a context tier: the
/// model's base rates apply.
pub base_tier: bool,
/// Harness the usage was read from; selects the tariff of a provider
/// that bills that harness's sessions (see [`billing_provider`]).
pub source: Option<SourceKind>,
}

/// Tokens-per-million divisor shared by every per-token cost calc in the
Expand Down Expand Up @@ -73,7 +78,7 @@ pub(crate) fn cost_for_usage(
pricing: &PricingTable,
options: CostForUsageOptions,
) -> Option<CostBreakdown> {
let rate = lookup_model_rate(model, pricing)?;
let rate = lookup_model_rate(model, options.source, pricing)?;
let tier = if options.base_tier {
None
} else {
Expand Down Expand Up @@ -118,6 +123,7 @@ pub fn cost_for_turn(turn: &TurnRecord, pricing: &PricingTable) -> Option<CostBr
.fidelity
.as_ref()
.is_some_and(|f| f.granularity == UsageGranularity::PerSessionAggregate),
source: Some(turn.source),
};
cost_for_usage(&turn.usage, &turn.model, pricing, opts)
}
Expand Down Expand Up @@ -216,28 +222,32 @@ 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
/// consistently across views.
pub fn lookup_model_rate<'a>(model: &str, pricing: &'a PricingTable) -> Option<&'a ModelCost> {
if let Some(direct) = pricing.get(model) {
/// Shared lookup: direct match → alias → synthetic reattribution → generic
/// `provider/model` strip, each step preferring the tariff of the provider
/// that bills `source`'s sessions. Every cost view prices through here so a
/// turn costs the same in all of them.
pub fn lookup_model_rate<'a>(
model: &str,
source: Option<SourceKind>,
pricing: &'a PricingTable,
) -> Option<&'a ModelCost> {
let billed_by = source.and_then(billing_provider);
let rate = |id: &str| pricing.rate(id, billed_by);
if let Some(direct) = rate(model) {
return Some(direct);
}
if let Some(alias) = pricing_model_alias(model) {
if let Some(via) = pricing.get(alias) {
return Some(via);
}
if let Some(via) = pricing_model_alias(model).and_then(rate) {
return Some(via);
}
let reattributed = resolve_provider(model);
if reattributed.normalized_model != model {
if let Some(via) = pricing.get(&reattributed.normalized_model) {
if let Some(via) = rate(&reattributed.normalized_model) {
return Some(via);
}
}
let stripped = strip_provider_prefix(model);
if stripped != model {
if let Some(via) = pricing.get(stripped) {
if let Some(via) = rate(stripped) {
return Some(via);
}
}
Expand Down Expand Up @@ -682,6 +692,33 @@ mod tests {
assert_eq!(skipped.reasoning, 0.0);
}

#[test]
fn copilot_turns_price_at_copilots_tariff() {
// A cache-read-heavy kimi-k3 Copilot turn bills its cache reads at
// Copilot's cache-read tariff, well under its input tariff.
let p = load_builtin_pricing();
let usage = Usage {
input: 20_000,
output: 5_000,
reasoning: 0,
cache_read: 2_000_000,
cache_create_5m: 0,
cache_create_1h: 0,
};
let copilot = p.rate("kimi-k3", Some("github-copilot")).unwrap();
assert!(copilot.cache_read < copilot.input);
let expected = (20_000.0 * copilot.input
+ 5_000.0 * copilot.output
+ 2_000_000.0 * copilot.cache_read)
/ PER_MILLION;
let c = cost_for_turn(&turn("kimi-k3", usage, SourceKind::CopilotCli), &p).unwrap();
assert!(
(c.total - expected).abs() < 1e-12,
"{} vs {expected}",
c.total
);
}

#[test]
fn returns_none_for_unknown_model() {
let p = load_builtin_pricing();
Expand Down Expand Up @@ -723,11 +760,26 @@ mod tests {
context_tiers: Vec::new(),
},
);
let rate = lookup_model_rate("synthetic/qwen3-coder", &p).expect("synthetic prefix routes");
let rate =
lookup_model_rate("synthetic/qwen3-coder", None, &p).expect("synthetic prefix routes");
assert_eq!(rate.input, 1.0);
assert_eq!(rate.output, 2.0);
}

#[test]
fn lookup_model_rate_strips_a_provider_prefix_at_the_billing_providers_tariff() {
let p = crate::analyze::pricing::parse_pricing(
r#"{
"acme": { "models": { "foo": { "cost": { "input": 1, "output": 2 } } } },
"github-copilot": { "models": { "foo": { "cost": { "input": 3, "output": 4 } } } }
}"#,
)
.unwrap();
let rate = |source| lookup_model_rate("router/foo", source, &p).unwrap().input;
assert_eq!(rate(None), 1.0);
assert_eq!(rate(Some(SourceKind::CopilotCli)), 3.0);
}

#[test]
fn lookup_model_rate_maps_codex_auto_review_to_gpt_5_2_codex() {
let mut p = PricingTable::new();
Expand All @@ -744,7 +796,8 @@ mod tests {
context_tiers: Vec::new(),
},
);
let rate = lookup_model_rate("codex-auto-review", &p).expect("auto-review alias routes");
let rate =
lookup_model_rate("codex-auto-review", None, &p).expect("auto-review alias routes");
assert_eq!(rate.input, 1.75);
assert_eq!(rate.output, 14.0);
assert_eq!(rate.cache_read, 0.175);
Expand Down Expand Up @@ -779,8 +832,8 @@ mod tests {
context_tiers: Vec::new(),
},
);
let rate =
lookup_model_rate("codex-auto-review", &p).expect("direct auto-review price wins");
let rate = lookup_model_rate("codex-auto-review", None, &p)
.expect("direct auto-review price wins");
assert_eq!(rate.input, 9.0);
assert_eq!(rate.output, 10.0);
assert_eq!(rate.cache_read, 1.0);
Expand Down
79 changes: 59 additions & 20 deletions crates/relayburn-sdk/src/analyze/ghost_surface_inputs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet};
use crate::reader::{SourceKind, TurnRecord};

use crate::analyze::ghost_surface::GhostSurfaceInputs;
use crate::analyze::pricing::PricingTable;
use crate::analyze::pricing::{billing_provider, PricingTable};

/// Per-source set of *observed invocation names* in this slice. Each turn
/// contributes its tool-call names, any `skillName` set on a tool call, and
Expand Down Expand Up @@ -65,30 +65,29 @@ pub fn build_session_count_by_source(turns: &[TurnRecord]) -> HashMap<SourceKind
/// Falls back to 0 (which produces $0 cost but still surfaces ghosts) when
/// no priced model is available.
pub fn pick_representative_cache_read_rate(turns: &[TurnRecord], pricing: &PricingTable) -> f64 {
let mut counts: HashMap<&str, u64> = HashMap::new();
let mut order: Vec<&str> = Vec::new();
// A model's rate depends on who bills it, so turns count per (model,
// billing provider) pair.
let mut counts: HashMap<(&str, Option<&str>), u64> = HashMap::new();
let mut order: Vec<(&str, Option<&str>)> = Vec::new();
for t in turns {
let m = t.model.as_str();
if !counts.contains_key(m) {
order.push(m);
let key = (t.model.as_str(), billing_provider(t.source));
if !counts.contains_key(&key) {
order.push(key);
}
*counts.entry(m).or_insert(0) += 1;
*counts.entry(key).or_insert(0) += 1;
}
let mut best_model: Option<&str> = None;
let mut best_rate: Option<f64> = None;
let mut best_count: i64 = -1;
// Iterate in first-seen order so ties go to the earliest-seen model
// Iterate in first-seen order so ties go to the earliest-seen pair
// (matches the TS `Map` iteration order).
for m in order {
let c = counts[m] as i64;
if c > best_count && pricing.contains_key(m) {
best_model = Some(m);
for key @ (model, billed_by) in order {
let c = counts[&key] as i64;
if let Some(rate) = pricing.rate(model, billed_by).filter(|_| c > best_count) {
best_rate = Some(rate.cache_read);
best_count = c;
}
}
match best_model {
Some(m) => pricing[m].cache_read / 1_000_000.0,
None => 0.0,
}
best_rate.map_or(0.0, |cache_read| cache_read / 1_000_000.0)
}

/// Assemble a `GhostSurfaceInputs` from a slice of turns plus a pricing
Expand Down Expand Up @@ -230,7 +229,7 @@ mod tests {

#[test]
fn picks_cache_read_rate_of_most_used_priced_model() {
let mut pricing: PricingTable = HashMap::new();
let mut pricing = PricingTable::new();
pricing.insert(
"claude-sonnet-4-6".to_string(),
ModelCost {
Expand Down Expand Up @@ -286,15 +285,55 @@ mod tests {

#[test]
fn picks_zero_when_no_priced_model() {
let pricing: PricingTable = HashMap::new();
let pricing = PricingTable::new();
let turns = vec![make_turn(SourceKind::Codex, "s1", "unknown", vec![], None)];
assert_eq!(pick_representative_cache_read_rate(&turns, &pricing), 0.0);
}

#[test]
fn build_inputs_drops_empty_user_turn_text() {
let pricing: PricingTable = HashMap::new();
let pricing = PricingTable::new();
let inputs = build_ghost_surface_inputs(&[], &pricing, Some(HashMap::new()));
assert!(inputs.user_turn_text_by_session.is_none());
}

#[test]
fn tied_models_take_the_first_seen_models_billing_provider_rate() {
let pricing = crate::analyze::pricing::parse_pricing(
r#"{
"acme": { "models": {
"first": { "cost": { "input": 1, "output": 1, "cache_read": 0.1 } },
"second": { "cost": { "input": 1, "output": 1, "cache_read": 0.9 } }
} },
"github-copilot": { "models": {
"first": { "cost": { "input": 1, "output": 1, "cache_read": 0.4 } }
} }
}"#,
)
.unwrap();
let turns = vec![
make_turn(SourceKind::CopilotCli, "s1", "first", vec![], None),
make_turn(SourceKind::CopilotCli, "s1", "second", vec![], None),
];
let rate = pick_representative_cache_read_rate(&turns, &pricing);
assert!((rate - 0.4 / 1_000_000.0).abs() < 1e-15, "{rate}");
}

#[test]
fn the_most_used_billing_provider_sets_a_shared_models_rate() {
let pricing = crate::analyze::pricing::parse_pricing(
r#"{
"acme": { "models": { "m": { "cost": { "input": 1, "output": 1, "cache_read": 0.3 } } } },
"github-copilot": { "models": { "m": { "cost": { "input": 1, "output": 1, "cache_read": 0.5 } } } }
}"#,
)
.unwrap();
let turns = vec![
make_turn(SourceKind::CopilotCli, "s1", "m", vec![], None),
make_turn(SourceKind::Opencode, "s2", "m", vec![], None),
make_turn(SourceKind::Opencode, "s2", "m", vec![], None),
];
let rate = pick_representative_cache_read_rate(&turns, &pricing);
assert!((rate - 0.3 / 1_000_000.0).abs() < 1e-15, "{rate}");
}
}
2 changes: 1 addition & 1 deletion crates/relayburn-sdk/src/analyze/hotspots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ fn attribute_session(
let mut grand_total = 0.0_f64;

for &turn in turns {
let turn_rate = lookup_model_rate(&turn.model, pricing)
let turn_rate = lookup_model_rate(&turn.model, Some(turn.source), pricing)
.map(|rate| effective_model_rate(&turn.usage, rate));

// Accumulate the per-turn grand total in this same pass. Routes
Expand Down
5 changes: 4 additions & 1 deletion crates/relayburn-sdk/src/analyze/patterns/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ pub(super) fn detect_compaction_losses(
&usage,
&preceding.model,
pricing,
CostForUsageOptions::default(),
CostForUsageOptions {
source: Some(preceding.source),
..CostForUsageOptions::default()
},
) {
cache_lost_cost = priced.total;
}
Expand Down
Loading
Loading