From 480ff56aba53af71d9f5892e5426216443783bdd Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Thu, 28 May 2026 15:06:30 -0400 Subject: [PATCH] feat(llm): add Response.cost_usd and CostSource across all adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new fields to the unified completion Response: - cost_usd: Option — USD cost for the completion - cost_source: Option — Authoritative | Estimated A new fabro_llm::cost::estimate_cost_usd helper centralizes the catalog math (tokens × catalog price → USD micros → f64). Every adapter response path (Anthropic, OpenAI, Gemini, openai_chat) calls the helper when a catalog is available, producing Estimated costs. Adapters with authoritative provider-side pricing (e.g. OpenRouter, in a later commit) will bypass the helper and set Authoritative. The openai_chat streaming path now plumbs catalog + speed through StreamState so finish_events can call the helper before assembling the final Response. Public API surface: CompletionResponse in fabro-api.yaml is extended with cost_usd and cost_source. Progenitor + the TS client are regenerated; apps/fabro-web typechecks clean since both fields are optional. 42 Response constructor sites updated: - 5 live adapter sites call estimate_cost_usd - ~32 synthesized/fallback/test sites set (None, None) explicitly - 5 test fixtures in types.rs set (None, None) 397 lib tests pass (was 392, +5 new helper unit tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/public/api-reference/fabro-api.yaml | 16 ++ lib/crates/fabro-agent/src/apply_patch.rs | 4 + lib/crates/fabro-agent/src/test_support.rs | 6 + lib/crates/fabro-cli/src/commands/exec.rs | 2 + lib/crates/fabro-llm/src/client.rs | 4 + lib/crates/fabro-llm/src/cost.rs | 211 ++++++++++++++++++ lib/crates/fabro-llm/src/generate.rs | 38 ++++ lib/crates/fabro-llm/src/lib.rs | 1 + lib/crates/fabro-llm/src/model_test.rs | 2 + .../fabro-llm/src/providers/anthropic.rs | 71 +++++- .../fabro-llm/src/providers/fabro_server.rs | 6 + lib/crates/fabro-llm/src/providers/gemini.rs | 64 +++++- lib/crates/fabro-llm/src/providers/openai.rs | 35 ++- .../src/providers/openai_chat/mod.rs | 21 +- .../src/providers/openai_chat/response.rs | 9 + .../src/providers/openai_chat/stream.rs | 58 ++++- .../src/providers/openai_compatible.rs | 2 +- lib/crates/fabro-llm/src/types.rs | 41 ++++ .../fabro-server/src/run_title_generation.rs | 2 + lib/crates/fabro-server/src/server.rs | 28 +-- .../src/server/handler/completions.rs | 22 +- .../src/pipeline/pull_request.rs | 4 + .../src/models/completion-response.ts | 15 ++ 23 files changed, 612 insertions(+), 50 deletions(-) create mode 100644 lib/crates/fabro-llm/src/cost.rs diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 103ab4cad..3a6eb26dd 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -7410,6 +7410,22 @@ components: $ref: "#/components/schemas/CompletionUsage" output: description: Parsed structured output when schema was provided. + cost_usd: + type: number + format: double + nullable: true + description: | + Total USD cost for the completion. Populated when the provider + returns an authoritative billing figure (e.g. OpenRouter) or + when the model has catalog pricing. + cost_source: + type: string + enum: [authoritative, estimated] + nullable: true + description: | + Whether `cost_usd` came from the provider's billing data + (`authoritative`) or was computed from catalog prices + (`estimated`). PaginatedSavedQueryList: description: Paginated list of saved queries. diff --git a/lib/crates/fabro-agent/src/apply_patch.rs b/lib/crates/fabro-agent/src/apply_patch.rs index 331accdd5..334e9a4d2 100644 --- a/lib/crates/fabro-agent/src/apply_patch.rs +++ b/lib/crates/fabro-agent/src/apply_patch.rs @@ -1747,6 +1747,8 @@ def farewell(name): }, finish_reason: FinishReason::ToolCalls, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1823,6 +1825,8 @@ def farewell(name): }, finish_reason: FinishReason::ToolCalls, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-agent/src/test_support.rs b/lib/crates/fabro-agent/src/test_support.rs index 51aac4233..370daa97b 100644 --- a/lib/crates/fabro-agent/src/test_support.rs +++ b/lib/crates/fabro-agent/src/test_support.rs @@ -184,6 +184,8 @@ pub fn text_response(text: &str) -> Response { output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -260,6 +262,8 @@ pub fn tool_call_response( output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -387,6 +391,8 @@ pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 0e118d2a0..872c7ce02 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -224,6 +224,8 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter { output_tokens: server_response.usage.output_tokens, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 7ad3153d4..eaddb085c 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -584,6 +584,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -605,6 +607,8 @@ mod tests { message: Message::assistant(&text), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-llm/src/cost.rs b/lib/crates/fabro-llm/src/cost.rs new file mode 100644 index 000000000..db8e7b49b --- /dev/null +++ b/lib/crates/fabro-llm/src/cost.rs @@ -0,0 +1,211 @@ +//! Catalog-derived cost estimation for completion responses. + +use fabro_model::billing::{ModelRef, Speed, TokenCounts}; +use fabro_model::{Catalog, ProviderId}; + +use crate::types::CostSource; + +/// Estimate the USD cost of a completion from the catalog's per-token +/// pricing for the model. Returns `(None, None)` if the catalog is absent, +/// the model is not in the catalog, or the model has no pricing. +/// +/// Used by adapters that don't receive an authoritative cost from the +/// provider. Adapters with authoritative pricing (e.g. OpenRouter +/// `usage.cost`) bypass this and set `Authoritative` directly. +#[must_use] +pub fn estimate_cost_usd( + catalog: Option<&Catalog>, + provider: &str, + model: &str, + tokens: &TokenCounts, + speed: Option, +) -> (Option, Option) { + let Some(catalog) = catalog else { + return (None, None); + }; + let model_ref = ModelRef { + provider: ProviderId::from(provider), + model_id: model.to_string(), + speed, + }; + let Some(micros) = catalog.price_tokens(&model_ref, tokens) else { + return (None, None); + }; + #[allow( + clippy::cast_precision_loss, + reason = "micros fit comfortably in f64 for any realistic completion cost" + )] + let usd = micros as f64 / 1_000_000.0; + (Some(usd), Some(CostSource::Estimated)) +} + +#[cfg(test)] +mod tests { + use fabro_model::catalog::LlmCatalogSettings; + + use super::*; + + fn catalog_with_openai_model() -> Catalog { + let settings: LlmCatalogSettings = toml::from_str( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" + +[models."gpt-test"] +provider = "openai" +display_name = "GPT Test" +family = "gpt" +default = true + +[models."gpt-test".limits] +context_window = 200000 +max_output = 4096 + +[models."gpt-test".features] +tools = true +vision = false +reasoning = false + +[models."gpt-test".costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 2.0 +"#, + ) + .unwrap(); + Catalog::from_settings(&settings).unwrap() + } + + fn catalog_without_costs() -> Catalog { + let settings: LlmCatalogSettings = toml::from_str( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" + +[models."gpt-no-cost"] +provider = "openai" +display_name = "GPT No Cost" +family = "gpt" +default = true + +[models."gpt-no-cost".limits] +context_window = 200000 +max_output = 4096 + +[models."gpt-no-cost".features] +tools = true +vision = false +reasoning = false +"#, + ) + .unwrap(); + Catalog::from_settings(&settings).unwrap() + } + + #[test] + fn returns_none_when_catalog_is_none() { + let tokens = TokenCounts { + input_tokens: 1000, + output_tokens: 500, + ..TokenCounts::default() + }; + let (cost, source) = estimate_cost_usd(None, "openai", "gpt-test", &tokens, None); + assert_eq!(cost, None); + assert_eq!(source, None); + } + + #[test] + fn returns_estimated_when_model_priced() { + let catalog = catalog_with_openai_model(); + let tokens = TokenCounts { + input_tokens: 1_000_000, // 1M tokens at $1/Mtok = $1.00 + output_tokens: 500_000, // 500k tokens at $2/Mtok = $1.00 + ..TokenCounts::default() + }; + let (cost, source) = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None); + assert_eq!(source, Some(CostSource::Estimated)); + let cost = cost.expect("cost should be Some"); + // 1.00 + 1.00 = 2.00 USD + assert!((cost - 2.0).abs() < 1e-9, "expected ~$2.00, got {cost}"); + } + + #[test] + fn returns_none_when_model_missing_from_catalog() { + let catalog = catalog_with_openai_model(); + let tokens = TokenCounts { + input_tokens: 1000, + output_tokens: 500, + ..TokenCounts::default() + }; + let (cost, source) = + estimate_cost_usd(Some(&catalog), "openai", "nonexistent-model", &tokens, None); + assert_eq!(cost, None); + assert_eq!(source, None); + } + + #[test] + fn returns_none_when_model_has_no_pricing() { + let catalog = catalog_without_costs(); + let tokens = TokenCounts { + input_tokens: 1000, + output_tokens: 500, + ..TokenCounts::default() + }; + let (cost, source) = + estimate_cost_usd(Some(&catalog), "openai", "gpt-no-cost", &tokens, None); + assert_eq!(cost, None); + assert_eq!(source, None); + } + + #[test] + fn micros_to_usd_conversion_is_exact_for_integer_amounts() { + // 1_500_000 micros = $1.50 exactly (representable as f64). + // Configure pricing so that price_tokens returns exactly 1_500_000. + // input_cost_per_mtok = 1.5 USD; 1M input tokens with no output yields + // 1_500_000 micros. + let settings: LlmCatalogSettings = toml::from_str( + r#" +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +agent_profile = "openai" + +[models."gpt-exact"] +provider = "openai" +display_name = "GPT Exact" +family = "gpt" +default = true + +[models."gpt-exact".limits] +context_window = 200000 +max_output = 4096 + +[models."gpt-exact".features] +tools = true +vision = false +reasoning = false + +[models."gpt-exact".costs] +input_cost_per_mtok = 1.5 +output_cost_per_mtok = 0.0 +"#, + ) + .unwrap(); + let catalog = Catalog::from_settings(&settings).unwrap(); + let tokens = TokenCounts { + input_tokens: 1_000_000, + output_tokens: 0, + ..TokenCounts::default() + }; + let (cost, _source) = + estimate_cost_usd(Some(&catalog), "openai", "gpt-exact", &tokens, None); + let cost = cost.expect("cost should be Some"); + assert!( + (cost - 1.5).abs() < f64::EPSILON, + "expected $1.50 exact, got {cost}" + ); + } +} diff --git a/lib/crates/fabro-llm/src/generate.rs b/lib/crates/fabro-llm/src/generate.rs index 4e93a63f3..71f0255bf 100644 --- a/lib/crates/fabro-llm/src/generate.rs +++ b/lib/crates/fabro-llm/src/generate.rs @@ -1135,6 +1135,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1163,6 +1165,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1271,6 +1275,8 @@ mod tests { output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1288,6 +1294,8 @@ mod tests { output_tokens: 10, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1357,6 +1365,8 @@ mod tests { output_tokens: 2, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1562,6 +1572,8 @@ mod tests { message: Message::assistant(&self.full_text), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1593,6 +1605,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1772,6 +1786,8 @@ mod tests { }, finish_reason: FinishReason::ToolCalls, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1938,6 +1954,8 @@ mod tests { message: Message::assistant("fallback"), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1967,6 +1985,8 @@ mod tests { output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1994,6 +2014,8 @@ mod tests { output_tokens: 10, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2110,6 +2132,8 @@ mod tests { output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2273,6 +2297,8 @@ mod tests { message: Message::assistant("fallback"), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2304,6 +2330,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2386,6 +2414,8 @@ mod tests { message: Message::assistant("fallback"), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2402,6 +2432,8 @@ mod tests { message: Message::assistant(text), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2484,6 +2516,8 @@ mod tests { message: Message::assistant("fallback"), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2509,6 +2543,8 @@ mod tests { }, finish_reason: FinishReason::ToolCalls, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -2533,6 +2569,8 @@ mod tests { message: Message::assistant(text), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-llm/src/lib.rs b/lib/crates/fabro-llm/src/lib.rs index 63066b622..9f32a61ca 100644 --- a/lib/crates/fabro-llm/src/lib.rs +++ b/lib/crates/fabro-llm/src/lib.rs @@ -1,5 +1,6 @@ pub mod adapter_registry; pub mod client; +pub mod cost; pub mod error; pub mod generate; pub mod middleware; diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index e177901a2..e8796f28a 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/fabro-llm/src/model_test.rs @@ -208,6 +208,8 @@ mod tests { message: Message::assistant(text), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 75bf0ea55..390156043 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -5,6 +5,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_model::{Catalog, ReasoningEffortFeature}; use futures::stream; +use crate::cost::estimate_cost_usd; use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code}; use crate::provider::{ProviderAdapter, StreamEventStream}; use crate::providers::common::{ @@ -747,10 +748,27 @@ struct StreamAccumulator { current_tool_args: String, /// Rate limit info parsed from the initial HTTP response headers. rate_limit: Option, + /// Provider name to attribute to the final Response and to use for + /// catalog cost estimation. + provider_name: String, + /// Optional catalog used to estimate `cost_usd` on the final Response. + catalog: Option>, + /// Speed setting forwarded from the request, used by catalog pricing. + speed: Option, } impl StreamAccumulator { + #[cfg(test)] fn new(rate_limit: Option) -> Self { + Self::new_with_pricing(rate_limit, "anthropic".to_string(), None, None) + } + + fn new_with_pricing( + rate_limit: Option, + provider_name: String, + catalog: Option>, + speed: Option, + ) -> Self { Self { id: String::new(), model: String::new(), @@ -762,6 +780,9 @@ impl StreamAccumulator { current_thinking: String::new(), current_tool_args: String::new(), rate_limit, + provider_name, + catalog, + speed, } } @@ -769,21 +790,30 @@ impl StreamAccumulator { /// parts. fn take_response(&mut self) -> Response { let content_parts = std::mem::take(&mut self.content_parts); + let (cost_usd, cost_source) = estimate_cost_usd( + self.catalog.as_deref(), + &self.provider_name, + &self.model, + &self.usage, + self.speed, + ); Response { - id: self.id.clone(), - model: self.model.clone(), - provider: "anthropic".to_string(), - message: Message { + id: self.id.clone(), + model: self.model.clone(), + provider: self.provider_name.clone(), + message: Message { role: Role::Assistant, content: content_parts, name: None, tool_call_id: None, }, finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), + usage: self.usage.clone(), + cost_usd, + cost_source, + raw: None, + warnings: vec![], + rate_limit: self.rate_limit.clone(), } } } @@ -1111,10 +1141,17 @@ impl SseReaderState { json_schema_mode: bool, stream_read_timeout: Option, provider_name: String, + catalog: Option>, + speed: Option, ) -> Self { Self { line_reader: super::common::LineReader::new(http_resp, stream_read_timeout), - accumulator: StreamAccumulator::new(rate_limit), + accumulator: StreamAccumulator::new_with_pricing( + rate_limit, + provider_name.clone(), + catalog, + speed, + ), pending_events: std::collections::VecDeque::new(), json_schema_mode, provider_name, @@ -1471,6 +1508,14 @@ impl ProviderAdapter for Adapter { } else { map_finish_reason(api_resp.stop_reason.as_deref()) }; + let usage = token_counts_from_api_usage(&api_resp.usage); + let (cost_usd, cost_source) = estimate_cost_usd( + self.catalog.as_deref(), + &self.provider_name, + &api_resp.model, + &usage, + request.speed, + ); Ok(Response { id: api_resp.id, model: api_resp.model, @@ -1482,7 +1527,9 @@ impl ProviderAdapter for Adapter { tool_call_id: None, }, finish_reason, - usage: token_counts_from_api_usage(&api_resp.usage), + usage, + cost_usd, + cost_source, raw: serde_json::from_str(&body).ok(), warnings: vec![], rate_limit: parse_rate_limit_headers(&headers), @@ -1527,6 +1574,8 @@ impl ProviderAdapter for Adapter { json_schema_mode, stream_read_timeout, self.provider_name.clone(), + self.catalog.clone(), + request.speed, ), |mut state| async move { loop { @@ -2430,6 +2479,8 @@ reasoning = true }, finish_reason: FinishReason::ToolCalls, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-llm/src/providers/fabro_server.rs b/lib/crates/fabro-llm/src/providers/fabro_server.rs index a737ff8f1..b815af423 100644 --- a/lib/crates/fabro-llm/src/providers/fabro_server.rs +++ b/lib/crates/fabro-llm/src/providers/fabro_server.rs @@ -148,6 +148,12 @@ impl ProviderAdapter for Adapter { output_tokens: server_resp.usage.output_tokens, ..Default::default() }, + // The remote fabro-server has its own catalog and may report + // cost on its wire response. Until that wire format carries + // `cost_usd`/`cost_source`, leave these `None` rather than + // pretending to estimate locally. + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-llm/src/providers/gemini.rs b/lib/crates/fabro-llm/src/providers/gemini.rs index af4fc4860..1133165e9 100644 --- a/lib/crates/fabro-llm/src/providers/gemini.rs +++ b/lib/crates/fabro-llm/src/providers/gemini.rs @@ -6,6 +6,7 @@ use fabro_http::HeaderMap; use fabro_model::Catalog; use futures::stream; +use crate::cost::estimate_cost_usd; use crate::error::{ Error, ProviderErrorDetail, ProviderErrorKind, error_from_grpc_status, error_from_status_code, }; @@ -642,9 +643,20 @@ fn process_sse_stream( model: String, rate_limit: Option, stream_read_timeout: Option, + provider_name: String, + catalog: Option>, + speed: Option, ) -> StreamEventStream { Box::pin(stream::unfold( - SseStreamState::new(http_resp, model, rate_limit, stream_read_timeout), + SseStreamState::new( + http_resp, + model, + rate_limit, + stream_read_timeout, + provider_name, + catalog, + speed, + ), |mut state| async move { // If we have buffered events, yield them first. if let Some(event) = state.pending_events.pop_front() { @@ -750,6 +762,13 @@ struct SseStreamState { finished: bool, /// Rate limit info parsed from HTTP response headers. rate_limit: Option, + /// Provider name to attribute to the final Response and use for catalog + /// cost estimation. + provider_name: String, + /// Optional catalog used to estimate `cost_usd` on the final Response. + catalog: Option>, + /// Speed setting forwarded from the request, used by catalog pricing. + speed: Option, } impl SseStreamState { @@ -758,6 +777,9 @@ impl SseStreamState { model: String, rate_limit: Option, stream_read_timeout: Option, + provider_name: String, + catalog: Option>, + speed: Option, ) -> Self { Self { line_reader: super::common::LineReader::new(http_resp, stream_read_timeout), @@ -774,6 +796,9 @@ impl SseStreamState { finish_reason_str: None, finished: false, rate_limit, + provider_name, + catalog, + speed, } } @@ -906,21 +931,30 @@ impl SseStreamState { content_parts.push(ContentPart::ToolCall(tc.clone())); } + let (cost_usd, cost_source) = estimate_cost_usd( + self.catalog.as_deref(), + &self.provider_name, + &self.model, + &self.usage, + self.speed, + ); let response = Response { - id: uuid::Uuid::new_v4().to_string(), - model: self.model.clone(), - provider: "gemini".to_string(), - message: Message { + id: uuid::Uuid::new_v4().to_string(), + model: self.model.clone(), + provider: self.provider_name.clone(), + message: Message { role: Role::Assistant, content: content_parts, name: None, tool_call_id: None, }, finish_reason: finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), + usage: self.usage.clone(), + cost_usd, + cost_source, + raw: None, + warnings: vec![], + rate_limit: self.rate_limit.clone(), }; StreamEvent::finish(finish_reason, self.usage.clone(), response) @@ -1027,6 +1061,13 @@ impl ProviderAdapter for Adapter { let usage = parse_usage(api_resp.usage_metadata.as_ref()); + let (cost_usd, cost_source) = estimate_cost_usd( + self.catalog.as_deref(), + &self.provider_name, + &request.model, + &usage, + request.speed, + ); Ok(Response { id: uuid::Uuid::new_v4().to_string(), model: request.model.clone(), @@ -1039,6 +1080,8 @@ impl ProviderAdapter for Adapter { }, finish_reason, usage, + cost_usd, + cost_source, raw: serde_json::from_str(&body).ok(), warnings: vec![], rate_limit: parse_rate_limit_headers(&headers), @@ -1070,6 +1113,9 @@ impl ProviderAdapter for Adapter { request.model.clone(), rate_limit, self.http.stream_read_timeout, + self.provider_name.clone(), + self.catalog.clone(), + request.speed, )) } } diff --git a/lib/crates/fabro-llm/src/providers/openai.rs b/lib/crates/fabro-llm/src/providers/openai.rs index 54bd6e2a5..16dbd4799 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/crates/fabro-llm/src/providers/openai.rs @@ -6,6 +6,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_model::Catalog; use futures::{StreamExt, stream}; +use crate::cost::estimate_cost_usd; use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code}; use crate::provider::{ ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, @@ -794,6 +795,13 @@ struct SseStreamState { emitted_reasoning_start: bool, raw_response: Option, rate_limit: Option, + /// Provider name to attribute to the final Response and to use for + /// catalog cost estimation. + provider_name: String, + /// Optional catalog used to estimate `cost_usd` on the final Response. + catalog: Option>, + /// Speed setting forwarded from the request, used by catalog pricing. + speed: Option, } /// Parse a single SSE message block into an (`event_type`, `data`) pair. @@ -1208,6 +1216,13 @@ fn handle_response_completed( state.response_model.clone() }; + let (cost_usd, cost_source) = estimate_cost_usd( + state.catalog.as_deref(), + &state.provider_name, + &model, + &state.usage, + state.speed, + ); let response = Response { id: state.response_id.clone(), model, @@ -1220,6 +1235,8 @@ fn handle_response_completed( }, finish_reason: state.finish_reason.clone(), usage: state.usage.clone(), + cost_usd, + cost_source, raw: state.raw_response.clone(), warnings: vec![], rate_limit: state.rate_limit.clone(), @@ -1323,9 +1340,17 @@ impl ProviderAdapter for Adapter { let usage = token_counts_from_api_usage(api_resp.usage.as_ref()); + let resp_model = api_resp.model.unwrap_or_else(|| request.model.clone()); + let (cost_usd, cost_source) = estimate_cost_usd( + self.catalog.as_deref(), + &self.provider_name, + &resp_model, + &usage, + request.speed, + ); Ok(Response { id: api_resp.id, - model: api_resp.model.unwrap_or_else(|| request.model.clone()), + model: resp_model, provider: "openai".to_string(), message: Message { role: Role::Assistant, @@ -1335,6 +1360,8 @@ impl ProviderAdapter for Adapter { }, finish_reason, usage, + cost_usd, + cost_source, raw: serde_json::from_str(&body).ok(), warnings: vec![], rate_limit: parse_rate_limit_headers(&headers), @@ -1397,6 +1424,9 @@ impl ProviderAdapter for Adapter { emitted_reasoning_start: false, raw_response: None, rate_limit, + provider_name: self.provider_name.clone(), + catalog: self.catalog.clone(), + speed: request.speed, }; let stream = stream::unfold(state, |mut state| async move { @@ -2342,6 +2372,9 @@ mod tests { emitted_reasoning_start: false, raw_response: None, rate_limit: None, + provider_name: "openai".to_string(), + catalog: None, + speed: None, } } diff --git a/lib/crates/fabro-llm/src/providers/openai_chat/mod.rs b/lib/crates/fabro-llm/src/providers/openai_chat/mod.rs index 8c56ac46b..e5b88ada3 100644 --- a/lib/crates/fabro-llm/src/providers/openai_chat/mod.rs +++ b/lib/crates/fabro-llm/src/providers/openai_chat/mod.rs @@ -12,6 +12,8 @@ pub(crate) mod stream; pub(crate) mod translate; pub(crate) mod wire; +use std::sync::Arc; + use fabro_model::Catalog; pub(crate) use hooks::ChatHooks; @@ -45,14 +47,25 @@ pub(crate) async fn complete( } let (body, headers) = send_and_read_response(req, provider_name, "type").await?; - response::parse_chat_response(&body, &headers, provider_name, request, hooks) + response::parse_chat_response( + &body, + &headers, + provider_name, + request, + hooks, + catalog, + request.speed, + ) } /// Run a streaming Chat Completions request through the shared pipeline. +/// +/// `catalog` is taken by `Arc` (not borrow) because the streaming +/// `StreamState` must own it for the lifetime of the spawned stream. pub(crate) async fn stream( http: &super::http_api::HttpApi, build_request: impl Fn(&str) -> fabro_http::RequestBuilder + Send, - catalog: Option<&Catalog>, + catalog: Option>, provider_name: &str, request: &Request, hooks: ChatHooks, @@ -61,7 +74,7 @@ pub(crate) async fn stream( request, Some(true), provider_name, - catalog, + catalog.as_deref(), hooks, ); let url = format!("{}/chat/completions", http.base_url); @@ -76,6 +89,8 @@ pub(crate) async fn stream( http.stream_read_timeout, hooks, custom_tool_names, + catalog, + request.speed, ) .await } diff --git a/lib/crates/fabro-llm/src/providers/openai_chat/response.rs b/lib/crates/fabro-llm/src/providers/openai_chat/response.rs index 4aaf19359..9995b4246 100644 --- a/lib/crates/fabro-llm/src/providers/openai_chat/response.rs +++ b/lib/crates/fabro-llm/src/providers/openai_chat/response.rs @@ -2,10 +2,12 @@ //! [`Response`](crate::types::Response). use fabro_http::HeaderMap; +use fabro_model::{Catalog, Speed}; use super::hooks::ChatHooks; use super::translate::{custom_tool_names, map_finish_reason, parse_tool_arguments}; use super::wire::ApiResponse; +use crate::cost::estimate_cost_usd; use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; use crate::providers::common::parse_rate_limit_headers; use crate::types::{ @@ -21,6 +23,8 @@ pub(crate) fn parse_chat_response( provider_name: &str, request: &Request, hooks: ChatHooks, + catalog: Option<&Catalog>, + speed: Option, ) -> Result { let api_resp: ApiResponse = serde_json::from_str(body) .map_err(|e| Error::network(format!("failed to parse response: {e}"), e))?; @@ -81,6 +85,9 @@ pub(crate) fn parse_chat_response( let raw: Option = serde_json::from_str(body).ok(); + let (cost_usd, cost_source) = + estimate_cost_usd(catalog, provider_name, &api_resp.model, &usage, speed); + let mut response = Response { id: api_resp.id, model: api_resp.model, @@ -93,6 +100,8 @@ pub(crate) fn parse_chat_response( }, finish_reason, usage, + cost_usd, + cost_source, raw: raw.clone(), warnings: vec![], rate_limit: parse_rate_limit_headers(headers), diff --git a/lib/crates/fabro-llm/src/providers/openai_chat/stream.rs b/lib/crates/fabro-llm/src/providers/openai_chat/stream.rs index e9a6a5fae..8629a7362 100644 --- a/lib/crates/fabro-llm/src/providers/openai_chat/stream.rs +++ b/lib/crates/fabro-llm/src/providers/openai_chat/stream.rs @@ -1,10 +1,14 @@ //! Streaming Chat Completions SSE parsing and finish-event assembly. +use std::sync::Arc; + +use fabro_model::{Catalog, Speed}; use futures::{StreamExt, stream}; use super::hooks::ChatHooks; use super::translate::{map_finish_reason, parse_tool_arguments}; use super::wire::{AccumulatedToolCall, StreamChunk}; +use crate::cost::estimate_cost_usd; use crate::error::{Error, error_from_status_code}; use crate::provider::StreamEventStream; use crate::providers::common::{ @@ -50,6 +54,10 @@ pub(crate) struct StreamState { /// Names of tools on the request marked custom (freeform), used by /// [`parse_tool_arguments`] to preserve raw non-JSON arguments. pub(crate) custom_tool_names: Vec, + /// Optional catalog used to estimate `cost_usd` on the final Response. + pub(crate) catalog: Option>, + /// Speed setting forwarded from the request, used by catalog pricing. + pub(crate) speed: Option, } impl StreamState { @@ -61,6 +69,8 @@ impl StreamState { stream_read_timeout: Option, hooks: ChatHooks, custom_tool_names: Vec, + catalog: Option>, + speed: Option, ) -> Self { Self { line_reader: LineReader::new(response, stream_read_timeout), @@ -80,6 +90,8 @@ impl StreamState { hooks, last_usage_raw: None, custom_tool_names, + catalog, + speed, } } @@ -271,21 +283,31 @@ impl StreamState { self.response_model.clone() }; + let (cost_usd, cost_source) = estimate_cost_usd( + self.catalog.as_deref(), + &self.provider_name, + &response_model, + &self.usage, + self.speed, + ); + let mut response = Response { - id: self.response_id.clone(), - model: response_model, - provider: self.provider_name.clone(), - message: Message { + id: self.response_id.clone(), + model: response_model, + provider: self.provider_name.clone(), + message: Message { role: Role::Assistant, content: content_parts, name: None, tool_call_id: None, }, finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), + usage: self.usage.clone(), + cost_usd, + cost_source, + raw: None, + warnings: vec![], + rate_limit: self.rate_limit.clone(), }; if let Some(enrich) = self.hooks.enrich_response { @@ -317,6 +339,8 @@ pub(crate) fn run_stream( stream_read_timeout: Option, hooks: ChatHooks, custom_tool_names: Vec, + catalog: Option>, + speed: Option, ) -> StreamEventStream { let stream = stream::unfold( StreamState::new( @@ -327,6 +351,8 @@ pub(crate) fn run_stream( stream_read_timeout, hooks, custom_tool_names, + catalog, + speed, ), |mut state| async move { loop { @@ -430,6 +456,8 @@ pub(crate) async fn send_and_stream( stream_read_timeout: Option, hooks: ChatHooks, custom_tool_names: Vec, + catalog: Option>, + speed: Option, ) -> Result { let http_resp = req .send() @@ -464,6 +492,8 @@ pub(crate) async fn send_and_stream( stream_read_timeout, hooks, custom_tool_names, + catalog, + speed, )) } @@ -548,6 +578,8 @@ mod tests { Some(std::time::Duration::from_secs(30)), ChatHooks::NONE, Vec::new(), + None, + None, ); let raw: serde_json::Value = serde_json::from_str( @@ -582,6 +614,8 @@ mod tests { Some(std::time::Duration::from_secs(30)), ChatHooks::NONE, Vec::new(), + None, + None, ); // First text chunk should emit TextStart + TextDelta. @@ -618,6 +652,8 @@ mod tests { Some(std::time::Duration::from_secs(30)), ChatHooks::NONE, Vec::new(), + None, + None, ); // First tool call chunk (has id and name) -> ToolCallStart. @@ -653,6 +689,8 @@ mod tests { Some(std::time::Duration::from_secs(30)), ChatHooks::NONE, Vec::new(), + None, + None, ); state.response_id = "resp-1".into(); state.response_model = "gpt-4".into(); @@ -698,6 +736,8 @@ mod tests { Some(std::time::Duration::from_secs(30)), ChatHooks::NONE, Vec::new(), + None, + None, ); state.response_id = "resp-1".into(); state.tool_calls.push(AccumulatedToolCall { @@ -745,6 +785,8 @@ mod tests { Some(std::time::Duration::from_secs(30)), ChatHooks::NONE, Vec::new(), + None, + None, ); // response_model is empty, so finish_events should use the request model. let events = state.finish_events(); diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/crates/fabro-llm/src/providers/openai_compatible.rs index 782afc200..c28fe904b 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -111,7 +111,7 @@ impl ProviderAdapter for Adapter { openai_chat::stream( &self.http, |url| self.build_request(url), - self.catalog.as_deref(), + self.catalog.clone(), &self.provider_name, request, ChatHooks::NONE, diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index e09989ab3..515c5363d 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -100,6 +100,33 @@ impl Message { } } +// --- 3.7.1 CostSource --- + +/// Source of the `cost_usd` value on a [`Response`]. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, + strum::VariantArray, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum CostSource { + /// Provider returned an inline authoritative cost (e.g. OpenRouter + /// `usage.cost`). Use this when reconciling billing. + Authoritative, + /// Computed from `tokens × catalog_price`. Approximation; can drift if + /// catalog prices are stale or the provider applies surcharges/credits. + Estimated, +} + // --- 3.8 FinishReason --- #[derive(Debug, Clone, PartialEq, Eq)] @@ -311,6 +338,10 @@ pub struct Response { pub message: Message, pub finish_reason: FinishReason, pub usage: TokenCounts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_source: Option, pub raw: Option, pub warnings: Vec, pub rate_limit: Option, @@ -782,6 +813,8 @@ mod tests { message: Message::assistant("Hello world"), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -810,6 +843,8 @@ mod tests { }, finish_reason: FinishReason::ToolCalls, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -841,6 +876,8 @@ mod tests { }, finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -858,6 +895,8 @@ mod tests { message: Message::assistant("Hello"), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -1028,6 +1067,8 @@ mod tests { output_tokens: 5, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/crates/fabro-server/src/run_title_generation.rs b/lib/crates/fabro-server/src/run_title_generation.rs index c9d258cc9..5d5e1bd6f 100644 --- a/lib/crates/fabro-server/src/run_title_generation.rs +++ b/lib/crates/fabro-server/src/run_title_generation.rs @@ -352,6 +352,8 @@ mod tests { message: Message::assistant(self.response_text.clone()), finish_reason: FinishReason::Stop, usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, raw: None, warnings: Vec::new(), rate_limit: None, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 11963ba1c..4ed417854 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -29,20 +29,20 @@ pub use fabro_api::types::{ BatchRunLifecycleResponse, BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse, - CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, - CreateRunPullRequestRequest, CreateSecretRequest, CreateVariableRequest, DeleteRunResponse, - DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, - DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind, - IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, - IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest, - MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList, - PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider, - ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, - RenderWorkflowGraphRequest, RewindRequest, RewindResponse, Run, RunArtifactEntry, - RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest, - RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService, - SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState, - StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources, + CompletionResponseCostSource, CompletionToolChoiceMode, CompletionUsage, + CreateCompletionRequest, CreateRunPullRequestRequest, CreateSecretRequest, + CreateVariableRequest, DeleteRunResponse, DeleteRunSandbox, DeleteSecretRequest, + DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ErrorResponseEntry, + ForkRequest, ForkResponse, IntegrationConnectionKind, IntegrationConnectionState, + IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, LinkRunPullRequestRequest, + MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, PaginatedEventList, + PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, + Provider, ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, + RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse, Run, + RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, + RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, + SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, + StageState, StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources, SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemIntegrationStatus, SystemIntegrationsResponse, SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, diff --git a/lib/crates/fabro-server/src/server/handler/completions.rs b/lib/crates/fabro-server/src/server/handler/completions.rs index 5b884a64a..bf87f730c 100644 --- a/lib/crates/fabro-server/src/server/handler/completions.rs +++ b/lib/crates/fabro-server/src/server/handler/completions.rs @@ -1,11 +1,14 @@ use std::sync::Arc; +use fabro_llm::types::CostSource; + use super::super::{ ApiError, AppState, CompletionContentPart, CompletionMessage, CompletionMessageRole, - CompletionResponse, CompletionToolChoiceMode, CompletionUsage, ContentPart, - CreateCompletionRequest, Duration, Event, FinishReason, GenerateParams, IntoResponse, Json, - KeepAlive, LlmMessage, LlmRequest, RequiredUser, Response, Role, Router, Sse, State, - StatusCode, ToolChoice, ToolDefinition, Ulid, error, generate_object, info, post, warn, + CompletionResponse, CompletionResponseCostSource, CompletionToolChoiceMode, CompletionUsage, + ContentPart, CreateCompletionRequest, Duration, Event, FinishReason, GenerateParams, + IntoResponse, Json, KeepAlive, LlmMessage, LlmRequest, RequiredUser, Response, Role, Router, + Sse, State, StatusCode, ToolChoice, ToolDefinition, Ulid, error, generate_object, info, post, + warn, }; pub(super) fn routes() -> Router> { @@ -23,6 +26,13 @@ fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String { } } +fn cost_source_to_api(source: CostSource) -> CompletionResponseCostSource { + match source { + CostSource::Authoritative => CompletionResponseCostSource::Authoritative, + CostSource::Estimated => CompletionResponseCostSource::Estimated, + } +} + fn convert_api_message(msg: &CompletionMessage) -> LlmMessage { let role = match msg.role { CompletionMessageRole::System => Role::System, @@ -254,6 +264,8 @@ async fn create_completion( output_tokens: result.usage.output_tokens, }, output: result.output, + cost_usd: result.response.cost_usd, + cost_source: result.response.cost_source.map(cost_source_to_api), }) .into_response(), Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}")) @@ -271,6 +283,8 @@ async fn create_completion( output_tokens: response.usage.output_tokens, }, output: None, + cost_usd: response.cost_usd, + cost_source: response.cost_source.map(cost_source_to_api), }) .into_response(), Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}")) diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index c02376988..2c1e756c2 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -729,6 +729,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, @@ -757,6 +759,8 @@ mod tests { output_tokens: 20, ..Default::default() }, + cost_usd: None, + cost_source: None, raw: None, warnings: vec![], rate_limit: None, diff --git a/lib/packages/fabro-api-client/src/models/completion-response.ts b/lib/packages/fabro-api-client/src/models/completion-response.ts index ed5bc0c40..ce8be4796 100644 --- a/lib/packages/fabro-api-client/src/models/completion-response.ts +++ b/lib/packages/fabro-api-client/src/models/completion-response.ts @@ -30,4 +30,19 @@ export interface CompletionResponse { 'stop_reason': string; 'usage': CompletionUsage; 'output'?: any; + /** + * Total USD cost for the completion. Populated when the provider returns an authoritative billing figure (e.g. OpenRouter) or when the model has catalog pricing. + */ + 'cost_usd'?: number; + /** + * Whether `cost_usd` came from the provider\'s billing data (`authoritative`) or was computed from catalog prices (`estimated`). + */ + 'cost_source'?: CompletionResponseCostSourceEnum; } + +export const CompletionResponseCostSourceEnum = { + AUTHORITATIVE: 'authoritative', + ESTIMATED: 'estimated' +} as const; + +export type CompletionResponseCostSourceEnum = typeof CompletionResponseCostSourceEnum[keyof typeof CompletionResponseCostSourceEnum];