diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts
index b0b5508ae..b43c6b529 100644
--- a/apps/fabro-web/app/lib/stage-sidebar.ts
+++ b/apps/fabro-web/app/lib/stage-sidebar.ts
@@ -33,8 +33,9 @@ export interface Stage {
startedAt: string | null;
providerUsed: StageModelUsage | null;
/**
- * Tokens and cost for this visit alone, priced the same way the Usage tab
- * prices its per-node rows. All-zero counts mean the stage called no model.
+ * Tokens and cost for this visit alone, the same figures the Usage tab
+ * sums into its per-node rows. All-zero counts mean the stage called no
+ * model.
*/
usage: Usage;
}
diff --git a/apps/fabro-web/app/routes/run-usage.tsx b/apps/fabro-web/app/routes/run-usage.tsx
index 1e600b7e2..4d976e042 100644
--- a/apps/fabro-web/app/routes/run-usage.tsx
+++ b/apps/fabro-web/app/routes/run-usage.tsx
@@ -95,7 +95,7 @@ function TokenBreakdown({ usage }: { usage: Usage }) {
))}
- Includes subagent tokens, priced at each subagent's model.
+ Includes subagent tokens, each priced at the model it ran on.
);
diff --git a/docs/internal/events.md b/docs/internal/events.md
index 5ca1c44ec..e90e8dc51 100644
--- a/docs/internal/events.md
+++ b/docs/internal/events.md
@@ -412,7 +412,7 @@ Emitted when a workflow node finishes execution.
| `suggested_next_ids` | string[] | Suggested successor node ids |
| `usage` | object? | The stage's usage under the model it ran on (`ModelUsage`): for an agent stage, the whole session tree's tokens under the root's route. Absent for a stage that made no model calls |
| `usage.model` | object | `provider`, `model_id`, and optional `speed` tier |
-| `usage.usage` | object | lithos-llm's `Usage`: `tokens` (the five disjoint buckets) and an optional `cost` (`usd_micros`, `source`). The cost is the provider's reported figure when it gave one, else the catalog's price; absent when the catalog has no rates |
+| `usage.usage` | object | lithos-llm's `Usage`: `tokens` (the five disjoint buckets) and an optional `cost` (`usd_micros`, `source`). The cost sums what lithos-llm attached to each answer, the provider's reported figure when it gave one, else the catalog's price for the route; absent when an answer had neither |
| `usage_by_model` | array? | For an agent stage, `usage` split by model: the root session's route and each subagent's own model, a subagent whose model the catalog does not know priced at the root's. Each row is a `ModelUsage`, and the rows sum to `usage`. Empty for stages without a coding agent and on events written before it existed |
| `error` | string? | Error message (flattened from failure detail) |
| `failure_class` | string? | `"transient_infra"`, `"deterministic"`, `"budget_exhausted"`, `"compilation_loop"`, `"canceled"`, `"structural"` |
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index 29abc9088..e620dbd55 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -11195,10 +11195,11 @@ components:
usage:
$ref: "#/components/schemas/Usage"
description: >-
- The stage's usage: while the stage runs, its agent's own
- accounting of the session tree with whatever cost the provider
- reported; once it ends, the same tokens with the catalog's price
- where the provider reported none.
+ The stage's usage: the session tree's tokens and cost, live and
+ once the stage ends. Every answer is priced once by lithos-llm
+ (the provider's reported cost, else the catalog's price for the
+ route) and summed; the cost is absent only when an answer had
+ neither.
model:
oneOf:
- $ref: "#/components/schemas/UsageModelRef"
@@ -13839,12 +13840,12 @@ components:
usage:
$ref: "#/components/schemas/Usage"
description: >-
- Usage for this stage execution alone. `cost` is the provider's
- reported cost when there is one, otherwise the server catalog's
- price for these tokens — the same pricing the `/runs/{id}/usage`
- rows use. All-zero counts mean the stage made no model calls.
- Unlike the usage rows, which sum every visit of a node, this
- covers only this visit.
+ Usage for this stage execution alone. `cost` sums what lithos-llm
+ attached to each answer: the provider's reported cost when there
+ is one, otherwise the catalog's price for the route — the same
+ figures the `/runs/{id}/usage` rows sum. All-zero counts mean the
+ stage made no model calls. Unlike the usage rows, which sum every
+ visit of a node, this covers only this visit.
# ── File Diff Schemas ──────────────────────────────────────────────
diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs
index b5dd8876e..04b838b95 100644
--- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs
+++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs
@@ -461,12 +461,12 @@ mod tests {
use fabro_workflow::event::{
Event, RunNoticeLevel, SandboxLifecycle, to_run_event, to_run_event_at,
};
- use fabro_workflow::outcome::model_usage_from_llm;
+ use fabro_workflow::outcome::ModelUsage;
use lithos_llm::catalog::{ModelId, builtin};
- use lithos_llm::types::TokenCounts;
+ use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage};
use pebble_coding_agent::events::{
CodingAgentEvent, CodingEvent, CompactionReason, ErrorData as AgentErrorData,
- ErrorKind as AgentErrorKind, Usage,
+ ErrorKind as AgentErrorKind,
};
use super::*;
@@ -649,18 +649,22 @@ mod tests {
preferred_label: None,
suggested_next_ids: Vec::new(),
usage_by_model: Vec::new(),
- usage: Some(
- model_usage_from_llm(
- &fabro_llm::test_support::test_catalog(),
- &ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")),
- TokenCounts {
+ // Priced as lithos-llm prices gpt-5.4: 1200 input at $2.50/M and
+ // 300 output at $15/M.
+ usage: Some(ModelUsage::new(
+ ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")),
+ Usage {
+ tokens: TokenCounts {
input: 1200,
output: 300,
..TokenCounts::default()
},
- )
- .unwrap(),
- ),
+ cost: Some(Cost {
+ usd_micros: 7_500,
+ source: CostSource::Catalog,
+ }),
+ },
+ )),
failure: None,
notes: None,
files_touched: Vec::new(),
diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs
index 6f3868109..ab533e1da 100644
--- a/lib/components/fabro-store/src/run_state.rs
+++ b/lib/components/fabro-store/src/run_state.rs
@@ -716,8 +716,8 @@ fn apply_agent_event(
// Pebble's own fold sees every agent event the stage stored, before the
// fabro-only arms below read the same event. While the stage runs, its
// usage is that fold's: the tree's tokens, the root's and every
- // subagent's, with whatever cost the provider reported. The terminal
- // usage then brings the catalog's price for the same tokens.
+ // subagent's, with the cost lithos-llm attached to each answer. The
+ // terminal usage is the same sum, split by model.
if let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) {
let agent = stage.agent.get_or_insert_default();
agent.apply(&props.event);
@@ -5342,11 +5342,105 @@ mod tests {
}
/// One usage rule: a stage's usage is its session tree's, live and at
- /// completion. The terminal usage carries the tokens the fold already
- /// showed plus the catalog's price, so completion changes the cost, not
- /// the tokens, and keeps the split by model.
+ /// completion, cost included. lithos-llm prices each answer once, pebble
+ /// sums them, and the terminal usage is the same sum, so completion
+ /// changes neither the tokens nor the cost; it adds the split by model.
#[test]
- fn stage_completed_keeps_the_trees_live_usage_and_prices_it() {
+ fn stage_completed_keeps_the_trees_live_usage_and_its_cost() {
+ let mut state = initialized_projection();
+ let stage_id = StageId::new("build", 1);
+ let model = priced_usage().model().clone();
+ let catalog_priced = |input: u64, output: u64, usd_micros: u64| Usage {
+ tokens: TokenCounts {
+ input,
+ output,
+ ..TokenCounts::default()
+ },
+ cost: Some(Cost {
+ usd_micros,
+ source: CostSource::Catalog,
+ }),
+ };
+ let message = |session: &str, usage: Usage| {
+ let mut event = CodingAgentEvent::new(
+ session,
+ CodingEvent::AssistantMessage {
+ text: "assistant text".to_string(),
+ model: model.model_id.to_string(),
+ usage,
+ tool_call_count: 0,
+ context_window: None,
+ reasoning: None,
+ },
+ SystemTime::UNIX_EPOCH,
+ );
+ if session != "ses_test" {
+ event = event.with_parent_session_id("ses_test");
+ }
+ EventBody::Agent(AgentEventProps::new("code", 1, event))
+ };
+
+ state
+ .apply_event(&test_stage_event(
+ 1,
+ EventBody::StageStarted(started_props()),
+ stage_id.clone(),
+ ))
+ .unwrap();
+ state
+ .apply_event(&test_stage_event(
+ 2,
+ activated(model.provider.as_str(), model.model_id.as_str()),
+ stage_id.clone(),
+ ))
+ .unwrap();
+ state
+ .apply_event(&test_stage_event(
+ 3,
+ message("ses_test", catalog_priced(100, 50, 300)),
+ stage_id.clone(),
+ ))
+ .unwrap();
+ state
+ .apply_event(&test_stage_event(
+ 4,
+ message("ses_child", catalog_priced(7, 1, 21)),
+ stage_id.clone(),
+ ))
+ .unwrap();
+ let live = state.stage(&stage_id).unwrap().usage;
+ assert_eq!(
+ live,
+ catalog_priced(107, 51, 321),
+ "the subagent's tokens and cost are the stage's too"
+ );
+
+ // The terminal usage is the same sum, under the root's route.
+ let tree = ModelUsage::new(model.clone(), live);
+ let mut props = completed_props(42, StageOutcome::Succeeded);
+ props.usage = Some(tree.clone());
+ props.usage_by_model = vec![tree.clone()];
+ state
+ .apply_event(&test_stage_event(
+ 5,
+ EventBody::StageCompleted(props),
+ stage_id.clone(),
+ ))
+ .unwrap();
+
+ let stage = state.stage(&stage_id).unwrap();
+ assert_eq!(
+ stage.usage, live,
+ "completion keeps the usage the fold showed, cost included"
+ );
+ assert_eq!(stage.model.as_ref(), Some(&model));
+ assert_eq!(stage.usage_by_model, vec![tree]);
+ }
+
+ /// An answer nobody priced leaves the tree's cost unknown, live and at
+ /// completion alike; the tokens are still counted.
+ #[test]
+ fn an_unpriced_answer_leaves_the_stage_cost_unknown_live_and_at_completion() {
let mut state = initialized_projection();
let stage_id = StageId::new("build", 1);
let model = priced_usage().model().clone();
@@ -5372,57 +5466,28 @@ mod tests {
stage_id.clone(),
))
.unwrap();
+ let live = state.stage(&stage_id).unwrap().usage;
+ assert_eq!(live, live_counts(100, 50));
+ assert_eq!(live.cost, None);
+
+ let tree = ModelUsage::new(model.clone(), live);
+ let mut props = completed_props(42, StageOutcome::Succeeded);
+ props.usage = Some(tree.clone());
+ props.usage_by_model = vec![tree];
state
.apply_event(&test_stage_event(
4,
- child_message_body(7, 1),
- stage_id.clone(),
- ))
- .unwrap();
- let live = state.stage(&stage_id).unwrap().usage;
- assert_eq!(
- live,
- live_counts(107, 51),
- "the subagent's tokens are the stage's too"
- );
-
- let tree = ModelUsage::new(model.clone(), Usage {
- tokens: TokenCounts {
- input: 107,
- output: 51,
- ..TokenCounts::default()
- },
- cost: Some(Cost {
- usd_micros: 321,
- source: CostSource::Catalog,
- }),
- });
- let mut props = completed_props(42, StageOutcome::Succeeded);
- props.usage = Some(tree.clone());
- props.usage_by_model = vec![tree.clone()];
- state
- .apply_event(&test_stage_event(
- 5,
EventBody::StageCompleted(props),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
+ assert_eq!(stage.usage, live);
assert_eq!(
- stage.usage.tokens, live.tokens,
- "completion keeps the tokens the fold showed"
+ stage.usage.cost, None,
+ "nothing priced it, so nothing invents a cost"
);
- assert_eq!(
- stage.usage.cost,
- Some(Cost {
- usd_micros: 321,
- source: CostSource::Catalog,
- }),
- "and brings the catalog's price"
- );
- assert_eq!(stage.model.as_ref(), Some(&model));
- assert_eq!(stage.usage_by_model, vec![tree]);
}
#[test]
diff --git a/lib/components/fabro-workflow/src/handler/llm/pebble.rs b/lib/components/fabro-workflow/src/handler/llm/pebble.rs
index 65b7b143e..2ac99daa3 100644
--- a/lib/components/fabro-workflow/src/handler/llm/pebble.rs
+++ b/lib/components/fabro-workflow/src/handler/llm/pebble.rs
@@ -63,7 +63,7 @@ use crate::context::keys::Fidelity;
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
use crate::model_fallback::{ModelFallbackNotice, ModelFallbackPolicy};
-use crate::outcome::{Outcome, model_usage_from_llm, with_reported_cost};
+use crate::outcome::Outcome;
use crate::services::FabroRunToolServices;
use crate::steering_hub::SteeringHub;
use crate::web_search::{self, SearchSecrets};
@@ -336,20 +336,18 @@ struct StageUsage {
by_model: Vec,
}
-/// Prices the stage's account from the catalog: the root session at
-/// `root_model`, its route, and each descendant at its own route where the
-/// catalog knows it and at the root's otherwise, so a subagent on a cheaper
-/// or dearer model is priced as what it ran. A descendant on the root's
-/// route joins the root's row. Where pebble carried a provider-reported
-/// cost, that cost stands in for the catalog's estimate. The total's cost is
-/// the rows' sum, which is `None` once a row that used tokens has no cost.
+/// The stage's account grouped by model: the root session at `root_model`,
+/// its route, and each descendant at its own route where the catalog knows
+/// it and at the root's otherwise. A descendant on the root's route joins
+/// the root's row. Every cost is the one pebble carried: lithos-llm attaches
+/// the provider's reported cost or the catalog's price to each answer, and
+/// pebble sums them per session, so fabro prices nothing of its own. A row,
+/// and the total, has a cost only when every answer in it was priced.
fn stage_usage(
catalog: &Catalog,
root_model: &ModelRef,
account: &SessionProjection,
-) -> Result {
- // Each group's usage is the sum of pebble's accounts, so its cost is what
- // the provider reported, or `None` once an unpriced account is in it.
+) -> StageUsage {
let mut groups: Vec<(ModelRef, Usage)> = vec![(root_model.clone(), account.usage)];
for descendant in account.descendants.values() {
let model = descendant_model(catalog, root_model, descendant);
@@ -361,25 +359,20 @@ fn stage_usage(
// The root's row first, then the others by model.
groups[1..].sort_by(|left, right| left.0.sort_key().cmp(&right.0.sort_key()));
- let mut by_model = Vec::with_capacity(groups.len());
- let mut total = Usage::default();
- for (model, usage) in groups {
- let row = with_reported_cost(
- model_usage_from_llm(catalog, &model, usage.tokens)?,
- usage.cost,
- );
- total = total.saturating_add(row.usage);
- by_model.push(row);
+ let total = fabro_types::sum_usage(groups.iter().map(|(_, usage)| *usage));
+ StageUsage {
+ total: ModelUsage::new(root_model.clone(), total),
+ by_model: groups
+ .into_iter()
+ .map(|(model, usage)| ModelUsage::new(model, usage))
+ .collect(),
}
- Ok(StageUsage {
- total: ModelUsage::new(root_model.clone(), total),
- by_model,
- })
}
-/// The route a descendant is billed at: its own where its start named one
-/// the catalog knows, else the root's. A descendant whose start was not seen
-/// names only its answers' model, taken to be on the root's provider.
+/// The route a descendant's usage is grouped under: its own where its start
+/// named one the catalog knows, else the root's. A descendant whose start
+/// was not seen names only its answers' model, taken to be on the root's
+/// provider.
fn descendant_model(
catalog: &Catalog,
root_model: &ModelRef,
@@ -756,27 +749,17 @@ impl PebbleBackend {
/// The failed outcome of an agent stage that spent before it failed: the
/// failure itself, with the session tree's usage, the files it wrote, and
- /// its active time, so the run records what the stage spent. A usage the
- /// catalog cannot price is logged and left off.
+ /// its active time, so the run records what the stage spent.
fn failed_outcome(&self, error: &Error, live: &LiveAgent, plan: &FallbackPlan) -> Outcome {
let mut outcome = error.to_fail_outcome();
let account = live.account();
- match stage_usage(
+ let usage = stage_usage(
self.catalog.as_ref(),
&route_model(plan.current()),
&account,
- ) {
- Ok(usage) => {
- outcome.usage = Some(usage.total);
- outcome.usage_by_model = usage.by_model;
- }
- Err(usage_error) => {
- tracing::debug!(
- error = %usage_error,
- "failed agent stage could not be priced"
- );
- }
- }
+ );
+ outcome.usage = Some(usage.total);
+ outcome.usage_by_model = usage.by_model;
outcome.files_touched = account.files_touched;
outcome.timing = Some(StageTiming::active_only(
crate::millis_u64(live.inference_duration),
@@ -1028,12 +1011,10 @@ impl CodergenBackend for PebbleBackend {
continue;
}
- // The provider's own cost, when every answer carried one, stands in
- // for the catalog's estimate.
- let stage_usage = with_reported_cost(
- model_usage_from_llm(self.catalog.as_ref(), &completion.model, total_usage.tokens)?,
- total_usage.cost,
- );
+ // Each response came priced by lithos-llm: the provider's reported
+ // cost, or the catalog's price for the route. The stage's cost is
+ // their sum, known only when every answer was priced.
+ let stage_usage = ModelUsage::new(completion.model.clone(), total_usage);
return Ok(CodergenResult::Text {
text: response_text,
@@ -1238,7 +1219,7 @@ impl CodergenBackend for PebbleBackend {
self.catalog.as_ref(),
&route_model(fallback_plan.current()),
&account,
- )?;
+ );
live.release_lease();
match reuse_key {
@@ -1303,7 +1284,7 @@ mod tests {
}
}
- fn message(model: &str, input: u64, output: u64, cost: Option) -> CodingEvent {
+ fn message(model: &str, input: u64, output: u64, cost: Option) -> CodingEvent {
CodingEvent::AssistantMessage {
text: "ok".to_string(),
model: model.to_string(),
@@ -1313,10 +1294,7 @@ mod tests {
output,
..TokenCounts::default()
},
- cost: cost.map(|usd_micros| Cost {
- usd_micros,
- source: CostSource::Provider,
- }),
+ cost,
},
tool_call_count: 0,
context_window: None,
@@ -1324,6 +1302,13 @@ mod tests {
}
}
+ fn catalog_cost(usd_micros: u64) -> Cost {
+ Cost {
+ usd_micros,
+ source: CostSource::Catalog,
+ }
+ }
+
fn root_model() -> ModelRef {
ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4"))
}
@@ -1334,8 +1319,10 @@ mod tests {
account
}
+ /// Every cost comes from pebble's stream, where lithos-llm attached it
+ /// to each answer; fabro groups and sums, and prices nothing itself.
#[test]
- fn stage_usage_prices_the_root_at_its_route_and_each_descendant_at_its_own() {
+ fn stage_usage_groups_pebbles_priced_accounts_by_route_and_sums_them() {
let catalog = test_catalog();
let account = account(&[
root(started("openai", "gpt-5.4")),
@@ -1344,20 +1331,43 @@ mod tests {
content: None,
source: InputSource::Prompt,
}),
- root(message("gpt-5.4", 100_000, 25_000, None)),
+ root(message(
+ "gpt-5.4",
+ 100_000,
+ 25_000,
+ Some(catalog_cost(300_000)),
+ )),
// A child on the parent's route joins the parent's row.
child("ses_same", started("openai", "gpt-5.4")),
- child("ses_same", message("gpt-5.4", 10_000, 1_000, None)),
- // A child on another route is its own row, at that route's rate.
+ child(
+ "ses_same",
+ message("gpt-5.4", 10_000, 1_000, Some(catalog_cost(30_000))),
+ ),
+ // A child on another route is its own row, at the cost its
+ // provider reported.
child("ses_other", started("anthropic", "claude-sonnet-5")),
- child("ses_other", message("claude-sonnet-5", 20_000, 2_000, None)),
- // A child on a route the catalog does not know bills at the root's.
+ child(
+ "ses_other",
+ message(
+ "claude-sonnet-5",
+ 20_000,
+ 2_000,
+ Some(Cost {
+ usd_micros: 70_000,
+ source: CostSource::Provider,
+ }),
+ ),
+ ),
+ // A child on a route the catalog does not know joins the root's row.
child("ses_unknown", started("nowhere", "mystery")),
- child("ses_unknown", message("mystery", 1_000, 100, None)),
+ child(
+ "ses_unknown",
+ message("mystery", 1_000, 100, Some(catalog_cost(5_000))),
+ ),
root(CodingEvent::ProcessingEnd),
]);
- let usage = stage_usage(&catalog, &root_model(), &account).unwrap();
+ let usage = stage_usage(&catalog, &root_model(), &account);
assert_eq!(usage.by_model.len(), 2, "{:?}", usage.by_model);
let root_row = &usage.by_model[0];
@@ -1367,102 +1377,103 @@ mod tests {
"the root, the same-route child, and the unknown-route child"
);
assert_eq!(root_row.usage.tokens.output, 26_100);
- let root_priced =
- model_usage_from_llm(&catalog, &root_model(), root_row.usage.tokens).unwrap();
- assert_eq!(root_row.usage.cost, root_priced.usage.cost);
assert_eq!(
- root_row.usage.cost.map(|cost| cost.source),
- Some(CostSource::Catalog)
+ root_row.usage.cost,
+ Some(catalog_cost(335_000)),
+ "the row's cost is the sum of what pebble carried, still the catalog's"
);
- let other_model = ModelRef::new(
- ProviderId::new("anthropic"),
- ModelId::new("claude-sonnet-5"),
- );
let other_row = &usage.by_model[1];
- assert_eq!(other_row.model, other_model);
+ assert_eq!(
+ other_row.model,
+ ModelRef::new(
+ ProviderId::new("anthropic"),
+ ModelId::new("claude-sonnet-5"),
+ )
+ );
assert_eq!(other_row.usage.tokens.input, 20_000);
- assert_eq!(other_row.usage.tokens.output, 2_000);
- let other_priced =
- model_usage_from_llm(&catalog, &other_model, other_row.usage.tokens).unwrap();
- assert_eq!(other_row.usage.cost, other_priced.usage.cost);
- assert_ne!(
+ assert_eq!(
other_row.usage.cost,
- model_usage_from_llm(&catalog, &root_model(), other_row.usage.tokens)
- .unwrap()
- .usage
- .cost,
- "priced at its own rate, not the root's"
+ Some(Cost {
+ usd_micros: 70_000,
+ source: CostSource::Provider,
+ }),
+ "a provider-reported cost is kept as reported"
);
- // The total is the tree's tokens under the root's route, at the rows' summed
- // cost, from the catalog like every row.
+ // The total is the tree's tokens under the root's route; its cost is
+ // the rows' sum, assembled from two sources.
assert_eq!(usage.total.model, root_model());
assert_eq!(usage.total.usage.tokens.input, 131_000);
assert_eq!(usage.total.usage.tokens.output, 28_100);
assert_eq!(
usage.total.usage.cost,
Some(Cost {
- usd_micros: root_priced.usage.cost.unwrap().usd_micros
- + other_priced.usage.cost.unwrap().usd_micros,
- source: CostSource::Catalog,
- })
- );
- }
-
- #[test]
- fn a_provider_reported_cost_stands_in_for_the_catalogs_estimate() {
- let catalog = test_catalog();
- let account = account(&[
- root(started("openai", "gpt-5.4")),
- root(message("gpt-5.4", 1_000, 100, Some(4_321))),
- child("ses_child", started("anthropic", "claude-sonnet-5")),
- child("ses_child", message("claude-sonnet-5", 500, 50, None)),
- ]);
-
- let usage = stage_usage(&catalog, &root_model(), &account).unwrap();
-
- assert_eq!(
- usage.by_model[0].usage.cost,
- Some(Cost {
- usd_micros: 4_321,
- source: CostSource::Provider,
- })
- );
- let child_priced = model_usage_from_llm(
- &catalog,
- &usage.by_model[1].model,
- usage.by_model[1].usage.tokens,
- )
- .unwrap();
- assert_eq!(usage.by_model[1].usage.cost, child_priced.usage.cost);
- // One row reported, one priced: the sum is the application's.
- assert_eq!(
- usage.total.usage.cost,
- Some(Cost {
- usd_micros: 4_321 + child_priced.usage.cost.unwrap().usd_micros,
+ usd_micros: 405_000,
source: CostSource::Application,
})
);
}
+ /// An answer pebble could not price (a model with no catalog price and
+ /// no provider cost) leaves its row's cost, and the total's, unknown; the
+ /// tokens are still counted. Live and completed usage agree because both
+ /// are the same sum of pebble's accounts.
#[test]
- fn a_descendant_seen_only_through_its_answers_bills_on_the_roots_provider() {
+ fn stage_usage_leaves_the_cost_unknown_once_an_answer_was_unpriced() {
+ let catalog = test_catalog();
+ let priced_only = account(&[
+ root(started("openai", "gpt-5.4")),
+ root(message("gpt-5.4", 1_000, 100, Some(catalog_cost(4_321)))),
+ ]);
+ let priced = stage_usage(&catalog, &root_model(), &priced_only);
+ assert_eq!(priced.total.usage.cost, Some(catalog_cost(4_321)));
+ assert_eq!(
+ priced.total.usage,
+ priced_only
+ .usage
+ .saturating_add(priced_only.descendant_usage()),
+ "the completed usage is the live fold's, cost included"
+ );
+
+ let tree = account(&[
+ root(started("openai", "gpt-5.4")),
+ root(message("gpt-5.4", 1_000, 100, Some(catalog_cost(4_321)))),
+ child("ses_child", started("anthropic", "claude-sonnet-5")),
+ child("ses_child", message("claude-sonnet-5", 500, 50, None)),
+ ]);
+
+ let usage = stage_usage(&catalog, &root_model(), &tree);
+
+ assert_eq!(usage.by_model[0].usage.cost, Some(catalog_cost(4_321)));
+ assert_eq!(usage.by_model[1].usage.tokens.input, 500);
+ assert_eq!(usage.by_model[1].usage.cost, None);
+ assert_eq!(usage.total.usage.tokens.input, 1_500);
+ assert_eq!(usage.total.usage.cost, None);
+ assert_eq!(
+ usage.total.usage,
+ tree.usage.saturating_add(tree.descendant_usage()),
+ "the completed usage is the live fold's, cost unknown at both"
+ );
+ }
+
+ #[test]
+ fn a_descendant_seen_only_through_its_answers_groups_under_the_roots_provider() {
let catalog = test_catalog();
let mut account = account(&[root(started("openai", "gpt-5.4"))]);
// No `SessionStarted` for the child: only its answer names a model.
account.apply(&child(
"ses_quiet",
- message("gpt-5.4-mini", 1_000, 100, None),
+ message("gpt-5.4-mini", 1_000, 100, Some(catalog_cost(1))),
));
- let usage = stage_usage(&catalog, &root_model(), &account).unwrap();
+ let usage = stage_usage(&catalog, &root_model(), &account);
let child_row = usage
.by_model
.iter()
.find(|row| row.model.model_id.as_str() == "gpt-5.4-mini")
- .expect("the child is billed as its answers' model on the root's provider");
+ .expect("the child is grouped as its answers' model on the root's provider");
assert_eq!(child_row.model.provider, root_model().provider);
assert_eq!(child_row.usage.tokens.input, 1_000);
}
diff --git a/lib/components/fabro-workflow/src/handler/llm/preamble.rs b/lib/components/fabro-workflow/src/handler/llm/preamble.rs
index 1fade571b..68e1bcf58 100644
--- a/lib/components/fabro-workflow/src/handler/llm/preamble.rs
+++ b/lib/components/fabro-workflow/src/handler/llm/preamble.rs
@@ -591,22 +591,20 @@ mod tests {
use fabro_graphviz::graph::AttrValue;
use fabro_types::ModelRef;
use lithos_llm::catalog::{ModelId, builtin};
- use lithos_llm::types::TokenCounts;
+ use lithos_llm::types::{TokenCounts, Usage};
use super::*;
- use crate::outcome::{ModelUsage, model_usage_from_llm};
+ use crate::outcome::ModelUsage;
fn stage_usage(model: &str, input: u64, output: u64) -> ModelUsage {
- model_usage_from_llm(
- &fabro_llm::test_support::test_catalog(),
- &ModelRef::new(builtin::anthropic(), ModelId::new(model)),
- TokenCounts {
+ ModelUsage::new(
+ ModelRef::new(builtin::anthropic(), ModelId::new(model)),
+ Usage::from(TokenCounts {
input,
output,
..TokenCounts::default()
- },
+ }),
)
- .unwrap()
}
fn large_prompt_value(bytes: u64, path: &str, preview: &str) -> serde_json::Value {
diff --git a/lib/components/fabro-workflow/src/outcome.rs b/lib/components/fabro-workflow/src/outcome.rs
index 0a1d0fc22..45f69752b 100644
--- a/lib/components/fabro-workflow/src/outcome.rs
+++ b/lib/components/fabro-workflow/src/outcome.rs
@@ -1,46 +1,12 @@
pub use fabro_core::outcome::{
FailureCategory, FailureDetail, OutcomeMeta, StageOutcome, StageState,
};
-use fabro_llm::lithos_catalog::Catalog;
-use fabro_types::ModelRef;
pub use fabro_types::ModelUsage;
-use lithos_llm::types::{Cost, TokenCounts, Usage};
-use crate::error::{Error, FailureSignature, classify_failure_reason};
+use crate::error::{FailureSignature, classify_failure_reason};
pub type Outcome = fabro_core::Outcome