From f8ed856959a7c302d4b9e7178becf10a05f5f70d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 06:59:48 -0400 Subject: [PATCH 1/4] Add regression test for authoritative provider billing --- .../fabro-workflow/tests/it/integration.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 3ceeced5d..87ebeee4d 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -2319,6 +2319,121 @@ reasoning = false ); } +#[tokio::test] +async fn workflow_persists_authoritative_openrouter_cost_for_agent_stage() { + use fabro_auth::EnvCredentialSource; + use fabro_workflow::steering_hub::SteeringHub; + use httpmock::Method::POST; + use httpmock::MockServer; + + const AUTHORITATIVE_COST_USD: f64 = 0.125; + const AUTHORITATIVE_COST_USD_MICROS: i64 = 125_000; + + let server = MockServer::start_async().await; + let text_chunk = serde_json::json!({ + "id": "chatcmpl_authoritative_cost", + "model": "openai/gpt-5.4", + "choices": [{ + "delta": {"content": "done"}, + "finish_reason": null + }] + }); + let usage_chunk = serde_json::json!({ + "id": "chatcmpl_authoritative_cost", + "model": "openai/gpt-5.4", + "choices": [], + "usage": { + "prompt_tokens": 11, + "completion_tokens": 7, + "total_tokens": 18, + "cost": AUTHORITATIVE_COST_USD + } + }); + let response = format!("data: {text_chunk}\n\ndata: {usage_chunk}\n\ndata: [DONE]\n\n"); + let completion_mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/chat/completions") + .body_includes(r#""stream":true"#) + .body_includes("Report completion"); + then.status(200) + .header("content-type", "text/event-stream") + .body(response); + }) + .await; + + let settings: LlmCatalogSettings = toml::from_str(&format!( + r#" +[providers.openrouter] +enabled = true +base_url = "{}" +"#, + server.base_url() + )) + .expect("test catalog should parse"); + let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); + let source = Arc::new(EnvCredentialSource::with_env_lookup(Arc::new(|name| { + (name == "OPENROUTER_API_KEY").then(|| "sk-test".to_string()) + }))); + let backend = AgentApiBackend::new_with_catalog( + "openai/gpt-5.4".to_string(), + ProviderId::from("openrouter"), + Vec::new(), + source, + Arc::new(SteeringHub::new(Arc::new(Emitter::default()))), + catalog, + ); + + let mut graph = make_graph_with_start_exit("AuthoritativeOpenRouterCost"); + let mut work = Node::new("work"); + work.attrs.insert( + "prompt".to_string(), + AttrValue::String("Report completion".to_string()), + ); + graph.nodes.insert("work".to_string(), work); + graph.edges.push(Edge::new("start", "work")); + graph.edges.push(Edge::new("work", "exit")); + + let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(backend))))); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + + let dir = tempfile::tempdir().unwrap(); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let run_options = RunOptions { + settings: WorkflowSettings::default(), + run_dir: dir.path().to_path_buf(), + cancel_token: CancellationToken::new(), + run_id: test_run_id("authoritative-openrouter-cost"), + labels: std::collections::HashMap::new(), + workflow_slug: None, + github_app: None, + base_branch: None, + display_base_sha: None, + pre_run_git: None, + fork_source_ref: None, + git: None, + }; + + let (outcome, state) = engine + .run_with_state(&graph, &run_options) + .await + .expect("workflow execution should complete"); + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert_eq!(completion_mock.calls_async().await, 1); + + let work = state + .stage(&fabro_types::StageId::new("work", 1)) + .expect("agent stage should be projected"); + assert_eq!(work.usage.input_tokens, 11); + assert_eq!(work.usage.output_tokens, 7); + assert_eq!( + work.usage.total_usd_micros, + Some(AUTHORITATIVE_COST_USD_MICROS), + "provider-reported usage.cost should override the catalog estimate" + ); +} + // --------------------------------------------------------------------------- // 12. Parallel fan-out / fan-in integration test (Gap #14) // --------------------------------------------------------------------------- From 7f25689fb641f3d2f183a383ec2886a654ab4023 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 07:08:04 -0400 Subject: [PATCH 2/4] Preserve provider-reported workflow costs --- lib/crates/fabro-agent/src/session.rs | 59 ++++++++++++++++++- lib/crates/fabro-agent/src/types.rs | 14 ++++- .../src/commands/run/run_progress/mod.rs | 2 + lib/crates/fabro-model/src/billing.rs | 11 +++- lib/crates/fabro-server/src/demo/mod.rs | 2 + .../fabro-server/src/server/handler/pair.rs | 2 + lib/crates/fabro-server/src/server/tests.rs | 2 + lib/crates/fabro-store/src/run_state.rs | 1 + lib/crates/fabro-types/src/run_event/agent.rs | 7 ++- lib/crates/fabro-types/src/run_event/mod.rs | 2 + .../fabro-workflow/src/event/convert.rs | 50 +++++++++++++++- .../fabro-workflow/src/handler/llm/api.rs | 38 ++++++++++-- lib/crates/fabro-workflow/src/outcome.rs | 37 +++++++++++- 13 files changed, 211 insertions(+), 16 deletions(-) diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 2bd840bda..93b2b5c7b 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -15,7 +15,7 @@ use fabro_llm::{Error as LlmError, retry}; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_mcp::connection_manager::McpConnectionManager; use fabro_mcp::http_transport; -use fabro_model::{AgentProfileKind, Catalog, ModelRef, Speed}; +use fabro_model::{AgentProfileKind, Catalog, ModelRef, Speed, UsdMicros}; use fabro_types::{ AgentToolSummary, PermissionLevel, Principal, SessionMessage, SessionRecord, StageContextWindowProjection, SteeringMessage, @@ -354,6 +354,7 @@ pub struct Session { completion_coordinator: Option>, last_input_timing: SessionInputTiming, last_input_usage: TokenCounts, + last_input_cost: Option, } impl Session { @@ -393,6 +394,7 @@ impl Session { completion_coordinator: None, last_input_timing: SessionInputTiming::default(), last_input_usage: TokenCounts::default(), + last_input_cost: None, } } @@ -1222,6 +1224,11 @@ impl Session { self.last_input_usage.clone() } + #[must_use] + pub const fn last_input_cost(&self) -> Option { + self.last_input_cost + } + /// Process an input. The inference/tool timing accumulated during the call /// is available via [`Self::last_input_timing`] after this returns, even on /// error. @@ -1232,8 +1239,10 @@ impl Session { ) -> Result<(), Error> { let mut timing = SessionInputTiming::default(); let mut usage = TokenCounts::default(); + let mut cost = None; self.last_input_timing = timing; self.last_input_usage = TokenCounts::default(); + self.last_input_cost = None; if self.state == SessionState::Closed { return Err(Error::SessionClosed); } @@ -1258,7 +1267,13 @@ impl Session { // Process the initial input, then drain any followups let mut result = self - .run_single_input(input, &agent_tool_runtime, &mut timing, &mut usage) + .run_single_input( + input, + &agent_tool_runtime, + &mut timing, + &mut usage, + &mut cost, + ) .await; if result.is_ok() { @@ -1270,7 +1285,13 @@ impl Session { .pop_front(); let Some(followup) = followup else { break }; result = self - .run_single_input(&followup, &agent_tool_runtime, &mut timing, &mut usage) + .run_single_input( + &followup, + &agent_tool_runtime, + &mut timing, + &mut usage, + &mut cost, + ) .await; if result.is_err() { break; @@ -1290,6 +1311,7 @@ impl Session { self.last_input_timing = timing; self.last_input_usage = usage; + self.last_input_cost = cost; result } @@ -1299,6 +1321,7 @@ impl Session { agent_tool_runtime: &AgentToolRuntime, timing: &mut SessionInputTiming, usage_accumulator: &mut TokenCounts, + cost_accumulator: &mut Option, ) -> Result<(), Error> { const STREAM_CONSUME_RETRIES: usize = 3; @@ -1704,6 +1727,9 @@ impl Session { &usage, )); *usage_accumulator += usage.clone(); + if let Some(cost_usd) = response.cost_usd { + *cost_accumulator.get_or_insert_default() += UsdMicros::from_usd(cost_usd); + } self.history.push(Message::Assistant { content: text.clone(), @@ -1729,6 +1755,8 @@ impl Session { text: text.clone(), model, usage: response.usage.clone(), + cost_usd: response.cost_usd, + cost_source: response.cost_source, tool_call_count: tool_calls.len(), context_window, }); @@ -2276,6 +2304,25 @@ mod tests { } } + #[tokio::test] + async fn last_input_cost_sums_each_response_in_a_multi_turn_input() { + let mut registry = ToolRegistry::new(); + registry.register(make_echo_tool()); + + let responses = vec![ + response_with_cost( + tool_call_response("echo", "call_1", serde_json::json!({"text": "hello"})), + 0.04, + ), + response_with_cost(text_response("Done!"), 0.06), + ]; + + let mut session = make_session_with_tools(responses, registry).await; + session.process_input("Use echo tool").await.unwrap(); + + assert_eq!(session.last_input_cost(), Some(UsdMicros(100_000))); + } + #[tokio::test] async fn last_input_timing_reports_inference_and_tool_per_call() { let mut registry = ToolRegistry::new(); @@ -4009,6 +4056,12 @@ mod tests { response } + fn response_with_cost(mut response: Response, cost_usd: f64) -> Response { + response.cost_usd = Some(cost_usd); + response.cost_source = Some(fabro_model::CostSource::Authoritative); + response + } + fn response_with_input_tokens(response: Response, input_tokens: i64) -> Response { response_with_usage(response, TokenCounts { input_tokens, diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index d6907880f..94cb6808e 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -3,7 +3,7 @@ use std::time::SystemTime; use chrono::{DateTime, Utc}; use fabro_llm::Error as LlmError; use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult}; -use fabro_model::ModelRef; +use fabro_model::{CostSource, ModelRef}; use fabro_types::{SessionMessage, StageContextWindowProjection}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -245,6 +245,12 @@ pub enum AgentEvent { text: String, model: ModelRef, usage: TokenCounts, + /// USD cost reported or estimated for this individual response. + #[serde(default, skip_serializing_if = "Option::is_none")] + cost_usd: Option, + /// Provenance of `cost_usd`. + #[serde(default, skip_serializing_if = "Option::is_none")] + cost_source: Option, tool_call_count: usize, #[serde(default, skip_serializing_if = "Option::is_none")] context_window: Option, @@ -880,12 +886,16 @@ mod tests { speed: None, }, usage: usage.clone(), + cost_usd: Some(0.125), + cost_source: Some(CostSource::Authoritative), tool_call_count: 2, context_window: None, }; match &event { AgentEvent::AssistantMessage { usage, + cost_usd, + cost_source, tool_call_count, .. } => { @@ -893,6 +903,8 @@ mod tests { assert_eq!(usage.input_tokens, 100); assert_eq!(usage.cache_read_tokens, 80); assert_eq!(usage.reasoning_tokens, 20); + assert_eq!(*cost_usd, Some(0.125)); + assert_eq!(*cost_source, Some(CostSource::Authoritative)); } _ => panic!("expected AssistantMessage"), } diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 75d7fc0ff..6e1a6227b 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -528,6 +528,8 @@ mod tests { speed: None, }, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, tool_call_count: 0, context_window: None, }) diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index d75992d7a..9754e8908 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -47,6 +47,15 @@ fn saturating_rounded_f64_to_i64(value: f64) -> i64 { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] pub struct UsdMicros(pub i64); +impl UsdMicros { + #[must_use] + pub fn from_usd(usd: f64) -> Self { + Self(saturating_rounded_f64_to_i64( + (usd * USD_MICROS_PER_USD_F64).round(), + )) + } +} + impl std::ops::Add for UsdMicros { type Output = Self; @@ -76,7 +85,7 @@ impl PricePerMTok { #[must_use] pub fn from_usd(usd: f64) -> Self { Self { - usd_micros: saturating_rounded_f64_to_i64((usd * USD_MICROS_PER_USD_F64).round()), + usd_micros: UsdMicros::from_usd(usd).0, } } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 283435092..4b5bb204e 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1485,6 +1485,7 @@ mod runs { speed: None, }, billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: None, @@ -1554,6 +1555,7 @@ mod runs { speed: None, }, billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: None, diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs index 8304bf1ec..5b9d36270 100644 --- a/lib/crates/fabro-server/src/server/handler/pair.rs +++ b/lib/crates/fabro-server/src/server/handler/pair.rs @@ -887,6 +887,7 @@ mod tests { speed: None, }, billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: None, @@ -919,6 +920,7 @@ mod tests { speed: None, }, billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: None, diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index f7fed5606..7314693dd 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -4134,6 +4134,8 @@ fn context_window_event( speed: None, }, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, tool_call_count: 0, context_window: Some(context_window), }, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index ec67aa4f0..4f8d55dd0 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -3788,6 +3788,7 @@ mod tests { text: "assistant text".to_string(), model: billed_usage().model().clone(), billing, + cost_source: None, tool_call_count: 0, visit: 1, message: None, diff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs index 64b2ede3d..d61ccfb14 100644 --- a/lib/crates/fabro-types/src/run_event/agent.rs +++ b/lib/crates/fabro-types/src/run_event/agent.rs @@ -1,4 +1,4 @@ -use fabro_model::{ReasoningEffort, Speed}; +use fabro_model::{CostSource, ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use serde_json::Value; use strum::{Display, EnumString, IntoStaticStr}; @@ -121,6 +121,9 @@ pub struct AgentMessageProps { pub text: String, pub model: ModelRef, pub billing: BilledTokenCounts, + /// Provenance of the optional total in `billing`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_source: Option, pub tool_call_count: usize, pub visit: u32, /// Canonical replay-authoritative transcript message. Present on events @@ -403,6 +406,7 @@ mod tests { }); let props: AgentMessageProps = serde_json::from_value(v).unwrap(); assert_eq!(props.text, "hello"); + assert!(props.cost_source.is_none()); assert!(props.message.is_none()); assert!(props.context_window.is_none()); } @@ -416,6 +420,7 @@ mod tests { text: "ok".to_string(), model: sample_model_ref(), billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: Some(msg.clone()), diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index e76558f3e..cb1c38b81 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -2139,6 +2139,7 @@ mod tests { speed: None, }, billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: None, @@ -2191,6 +2192,7 @@ mod tests { speed: None, }, billing: BilledTokenCounts::default(), + cost_source: None, tool_call_count: 0, visit: 1, message: None, diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 5f0fc9959..b00ba0251 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -3,6 +3,7 @@ use ::fabro_types::{ }; use chrono::Utc; use fabro_agent::{AgentEvent, SandboxEvent, SkillActivationSource}; +use fabro_model::UsdMicros; use uuid::Uuid; use super::Event; @@ -611,14 +612,18 @@ fn event_body_from_event(event: &Event) -> EventBody { text, model, usage, + cost_usd, + cost_source, tool_call_count, context_window, } => { - let billing = billed_token_counts_from_llm(usage); + let mut billing = billed_token_counts_from_llm(usage); + billing.total_usd_micros = cost_usd.map(|cost| UsdMicros::from_usd(cost).0); EventBody::AgentMessage(fabro_types::AgentMessageProps { text: text.clone(), model: model.clone(), billing, + cost_source: *cost_source, tool_call_count: *tool_call_count, visit: *visit, message: None, @@ -2116,6 +2121,8 @@ mod tests { speed: None, }, usage: LlmTokenCounts::default(), + cost_usd: None, + cost_source: None, tool_call_count: 0, context_window: None, }, @@ -2148,6 +2155,8 @@ mod tests { output_tokens: 34, ..LlmTokenCounts::default() }, + cost_usd: None, + cost_source: None, tool_call_count: 0, context_window: None, }, @@ -2166,6 +2175,43 @@ mod tests { assert_eq!(message.billing.total_usd_micros, None); } + #[test] + fn agent_assistant_message_preserves_provider_cost() { + let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { + stage: "code".to_string(), + visit: 1, + event: AgentEvent::AssistantMessage { + text: "ok".to_string(), + model: ModelRef { + provider: ProviderId::new("openrouter"), + model_id: "openai/gpt-5.4".to_string(), + speed: None, + }, + usage: LlmTokenCounts { + input_tokens: 12, + output_tokens: 34, + ..LlmTokenCounts::default() + }, + cost_usd: Some(0.125), + cost_source: Some(fabro_model::CostSource::Authoritative), + tool_call_count: 0, + context_window: None, + }, + session_id: Some("ses_agent".to_string()), + parent_session_id: None, + tool_call_id: None, + }); + + let EventBody::AgentMessage(message) = stored.body else { + panic!("expected agent message body"); + }; + assert_eq!(message.billing.total_usd_micros, Some(125_000)); + assert_eq!( + message.cost_source, + Some(fabro_model::CostSource::Authoritative) + ); + } + #[test] fn agent_assistant_message_copies_context_window_to_props() { let context_window = ::fabro_types::StageContextWindowProjection { @@ -2196,6 +2242,8 @@ mod tests { speed: None, }, usage: LlmTokenCounts::default(), + cost_usd: None, + cost_source: None, tool_call_count: 0, context_window: Some(context_window), }, diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index c1f27a7a3..feb5cc4f5 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -20,7 +20,7 @@ use fabro_llm::types::{ use fabro_mcp::config::McpServerSettings; #[cfg(test)] use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ModelRef, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ModelRef, ProviderId, UsdMicros}; use fabro_types::settings::run::RunModelControls; use fabro_types::{PermissionLevel, RunId, SessionCapability, StageId, StageTiming}; use serde::de::DeserializeOwned; @@ -40,7 +40,7 @@ use crate::context::WorkflowContext; use crate::context::keys::Fidelity; use crate::error::Error; use crate::event::{Emitter, Event, StageScope}; -use crate::outcome::billed_model_usage_from_llm; +use crate::outcome::billed_model_usage_from_llm_with_cost; use crate::services::FabroRunToolServices; use crate::steering_hub::{ActiveControlHandle, SteeringHub}; @@ -601,6 +601,12 @@ struct OneShotCompletion { model: ModelRef, } +fn add_cost(total: &mut Option, cost: Option) { + if let Some(cost) = cost { + *total.get_or_insert_default() += cost; + } +} + impl AgentApiBackend { #[must_use] pub fn new( @@ -1058,6 +1064,7 @@ impl CodergenBackend for AgentApiBackend { .map(structured_output::prompt_response_format); let mut repair_attempts = 0_i64; let mut total_usage = TokenCounts::default(); + let mut total_cost = None; let mut inference_duration = Duration::ZERO; loop { @@ -1093,6 +1100,10 @@ impl CodergenBackend for AgentApiBackend { inference_duration = inference_duration.saturating_add(inference_start.elapsed()); let completion = completion_result?; total_usage += completion.response.usage.clone(); + add_cost( + &mut total_cost, + completion.response.cost_usd.map(UsdMicros::from_usd), + ); let response_text = completion.response.text(); let validation_error = if let Some(schema) = &output_schema { @@ -1116,10 +1127,11 @@ impl CodergenBackend for AgentApiBackend { continue; } - let stage_usage = billed_model_usage_from_llm( + let stage_usage = billed_model_usage_from_llm_with_cost( self.catalog.as_ref(), &completion.model, &total_usage, + total_cost, )?; return Ok(CodergenResult::Text { @@ -1214,6 +1226,7 @@ impl CodergenBackend for AgentApiBackend { ); let mut total_usage = TokenCounts::default(); + let mut total_cost = None; let mut inference_duration = Duration::ZERO; let mut tool_duration = Duration::ZERO; @@ -1276,6 +1289,7 @@ impl CodergenBackend for AgentApiBackend { tool_duration = tool_duration.saturating_add(timing.tool); if process_result.is_ok() { total_usage += session.last_input_usage(); + add_cost(&mut total_cost, session.last_input_cost()); } process_result } @@ -1411,6 +1425,7 @@ impl CodergenBackend for AgentApiBackend { match process_result { Ok(()) => { total_usage += session.last_input_usage(); + add_cost(&mut total_cost, session.last_input_cost()); succeeded = true; break; } @@ -1482,6 +1497,7 @@ impl CodergenBackend for AgentApiBackend { match repair_result { Ok(()) => { total_usage += session.last_input_usage(); + add_cost(&mut total_cost, session.last_input_cost()); repair_attempts += 1; response = last_assistant_response(&session); } @@ -1509,7 +1525,7 @@ impl CodergenBackend for AgentApiBackend { } let billing_controls = self.resolve_effective_request_controls(node)?; - let stage_usage = billed_model_usage_from_llm( + let stage_usage = billed_model_usage_from_llm_with_cost( self.catalog.as_ref(), &ModelRef { provider: session.provider_id(), @@ -1517,6 +1533,7 @@ impl CodergenBackend for AgentApiBackend { speed: billing_controls.speed, }, &total_usage, + total_cost, )?; // Collect files_touched from the shared tracking state. @@ -2778,7 +2795,11 @@ reasoning = false .body_excludes(r#""role":"assistant""#); then.status(200) .header("content-type", "application/json") - .json_body(chat_completion_response("not json", 10, 1)); + .json_body({ + let mut response = chat_completion_response("not json", 10, 1); + response["usage"]["cost"] = serde_json::json!(0.04); + response + }); }); let repair = server.mock(|when, then| { when.method(POST) @@ -2789,7 +2810,11 @@ reasoning = false .body_includes("output_schema"); then.status(200) .header("content-type", "application/json") - .json_body(chat_completion_response(r#"{"passed":true}"#, 11, 2)); + .json_body({ + let mut response = chat_completion_response(r#"{"passed":true}"#, 11, 2); + response["usage"]["cost"] = serde_json::json!(0.06); + response + }); }); let backend = mock_api_backend(&server); let mut node = Node::new("audit"); @@ -2826,6 +2851,7 @@ reasoning = false let usage = usage.expect("usage should be aggregated"); assert_eq!(usage.tokens().input_tokens, 21); assert_eq!(usage.tokens().output_tokens, 3); + assert_eq!(usage.total_usd_micros, Some(100_000)); } #[tokio::test] diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/crates/fabro-workflow/src/outcome.rs index 532d26860..2b84f7bc5 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/fabro-workflow/src/outcome.rs @@ -3,7 +3,7 @@ pub use fabro_core::outcome::{ }; use fabro_llm::types::TokenCounts as LlmTokenCounts; use fabro_model::{ - BilledTokenCounts, Catalog, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, + BilledTokenCounts, Catalog, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, UsdMicros, }; pub use fabro_types::BilledModelUsage; @@ -39,6 +39,19 @@ pub fn billed_model_usage_from_llm( }) } +pub fn billed_model_usage_from_llm_with_cost( + catalog: &Catalog, + model: &ModelRef, + usage: &LlmTokenCounts, + total_cost: Option, +) -> Result { + let mut billed = billed_model_usage_from_llm(catalog, model, usage)?; + if let Some(total_cost) = total_cost { + billed.total_usd_micros = Some(total_cost.0); + } + Ok(billed) +} + #[must_use] pub fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { let tokens = token_counts_from_llm_usage(usage); @@ -149,9 +162,9 @@ fn token_counts_from_llm_usage(usage: &LlmTokenCounts) -> TokenCounts { mod tests { use fabro_llm::types::TokenCounts; use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ModelRef, ProviderId, Speed}; + use fabro_model::{Catalog, ModelRef, ProviderId, Speed, UsdMicros}; - use super::{OutcomeExt, billed_model_usage_from_llm}; + use super::{OutcomeExt, billed_model_usage_from_llm, billed_model_usage_from_llm_with_cost}; fn model_ref(provider: ProviderId, model_id: &str, speed: Option) -> ModelRef { ModelRef { @@ -182,6 +195,24 @@ mod tests { assert_eq!(billed.tokens().reasoning_tokens, 25_000); } + #[test] + fn response_cost_overrides_catalog_estimate() { + let usage = TokenCounts { + input_tokens: 11, + output_tokens: 7, + ..TokenCounts::default() + }; + let billed = billed_model_usage_from_llm_with_cost( + Catalog::builtin(), + &model_ref(ProviderId::openai(), "gpt-5.4", None), + &usage, + Some(UsdMicros(125_000)), + ) + .unwrap(); + + assert_eq!(billed.total_usd_micros, Some(125_000)); + } + #[test] fn retry_classify_marks_failed_outcome_with_retry_request() { let outcome = crate::outcome::Outcome::retry_classify("timeout"); From 4d2712b8db60c3cb860492a9f1f44e2340229d67 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 07:21:17 -0400 Subject: [PATCH 3/4] Consolidate provider-reported cost handling into fabro-model Replace the billed_model_usage_from_llm_with_cost wrapper with a with_reported_cost method on BilledModelUsage and BilledTokenCounts, and centralize the optional-cost fold as UsdMicros::accumulate so fabro-agent and fabro-workflow share one implementation. Co-Authored-By: Claude Fable 5 --- lib/crates/fabro-agent/src/session.rs | 4 +- lib/crates/fabro-model/src/billing.rs | 40 +++++++++++++++++++ .../fabro-workflow/src/event/convert.rs | 4 +- .../fabro-workflow/src/handler/llm/api.rs | 28 +++++-------- lib/crates/fabro-workflow/src/outcome.rs | 23 +++-------- 5 files changed, 59 insertions(+), 40 deletions(-) diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 93b2b5c7b..6541ddc6c 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -1727,9 +1727,7 @@ impl Session { &usage, )); *usage_accumulator += usage.clone(); - if let Some(cost_usd) = response.cost_usd { - *cost_accumulator.get_or_insert_default() += UsdMicros::from_usd(cost_usd); - } + UsdMicros::accumulate(cost_accumulator, response.cost_usd.map(UsdMicros::from_usd)); self.history.push(Message::Assistant { content: text.clone(), diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index 9754e8908..0df9fc824 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -54,6 +54,14 @@ impl UsdMicros { (usd * USD_MICROS_PER_USD_F64).round(), )) } + + /// Folds a cost into a running total that stays `None` until a cost is + /// observed (`None` means "no provider data", not $0). + pub fn accumulate(total: &mut Option, cost: Option) { + if let Some(cost) = cost { + *total.get_or_insert_default() += cost; + } + } } impl std::ops::Add for UsdMicros { @@ -337,6 +345,16 @@ impl BilledModelUsage { pub fn tokens(&self) -> &TokenCounts { &self.input.usage.tokens } + + /// Overrides the billed total with a provider-reported cost; `None` leaves + /// the catalog estimate in place. + #[must_use] + pub fn with_reported_cost(mut self, cost: Option) -> Self { + if let Some(cost) = cost { + self.total_usd_micros = Some(cost.0); + } + self + } } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -422,6 +440,16 @@ impl BilledTokenCounts { *self = Self::from_billed_usage(std::slice::from_ref(usage)); } + /// Overrides the billed total with a provider-reported cost; `None` leaves + /// any existing estimate in place. + #[must_use] + pub fn with_reported_cost(mut self, cost: Option) -> Self { + if let Some(cost) = cost { + self.total_usd_micros = Some(cost.0); + } + self + } + #[must_use] pub fn is_zero(&self) -> bool { self.input_tokens == 0 @@ -739,6 +767,18 @@ mod tests { } } + #[test] + fn usd_micros_accumulate_keeps_none_until_a_cost_is_observed() { + let mut total = None; + UsdMicros::accumulate(&mut total, None); + assert_eq!(total, None); + + UsdMicros::accumulate(&mut total, Some(UsdMicros(40_000))); + UsdMicros::accumulate(&mut total, None); + UsdMicros::accumulate(&mut total, Some(UsdMicros(60_000))); + assert_eq!(total, Some(UsdMicros(100_000))); + } + #[test] fn model_billing_policy_override_changes_the_billing_algorithm() { let catalog = catalog_from_toml( diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index b00ba0251..3238e153d 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -617,8 +617,8 @@ fn event_body_from_event(event: &Event) -> EventBody { tool_call_count, context_window, } => { - let mut billing = billed_token_counts_from_llm(usage); - billing.total_usd_micros = cost_usd.map(|cost| UsdMicros::from_usd(cost).0); + let billing = billed_token_counts_from_llm(usage) + .with_reported_cost(cost_usd.map(UsdMicros::from_usd)); EventBody::AgentMessage(fabro_types::AgentMessageProps { text: text.clone(), model: model.clone(), diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index feb5cc4f5..5eda3130f 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -40,7 +40,7 @@ use crate::context::WorkflowContext; use crate::context::keys::Fidelity; use crate::error::Error; use crate::event::{Emitter, Event, StageScope}; -use crate::outcome::billed_model_usage_from_llm_with_cost; +use crate::outcome::billed_model_usage_from_llm; use crate::services::FabroRunToolServices; use crate::steering_hub::{ActiveControlHandle, SteeringHub}; @@ -601,12 +601,6 @@ struct OneShotCompletion { model: ModelRef, } -fn add_cost(total: &mut Option, cost: Option) { - if let Some(cost) = cost { - *total.get_or_insert_default() += cost; - } -} - impl AgentApiBackend { #[must_use] pub fn new( @@ -1100,7 +1094,7 @@ impl CodergenBackend for AgentApiBackend { inference_duration = inference_duration.saturating_add(inference_start.elapsed()); let completion = completion_result?; total_usage += completion.response.usage.clone(); - add_cost( + UsdMicros::accumulate( &mut total_cost, completion.response.cost_usd.map(UsdMicros::from_usd), ); @@ -1127,12 +1121,12 @@ impl CodergenBackend for AgentApiBackend { continue; } - let stage_usage = billed_model_usage_from_llm_with_cost( + let stage_usage = billed_model_usage_from_llm( self.catalog.as_ref(), &completion.model, &total_usage, - total_cost, - )?; + )? + .with_reported_cost(total_cost); return Ok(CodergenResult::Text { text: response_text, @@ -1289,7 +1283,7 @@ impl CodergenBackend for AgentApiBackend { tool_duration = tool_duration.saturating_add(timing.tool); if process_result.is_ok() { total_usage += session.last_input_usage(); - add_cost(&mut total_cost, session.last_input_cost()); + UsdMicros::accumulate(&mut total_cost, session.last_input_cost()); } process_result } @@ -1425,7 +1419,7 @@ impl CodergenBackend for AgentApiBackend { match process_result { Ok(()) => { total_usage += session.last_input_usage(); - add_cost(&mut total_cost, session.last_input_cost()); + UsdMicros::accumulate(&mut total_cost, session.last_input_cost()); succeeded = true; break; } @@ -1497,7 +1491,7 @@ impl CodergenBackend for AgentApiBackend { match repair_result { Ok(()) => { total_usage += session.last_input_usage(); - add_cost(&mut total_cost, session.last_input_cost()); + UsdMicros::accumulate(&mut total_cost, session.last_input_cost()); repair_attempts += 1; response = last_assistant_response(&session); } @@ -1525,7 +1519,7 @@ impl CodergenBackend for AgentApiBackend { } let billing_controls = self.resolve_effective_request_controls(node)?; - let stage_usage = billed_model_usage_from_llm_with_cost( + let stage_usage = billed_model_usage_from_llm( self.catalog.as_ref(), &ModelRef { provider: session.provider_id(), @@ -1533,8 +1527,8 @@ impl CodergenBackend for AgentApiBackend { speed: billing_controls.speed, }, &total_usage, - total_cost, - )?; + )? + .with_reported_cost(total_cost); // Collect files_touched from the shared tracking state. let (files_touched, last_file_touched) = file_tracking_snapshot(&file_tracking); diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/crates/fabro-workflow/src/outcome.rs index 2b84f7bc5..853acd3a6 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/fabro-workflow/src/outcome.rs @@ -3,7 +3,7 @@ pub use fabro_core::outcome::{ }; use fabro_llm::types::TokenCounts as LlmTokenCounts; use fabro_model::{ - BilledTokenCounts, Catalog, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, UsdMicros, + BilledTokenCounts, Catalog, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, }; pub use fabro_types::BilledModelUsage; @@ -39,19 +39,6 @@ pub fn billed_model_usage_from_llm( }) } -pub fn billed_model_usage_from_llm_with_cost( - catalog: &Catalog, - model: &ModelRef, - usage: &LlmTokenCounts, - total_cost: Option, -) -> Result { - let mut billed = billed_model_usage_from_llm(catalog, model, usage)?; - if let Some(total_cost) = total_cost { - billed.total_usd_micros = Some(total_cost.0); - } - Ok(billed) -} - #[must_use] pub fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { let tokens = token_counts_from_llm_usage(usage); @@ -164,7 +151,7 @@ mod tests { use fabro_model::catalog::LlmCatalogSettings; use fabro_model::{Catalog, ModelRef, ProviderId, Speed, UsdMicros}; - use super::{OutcomeExt, billed_model_usage_from_llm, billed_model_usage_from_llm_with_cost}; + use super::{OutcomeExt, billed_model_usage_from_llm}; fn model_ref(provider: ProviderId, model_id: &str, speed: Option) -> ModelRef { ModelRef { @@ -202,13 +189,13 @@ mod tests { output_tokens: 7, ..TokenCounts::default() }; - let billed = billed_model_usage_from_llm_with_cost( + let billed = billed_model_usage_from_llm( Catalog::builtin(), &model_ref(ProviderId::openai(), "gpt-5.4", None), &usage, - Some(UsdMicros(125_000)), ) - .unwrap(); + .unwrap() + .with_reported_cost(Some(UsdMicros(125_000))); assert_eq!(billed.total_usd_micros, Some(125_000)); } From 08cef66ec8bae78e8419f7ead03df29ac19719f9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 23 Jul 2026 07:53:01 -0400 Subject: [PATCH 4/4] Saturate billing cost accumulation --- lib/crates/fabro-model/src/billing.rs | 90 ++++++++++++++----- .../fabro-server/src/server/handler/system.rs | 11 +-- lib/crates/fabro-server/src/server/tests.rs | 42 +++++++++ 3 files changed, 113 insertions(+), 30 deletions(-) diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index 0df9fc824..baa592cb5 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -68,13 +68,13 @@ impl std::ops::Add for UsdMicros { type Output = Self; fn add(self, rhs: Self) -> Self::Output { - Self(self.0 + rhs.0) + Self(self.0.saturating_add(rhs.0)) } } impl std::ops::AddAssign for UsdMicros { fn add_assign(&mut self, rhs: Self) { - self.0 += rhs.0; + *self = *self + rhs; } } @@ -84,6 +84,12 @@ impl std::iter::Sum for UsdMicros { } } +fn accumulate_optional_usd_micros(total: &mut Option, cost: Option) { + let mut typed_total = (*total).map(UsdMicros); + UsdMicros::accumulate(&mut typed_total, cost.map(UsdMicros)); + *total = typed_total.map(|value| value.0); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct PricePerMTok { pub usd_micros: i64, @@ -376,25 +382,21 @@ impl BilledTokenCounts { #[must_use] pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self { let mut tokens = TokenCounts::default(); - let mut total_usd_micros = 0_i64; - let mut has_total = false; + let mut total_usd_micros = None; for entry in billed { tokens += entry.input.usage.tokens.clone(); - if let Some(value) = entry.total_usd_micros { - total_usd_micros += value; - has_total = true; - } + accumulate_optional_usd_micros(&mut total_usd_micros, entry.total_usd_micros); } Self { - input_tokens: tokens.input_tokens, - output_tokens: tokens.output_tokens, - total_tokens: tokens.total_tokens(), - reasoning_tokens: tokens.reasoning_tokens, - cache_read_tokens: tokens.cache_read_tokens, + input_tokens: tokens.input_tokens, + output_tokens: tokens.output_tokens, + total_tokens: tokens.total_tokens(), + reasoning_tokens: tokens.reasoning_tokens, + cache_read_tokens: tokens.cache_read_tokens, cache_write_tokens: tokens.cache_write_tokens, - total_usd_micros: has_total.then_some(total_usd_micros), + total_usd_micros, } } @@ -418,9 +420,7 @@ impl BilledTokenCounts { self.reasoning_tokens += source.reasoning_tokens; self.cache_read_tokens += source.cache_read_tokens; self.cache_write_tokens += source.cache_write_tokens; - if let Some(value) = source.total_usd_micros { - *self.total_usd_micros.get_or_insert(0) += value; - } + accumulate_optional_usd_micros(&mut self.total_usd_micros, source.total_usd_micros); } pub fn add_billed_usage(&mut self, usage: &BilledModelUsage) { @@ -431,9 +431,7 @@ impl BilledTokenCounts { self.cache_read_tokens += tokens.cache_read_tokens; self.cache_write_tokens += tokens.cache_write_tokens; self.total_tokens += tokens.total_tokens(); - if let Some(value) = usage.total_usd_micros { - *self.total_usd_micros.get_or_insert(0) += value; - } + accumulate_optional_usd_micros(&mut self.total_usd_micros, usage.total_usd_micros); } pub fn replace_with_billed_usage(&mut self, usage: &BilledModelUsage) { @@ -779,6 +777,33 @@ mod tests { assert_eq!(total, Some(UsdMicros(100_000))); } + #[test] + fn usd_micros_arithmetic_saturates_at_i64_bounds() { + assert_eq!(UsdMicros(i64::MAX) + UsdMicros(1), UsdMicros(i64::MAX)); + + let mut minimum = UsdMicros(i64::MIN); + minimum += UsdMicros(-1); + assert_eq!(minimum, UsdMicros(i64::MIN)); + + assert_eq!( + [UsdMicros(i64::MAX), UsdMicros(1)] + .into_iter() + .sum::(), + UsdMicros(i64::MAX) + ); + } + + #[test] + fn usd_micros_accumulate_saturates_at_i64_bounds() { + let mut maximum = Some(UsdMicros(i64::MAX)); + UsdMicros::accumulate(&mut maximum, Some(UsdMicros(1))); + assert_eq!(maximum, Some(UsdMicros(i64::MAX))); + + let mut minimum = Some(UsdMicros(i64::MIN)); + UsdMicros::accumulate(&mut minimum, Some(UsdMicros(-1))); + assert_eq!(minimum, Some(UsdMicros(i64::MIN))); + } + #[test] fn model_billing_policy_override_changes_the_billing_algorithm() { let catalog = catalog_from_toml( @@ -912,6 +937,31 @@ cache_input_cost_per_mtok = 0.3 assert_eq!(counts.total_usd_micros, Some(150)); } + #[test] + fn billed_token_counts_cost_rollups_saturate() { + let billed = [ + billed_usage(0, 0, Some(i64::MAX)), + billed_usage(0, 0, Some(1)), + ]; + assert_eq!( + BilledTokenCounts::from_billed_usage(&billed).total_usd_micros, + Some(i64::MAX) + ); + + let mut counts = BilledTokenCounts { + total_usd_micros: Some(i64::MAX), + ..BilledTokenCounts::default() + }; + counts.add_counts(&BilledTokenCounts { + total_usd_micros: Some(1), + ..BilledTokenCounts::default() + }); + assert_eq!(counts.total_usd_micros, Some(i64::MAX)); + + counts.add_billed_usage(&billed_usage(0, 0, Some(1))); + assert_eq!(counts.total_usd_micros, Some(i64::MAX)); + } + #[test] fn billed_token_counts_replace_with_billed_usage_discards_previous_values() { let mut counts = BilledTokenCounts { diff --git a/lib/crates/fabro-server/src/server/handler/system.rs b/lib/crates/fabro-server/src/server/handler/system.rs index ba387b1ab..d26e445a6 100644 --- a/lib/crates/fabro-server/src/server/handler/system.rs +++ b/lib/crates/fabro-server/src/server/handler/system.rs @@ -718,16 +718,7 @@ async fn get_aggregate_billing( agg.by_model .values() .fold(BilledTokenCounts::default(), |mut acc, totals| { - let billing = &totals.billing; - acc.input_tokens += billing.input_tokens; - acc.output_tokens += billing.output_tokens; - acc.reasoning_tokens += billing.reasoning_tokens; - acc.cache_read_tokens += billing.cache_read_tokens; - acc.cache_write_tokens += billing.cache_write_tokens; - acc.total_tokens += billing.total_tokens; - if let Some(value) = billing.total_usd_micros { - *acc.total_usd_micros.get_or_insert(0) += value; - } + acc.add_counts(&totals.billing); acc }); let response = AggregateBilling { diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 7314693dd..3d412d5a4 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -13551,6 +13551,48 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { assert_eq!(fast["billing"]["input_tokens"], 20); } +#[tokio::test] +async fn get_aggregate_billing_saturates_total_cost_across_models() { + let state = test_app_state(); + { + let mut agg = state + .aggregate_billing + .lock() + .expect("aggregate billing lock"); + for (model_id, total_usd_micros) in [("maximum", i64::MAX), ("one", 1)] { + agg.by_model.insert( + ModelRef { + provider: ProviderId::openai(), + model_id: model_id.to_string(), + speed: None, + }, + ModelBillingTotals { + stages: 1, + billing: BilledTokenCounts { + total_usd_micros: Some(total_usd_micros), + ..BilledTokenCounts::default() + }, + }, + ); + } + } + let app = crate::test_support::build_test_router(Arc::clone(&state)); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(api("/billing")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::OK).await; + + assert_eq!(body["totals"]["total_usd_micros"].as_i64(), Some(i64::MAX)); +} + #[test] fn aggregate_billing_counts_projection_rollup_usage_visits() { let mut accumulator = BillingAccumulator::default();