From c300d32a820d692ed369d8616cf3b7fdc2bcd5e6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 12 May 2026 21:46:02 -0400 Subject: [PATCH] feat(billing): price usage by catalog model speed --- .../fabro-web/app/routes/run-billing.test.tsx | 24 +- apps/fabro-web/app/routes/run-billing.tsx | 16 +- docs/public/api-reference/fabro-api.yaml | 4 +- lib/crates/fabro-api/build.rs | 2 + lib/crates/fabro-api/src/lib.rs | 5 +- .../tests/run_billing_stage_round_trip.rs | 52 +- .../src/commands/run/run_progress/mod.rs | 11 +- lib/crates/fabro-model/src/billing.rs | 583 +++++++++++++++--- .../src/catalog/providers/anthropic.toml | 16 + lib/crates/fabro-server/src/demo/mod.rs | 68 +- lib/crates/fabro-server/src/server.rs | 9 +- .../src/server/handler/billing.rs | 15 +- .../fabro-server/src/server/handler/system.rs | 14 +- lib/crates/fabro-server/src/server/tests.rs | 140 ++++- .../fabro-workflow/src/event/convert.rs | 43 +- .../fabro-workflow/src/handler/llm/api.rs | 48 +- .../fabro-workflow/src/handler/llm/cli.rs | 49 +- .../src/handler/llm/preamble.rs | 20 +- lib/crates/fabro-workflow/src/outcome.rs | 177 ++++-- .../fabro-workflow/src/pipeline/initialize.rs | 2 + .../fabro-api-client/src/api/models-api.ts | 2 +- .../src/models/billing-by-model.ts | 7 +- .../fabro-api-client/src/models/model.ts | 6 +- .../src/models/run-billing-stage.ts | 11 +- 24 files changed, 1050 insertions(+), 274 deletions(-) diff --git a/apps/fabro-web/app/routes/run-billing.test.tsx b/apps/fabro-web/app/routes/run-billing.test.tsx index 3cc3ff60e..2940ef677 100644 --- a/apps/fabro-web/app/routes/run-billing.test.tsx +++ b/apps/fabro-web/app/routes/run-billing.test.tsx @@ -114,7 +114,10 @@ describe("RunBilling", () => { }, { stage: { id: "agent", name: "agent" }, - model: { id: "claude-sonnet-4-5" }, + model: { + provider: "anthropic", + model_id: "claude-sonnet-4-5", + }, billing: zeroBilling({ input_tokens: 1200, output_tokens: 300, @@ -136,7 +139,10 @@ describe("RunBilling", () => { }, by_model: [ { - model: { id: "claude-sonnet-4-5" }, + model: { + provider: "anthropic", + model_id: "claude-sonnet-4-5", + }, stages: 1, billing: zeroBilling({ input_tokens: 1200, @@ -180,7 +186,11 @@ describe("RunBilling", () => { stages: [ { stage: { id: "in-flight", name: "in-flight" }, - model: { id: "claude-opus-4-6" }, + model: { + provider: "anthropic", + model_id: "claude-opus-4-6", + speed: "fast", + }, billing: zeroBilling({ input_tokens: 1200, output_tokens: 300, @@ -203,7 +213,11 @@ describe("RunBilling", () => { }, by_model: [ { - model: { id: "claude-opus-4-6" }, + model: { + provider: "anthropic", + model_id: "claude-opus-4-6", + speed: "fast", + }, stages: 1, billing: zeroBilling({ input_tokens: 1200, @@ -221,7 +235,7 @@ describe("RunBilling", () => { // first stage starts. expect(text).not.toContain("No stages yet"); expect(text).toContain("in-flight"); - expect(text).toContain("claude-opus-4-6"); + expect(text).toContain("anthropic:claude-opus-4-6 · fast"); expect(text).toContain("1.2k"); expect(text).toContain("0.3k"); expect(text).toContain("$0.24"); diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 9c7323bdf..06adad86e 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -5,7 +5,11 @@ import { formatDurationSecs } from "../lib/format"; import { useRunBilling } from "../lib/queries"; import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar"; import { useTickingNow } from "../lib/time"; -import type { RunBilling, RunBillingStage } from "@qltysh/fabro-api-client"; +import type { + BillingModelRef, + RunBilling, + RunBillingStage, +} from "@qltysh/fabro-api-client"; const EMPTY_VALUE = "—"; @@ -18,6 +22,12 @@ function formatUsdMicros(usdMicros?: number | null) { return usdMicros == null ? EMPTY_VALUE : `$${(usdMicros / 1_000_000).toFixed(2)}`; } +function formatModelRef(model?: BillingModelRef | null): string | null { + if (!model) return null; + const speed = model.speed && model.speed !== "standard" ? ` · ${model.speed}` : ""; + return `${model.provider}:${model.model_id}${speed}`; +} + function isInFlight(stage: RunBillingStage): boolean { return stage.state != null && IN_FLIGHT_STAGE_STATES.has(stage.state); } @@ -57,7 +67,7 @@ function mapStageRow(stage: RunBillingStage, runtimeSecs: number): MappedStageRo const hasModel = stage.model != null; return { stage: stage.stage.name, - model: stage.model?.id ?? null, + model: formatModelRef(stage.model), inputTokens: hasModel ? stage.billing.input_tokens : null, outputTokens: hasModel ? stage.billing.output_tokens + stage.billing.reasoning_tokens @@ -88,7 +98,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { if (!billing) return []; return billing.by_model .map((entry) => ({ - model: entry.model.id, + model: formatModelRef(entry.model) ?? EMPTY_VALUE, stages: entry.stages, inputTokens: entry.billing.input_tokens, outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens, diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 6c1eda3ba..ba165d183 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -7769,7 +7769,7 @@ components: model: description: Latest usage-bearing visit model for this node; null when no visit used an LLM model. oneOf: - - $ref: "#/components/schemas/ModelReference" + - $ref: "#/components/schemas/BillingModelRef" - type: "null" billing: $ref: "#/components/schemas/BilledTokenCounts" @@ -7843,7 +7843,7 @@ components: - billing properties: model: - $ref: "#/components/schemas/ModelReference" + $ref: "#/components/schemas/BillingModelRef" stages: type: integer description: Number of usage-bearing stage visits that used this model. diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index e81126d4e..e142b11fc 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -356,6 +356,8 @@ fn main() { &[], ), ("BilledTokenCounts", "fabro_types::BilledTokenCounts", &[]), + ("BillingModelRef", "fabro_model::ModelRef", &[]), + ("BillingSpeed", "fabro_model::Speed", &[]), ("ProviderId", "fabro_model::ProviderId", &[]), ("Model", "fabro_model::Model", &[]), ("ModelLimits", "fabro_model::ModelLimits", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index e5a6e62a7..738917a80 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -14,7 +14,10 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } pub mod types { - pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, ModelTestMode, Provider}; + pub use fabro_model::{ + Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, + Provider, Speed as BillingSpeed, + }; pub use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings, diff --git a/lib/crates/fabro-api/tests/run_billing_stage_round_trip.rs b/lib/crates/fabro-api/tests/run_billing_stage_round_trip.rs index 52fb4ece1..8acfd67af 100644 --- a/lib/crates/fabro-api/tests/run_billing_stage_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_billing_stage_round_trip.rs @@ -1,7 +1,26 @@ -use fabro_api::types::RunBillingStage; +use std::any::{TypeId, type_name}; + +use fabro_api::types::{BillingByModel, BillingModelRef, BillingSpeed, RunBillingStage}; +use fabro_model::{ModelRef, Speed}; use fabro_types::StageState; use serde_json::json; +#[test] +fn billing_model_ref_reuses_domain_type() { + assert_same_type::(); + assert_same_type::(); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} + #[test] fn run_billing_stage_model_accepts_required_null() { let value = json!({ @@ -37,7 +56,11 @@ fn run_billing_stage_round_trips_terminal_row_with_started_at_and_state() { "id": "build", "name": "build" }, - "model": { "id": "claude-sonnet-4-5" }, + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-5", + "speed": "fast" + }, "billing": { "input_tokens": 12, "output_tokens": 34, @@ -58,6 +81,31 @@ fn run_billing_stage_round_trips_terminal_row_with_started_at_and_state() { assert_eq!(serde_json::to_value(stage).unwrap(), value); } +#[test] +fn billing_by_model_round_trips_provider_model_speed_identity() { + let value = json!({ + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-6", + "speed": "fast" + }, + "stages": 2, + "billing": { + "input_tokens": 12, + "output_tokens": 34, + "total_tokens": 46, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + "total_usd_micros": 123 + } + }); + + let row: BillingByModel = + serde_json::from_value(value.clone()).expect("billing model ref should deserialize"); + assert_eq!(serde_json::to_value(row).unwrap(), value); +} + #[test] fn run_billing_stage_round_trips_in_flight_row() { let value = json!({ 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 b2d5ea08f..9b40b0070 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 @@ -471,7 +471,7 @@ mod tests { use chrono::{DateTime, Utc}; use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_llm::types::TokenCounts; - use fabro_model::{ModelRef, Provider}; + use fabro_model::{Catalog, ModelRef, Provider}; use fabro_types::run_event::CliEnsureCompletedProps; use fabro_types::{ MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProvider, @@ -590,9 +590,12 @@ mod tests { preferred_label: None, suggested_next_ids: Vec::new(), billing: Some(billed_model_usage_from_llm( - "gpt-5-mini", - Provider::OpenAi, - None, + Catalog::builtin(), + &ModelRef { + provider: Provider::OpenAi.id(), + model_id: "gpt-5-mini".into(), + speed: None, + }, &TokenCounts { input_tokens: 1200, output_tokens: 300, diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index 74682a62b..b32f5e831 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -1,11 +1,10 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; -use crate::{Model, Provider, ProviderId}; +use crate::catalog::{Catalog, CatalogModelSettings}; +use crate::{Model, ModelCosts, Provider, ProviderId, adapter}; const TOKENS_PER_MTOK: i128 = 1_000_000; -const ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR: i64 = 6; -const ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR: i64 = 1; const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5; const ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR: i64 = 4; const ANTHROPIC_CACHE_WRITE_1H_NUMERATOR: i64 = 2; @@ -280,10 +279,45 @@ pub enum ModelBillingFacts { impl ModelBillingFacts { #[must_use] pub fn for_provider(provider: Provider) -> Self { + Self::for_builtin_provider(provider, &TokenCounts::default()) + } + + #[must_use] + pub fn for_provider_id(provider: &ProviderId, tokens: &TokenCounts) -> Self { + Provider::from_id(provider).map_or_else( + || Self::OpenAiCompatible(OpenAiBillingFacts::default()), + |provider| Self::for_builtin_provider(provider, tokens), + ) + } + + #[must_use] + pub fn for_provider_adapter( + provider: &ProviderId, + adapter_key: &str, + tokens: &TokenCounts, + ) -> Self { + if let Some(provider) = Provider::from_id(provider) { + return Self::for_builtin_provider(provider, tokens); + } + + match adapter_key { + key if key == adapter::ANTHROPIC.key => { + Self::Anthropic(anthropic_billing_facts(tokens)) + } + key if key == adapter::GEMINI.key => Self::Gemini(GeminiBillingFacts::default()), + key if key == adapter::OPENAI.key => Self::OpenAi(OpenAiBillingFacts::default()), + key if key == adapter::OPENAI_COMPATIBLE.key => { + Self::OpenAiCompatible(OpenAiBillingFacts::default()) + } + _ => Self::OpenAiCompatible(OpenAiBillingFacts::default()), + } + } + + fn for_builtin_provider(provider: Provider, tokens: &TokenCounts) -> Self { match provider { Provider::OpenAi => Self::OpenAi(OpenAiBillingFacts::default()), Provider::OpenAiCompatible => Self::OpenAiCompatible(OpenAiBillingFacts::default()), - Provider::Anthropic => Self::Anthropic(AnthropicBillingFacts::default()), + Provider::Anthropic => Self::Anthropic(anthropic_billing_facts(tokens)), Provider::Gemini => Self::Gemini(GeminiBillingFacts::default()), Provider::Kimi => Self::Kimi(OpenAiBillingFacts::default()), Provider::Zai => Self::Zai(OpenAiBillingFacts::default()), @@ -405,6 +439,81 @@ impl BilledTokenCounts { } } +fn anthropic_billing_facts(tokens: &TokenCounts) -> AnthropicBillingFacts { + AnthropicBillingFacts { + cache_write_5m_tokens: tokens.cache_write_tokens, + cache_write_1h_tokens: 0, + } +} + +impl Catalog { + #[must_use] + pub fn pricing_for(&self, model_ref: &ModelRef) -> Option { + let model = self.get(&model_ref.model_id)?; + let provider = self.provider(&model_ref.provider)?; + if model.provider != provider.id { + return None; + } + + let settings = self.model_settings(&model.id)?; + let costs = costs_for_speed(model, settings, model_ref.speed)?; + pricing_for_model_costs( + model, + provider.id.clone(), + provider.adapter.as_str(), + model_ref.speed, + &costs, + ) + } + + #[must_use] + pub fn billing_facts_for( + &self, + model_ref: &ModelRef, + tokens: &TokenCounts, + ) -> ModelBillingFacts { + self.provider(&model_ref.provider).map_or_else( + || ModelBillingFacts::for_provider_id(&model_ref.provider, tokens), + |provider| { + ModelBillingFacts::for_provider_adapter(&provider.id, &provider.adapter, tokens) + }, + ) + } +} + +fn costs_for_speed( + model: &Model, + settings: &CatalogModelSettings, + speed: Option, +) -> Option { + match speed { + None | Some(Speed::Standard) => Some(model.costs.clone()), + Some(speed) => { + if !settings.controls.speed.contains(&speed) { + return None; + } + let Some(speed_costs) = settings.speed_costs.get(&speed) else { + return Some(model.costs.clone()); + }; + Some(merge_cost_override(&model.costs, speed_costs)) + } + } +} + +fn merge_cost_override(base: &ModelCosts, override_costs: &ModelCosts) -> ModelCosts { + ModelCosts { + input_cost_per_mtok: override_costs + .input_cost_per_mtok + .or(base.input_cost_per_mtok), + output_cost_per_mtok: override_costs + .output_cost_per_mtok + .or(base.output_cost_per_mtok), + cache_input_cost_per_mtok: override_costs + .cache_input_cost_per_mtok + .or(base.cache_input_cost_per_mtok), + } +} + impl Model { #[must_use] pub fn billing_model_ref(&self, speed: Option) -> ModelRef { @@ -417,104 +526,164 @@ impl Model { #[must_use] pub fn pricing_for(&self, speed: Option) -> Option { - let input = self.costs.input_cost_per_mtok.map(PricePerMTok::from_usd)?; - let output = self - .costs - .output_cost_per_mtok - .map(PricePerMTok::from_usd)?; - let cached_input = self - .costs - .cache_input_cost_per_mtok - .map(PricePerMTok::from_usd); - + if matches!(speed, Some(Speed::Fast)) { + return None; + } let provider = self.builtin_provider()?; - - let (input, output, cached_input) = match (provider, speed) { - (Provider::Anthropic, Some(Speed::Fast)) - if self.id == "claude-opus-4-7" || self.id == "claude-opus-4-6" => - { - ( - input.multiply_ratio( - ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR, - ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR, - ), - output.multiply_ratio( - ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR, - ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR, - ), - cached_input.map(|rate| { - rate.multiply_ratio( - ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR, - ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR, - ) - }), - ) - } - (_, None | Some(Speed::Standard)) => (input, output, cached_input), - _ => return None, - }; - - let policy = match provider { - Provider::OpenAi => ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::OpenAiCompatible => { - ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { - input, - cached_input, - output, - }) - } - Provider::Anthropic => ModelPricingPolicy::Anthropic(AnthropicModelPricing { - input, - cache_read: cached_input, - cache_write_5m: Some(input.multiply_ratio( - ANTHROPIC_CACHE_WRITE_5M_NUMERATOR, - ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR, - )), - cache_write_1h: Some(input.multiply_ratio( - ANTHROPIC_CACHE_WRITE_1H_NUMERATOR, - ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR, - )), - output, - }), - Provider::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing { - input, - output, - cached_input, - storage: None, - }), - Provider::Kimi => ModelPricingPolicy::Kimi(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Zai => ModelPricingPolicy::Zai(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Minimax => ModelPricingPolicy::Minimax(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Inception => ModelPricingPolicy::Inception(OpenAiModelPricing { - input, - cached_input, - output, - }), - }; - - Some(ModelPricing { - model: self.billing_model_ref(speed), - policy, - }) + pricing_for_model_costs( + self, + provider.id(), + adapter_key_for_builtin_provider(provider), + speed, + &self.costs, + ) } } +fn adapter_key_for_builtin_provider(provider: Provider) -> &'static str { + match provider { + Provider::Anthropic => adapter::ANTHROPIC.key, + Provider::OpenAi => adapter::OPENAI.key, + Provider::Gemini => adapter::GEMINI.key, + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => adapter::OPENAI_COMPATIBLE.key, + } +} + +fn pricing_for_model_costs( + model: &Model, + provider_id: ProviderId, + adapter_key: &str, + speed: Option, + costs: &ModelCosts, +) -> Option { + let input = costs.input_cost_per_mtok.map(PricePerMTok::from_usd)?; + let output = costs.output_cost_per_mtok.map(PricePerMTok::from_usd)?; + let cached_input = costs.cache_input_cost_per_mtok.map(PricePerMTok::from_usd); + + let policy = + pricing_policy_for_provider_adapter(&provider_id, adapter_key, input, output, cached_input); + Some(ModelPricing { + model: ModelRef { + provider: provider_id, + model_id: model.id.clone(), + speed, + }, + policy, + }) +} + +fn pricing_policy_for_provider_adapter( + provider_id: &ProviderId, + adapter_key: &str, + input: PricePerMTok, + output: PricePerMTok, + cached_input: Option, +) -> ModelPricingPolicy { + if let Some(provider) = Provider::from_id(provider_id) { + return pricing_policy_for_builtin_provider(provider, input, output, cached_input); + } + + match adapter_key { + key if key == adapter::ANTHROPIC.key => { + anthropic_pricing_policy(input, output, cached_input) + } + key if key == adapter::GEMINI.key => ModelPricingPolicy::Gemini(GeminiModelPricing { + input, + output, + cached_input, + storage: None, + }), + key if key == adapter::OPENAI.key => ModelPricingPolicy::OpenAi(OpenAiModelPricing { + input, + cached_input, + output, + }), + key if key == adapter::OPENAI_COMPATIBLE.key => { + ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { + input, + cached_input, + output, + }) + } + _ => ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { + input, + cached_input, + output, + }), + } +} + +fn pricing_policy_for_builtin_provider( + provider: Provider, + input: PricePerMTok, + output: PricePerMTok, + cached_input: Option, +) -> ModelPricingPolicy { + match provider { + Provider::OpenAi => ModelPricingPolicy::OpenAi(OpenAiModelPricing { + input, + cached_input, + output, + }), + Provider::OpenAiCompatible => ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { + input, + cached_input, + output, + }), + Provider::Anthropic => anthropic_pricing_policy(input, output, cached_input), + Provider::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing { + input, + output, + cached_input, + storage: None, + }), + Provider::Kimi => ModelPricingPolicy::Kimi(OpenAiModelPricing { + input, + cached_input, + output, + }), + Provider::Zai => ModelPricingPolicy::Zai(OpenAiModelPricing { + input, + cached_input, + output, + }), + Provider::Minimax => ModelPricingPolicy::Minimax(OpenAiModelPricing { + input, + cached_input, + output, + }), + Provider::Inception => ModelPricingPolicy::Inception(OpenAiModelPricing { + input, + cached_input, + output, + }), + } +} + +fn anthropic_pricing_policy( + input: PricePerMTok, + output: PricePerMTok, + cached_input: Option, +) -> ModelPricingPolicy { + ModelPricingPolicy::Anthropic(AnthropicModelPricing { + input, + cache_read: cached_input, + cache_write_5m: Some(input.multiply_ratio( + ANTHROPIC_CACHE_WRITE_5M_NUMERATOR, + ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR, + )), + cache_write_1h: Some(input.multiply_ratio( + ANTHROPIC_CACHE_WRITE_1H_NUMERATOR, + ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR, + )), + output, + }) +} + impl ModelPricing { #[must_use] pub fn bill(&self, input: &ModelBillingInput) -> Option { @@ -624,6 +793,13 @@ fn bill_gemini( mod tests { use super::*; use crate::Catalog; + use crate::catalog::LlmCatalogSettings; + + fn catalog_from_toml(source: &str) -> Catalog { + let settings: LlmCatalogSettings = + toml::from_str(source).expect("catalog fixture should parse"); + Catalog::from_settings(&settings).expect("catalog fixture should build") + } fn billed_usage( input_tokens: i64, @@ -803,14 +979,22 @@ mod tests { } #[test] - fn anthropic_fast_mode_derives_cache_write_rates_from_base_input() { - let model = Catalog::builtin().get("claude-opus-4-6").unwrap(); - let pricing = model.pricing_for(Some(Speed::Fast)).unwrap(); + fn catalog_pricing_uses_speed_cost_overrides() { + let pricing = Catalog::builtin() + .pricing_for(&ModelRef { + provider: Provider::Anthropic.id(), + model_id: "claude-opus-4-6".to_string(), + speed: Some(Speed::Fast), + }) + .unwrap(); let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { panic!("expected anthropic pricing"); }; + assert_eq!(pricing.model.provider, Provider::Anthropic.id()); + assert_eq!(pricing.model.model_id, "claude-opus-4-6"); + assert_eq!(pricing.model.speed, Some(Speed::Fast)); assert_eq!(anthropic.input.usd_micros, 30_000_000); assert_eq!(anthropic.output.usd_micros, 150_000_000); assert_eq!(anthropic.cache_read.unwrap().usd_micros, 3_000_000); @@ -818,6 +1002,203 @@ mod tests { assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 60_000_000); } + #[test] + fn catalog_pricing_standard_speed_uses_base_costs() { + let pricing = Catalog::builtin() + .pricing_for(&ModelRef { + provider: Provider::Anthropic.id(), + model_id: "claude-opus-4-6".to_string(), + speed: Some(Speed::Standard), + }) + .unwrap(); + + let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { + panic!("expected anthropic pricing"); + }; + + assert_eq!(anthropic.input.usd_micros, 5_000_000); + assert_eq!(anthropic.output.usd_micros, 25_000_000); + assert_eq!(anthropic.cache_read.unwrap().usd_micros, 500_000); + assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 6_250_000); + assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 10_000_000); + } + + #[test] + fn catalog_pricing_supported_fast_without_override_uses_base_costs() { + let catalog = catalog_from_toml( + r#" +[providers.test_anthropic] +display_name = "Test Anthropic" +adapter = "anthropic" + +[models.test-opus] +provider = "test_anthropic" +display_name = "Test Opus" +family = "test" +default = true + +[models.test-opus.limits] +context_window = 1000 + +[models.test-opus.features] +tools = true +vision = false +reasoning = false + +[models.test-opus.controls] +speed = ["fast"] + +[models.test-opus.costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 4.0 +cache_input_cost_per_mtok = 0.25 +"#, + ); + + let pricing = catalog + .pricing_for(&ModelRef { + provider: ProviderId::new("test_anthropic"), + model_id: "test-opus".to_string(), + speed: Some(Speed::Fast), + }) + .unwrap(); + + let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { + panic!("expected anthropic adapter pricing"); + }; + assert_eq!(anthropic.input.usd_micros, 1_000_000); + assert_eq!(anthropic.output.usd_micros, 4_000_000); + assert_eq!(anthropic.cache_read.unwrap().usd_micros, 250_000); + } + + #[test] + fn catalog_pricing_supports_custom_openai_compatible_provider_costs() { + let catalog = catalog_from_toml( + r#" +[providers.proxy] +display_name = "Proxy" +adapter = "openai_compatible" +base_url = "https://proxy.example/v1" + +[models.proxy-model] +provider = "proxy" +display_name = "Proxy Model" +family = "proxy" +default = true + +[models.proxy-model.limits] +context_window = 1000 + +[models.proxy-model.features] +tools = true +vision = false +reasoning = false + +[models.proxy-model.costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 2.0 +cache_input_cost_per_mtok = 0.1 +"#, + ); + + let pricing = catalog + .pricing_for(&ModelRef { + provider: ProviderId::new("proxy"), + model_id: "proxy-model".to_string(), + speed: None, + }) + .unwrap(); + + let ModelPricingPolicy::OpenAiCompatible(openai_like) = pricing.policy else { + panic!("expected openai-compatible adapter pricing"); + }; + assert_eq!(pricing.model.provider, ProviderId::new("proxy")); + assert_eq!(openai_like.input.usd_micros, 1_000_000); + assert_eq!(openai_like.output.usd_micros, 2_000_000); + assert_eq!(openai_like.cached_input.unwrap().usd_micros, 100_000); + } + + #[test] + fn catalog_pricing_uses_canonical_model_id_not_api_id() { + let catalog = catalog_from_toml( + r#" +[providers.proxy] +display_name = "Proxy" +adapter = "openai_compatible" +base_url = "https://proxy.example/v1" + +[models.canonical-model] +provider = "proxy" +api_id = "wire-model" +display_name = "Canonical Model" +family = "proxy" +default = true + +[models.canonical-model.limits] +context_window = 1000 + +[models.canonical-model.features] +tools = true +vision = false +reasoning = false + +[models.canonical-model.costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 2.0 +"#, + ); + + assert!( + catalog + .pricing_for(&ModelRef { + provider: ProviderId::new("proxy"), + model_id: "canonical-model".to_string(), + speed: None, + }) + .is_some() + ); + assert!( + catalog + .pricing_for(&ModelRef { + provider: ProviderId::new("proxy"), + model_id: "wire-model".to_string(), + speed: None, + }) + .is_none() + ); + } + + #[test] + fn catalog_pricing_unknown_provider_model_or_speed_has_no_estimate() { + assert!( + Catalog::builtin() + .pricing_for(&ModelRef { + provider: ProviderId::new("unknown"), + model_id: "claude-opus-4-6".to_string(), + speed: None, + }) + .is_none() + ); + assert!( + Catalog::builtin() + .pricing_for(&ModelRef { + provider: Provider::Anthropic.id(), + model_id: "unknown".to_string(), + speed: None, + }) + .is_none() + ); + assert!( + Catalog::builtin() + .pricing_for(&ModelRef { + provider: Provider::OpenAi.id(), + model_id: "gpt-5.4".to_string(), + speed: Some(Speed::Fast), + }) + .is_none() + ); + } + #[test] fn anthropic_billing_supports_distinct_cache_write_buckets() { let pricing = ModelPricing { diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml index 0b2ec120f..5cb101f0c 100644 --- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml @@ -24,11 +24,19 @@ vision = true reasoning = true effort = true +[models."claude-opus-4-7".controls] +speed = ["fast"] + [models."claude-opus-4-7".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 +[models."claude-opus-4-7".costs.speed.fast] +input_cost_per_mtok = 30.0 +output_cost_per_mtok = 150.0 +cache_input_cost_per_mtok = 3.0 + [models."claude-opus-4-6"] provider = "anthropic" api_id = "claude-opus-4-6" @@ -48,11 +56,19 @@ vision = true reasoning = true effort = true +[models."claude-opus-4-6".controls] +speed = ["fast"] + [models."claude-opus-4-6".costs] input_cost_per_mtok = 5.0 output_cost_per_mtok = 25.0 cache_input_cost_per_mtok = 0.5 +[models."claude-opus-4-6".costs.speed.fast] +input_cost_per_mtok = 30.0 +output_cost_per_mtok = 150.0 +cache_input_cost_per_mtok = 3.0 + [models."claude-sonnet-4-5"] provider = "anthropic" api_id = "claude-sonnet-4-5" diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 366230664..9cab3544e 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1008,6 +1008,14 @@ mod runs { .collect() } + fn billing_model(provider: fabro_model::Provider, model_id: &str) -> BillingModelRef { + BillingModelRef { + provider: provider.id(), + model_id: model_id.into(), + speed: None, + } + } + fn demo_run_ids() -> &'static [RunId; 7] { static IDS: OnceLock<[RunId; 7]> = OnceLock::new(); IDS.get_or_init(|| { @@ -1450,9 +1458,10 @@ mod runs { id: "detect-drift".into(), name: "Detect Drift".into(), }, - model: Some(ModelReference { - id: "Opus 4.6".into(), - }), + model: Some(billing_model( + fabro_model::Provider::Anthropic, + "claude-opus-4-6", + )), billing: BilledTokenCounts { cache_read_tokens: 0, cache_write_tokens: 0, @@ -1471,9 +1480,10 @@ mod runs { id: "propose-changes".into(), name: "Propose Changes".into(), }, - model: Some(ModelReference { - id: "Gemini 3.1".into(), - }), + model: Some(billing_model( + fabro_model::Provider::Gemini, + "gemini-3.1-pro-preview", + )), billing: BilledTokenCounts { cache_read_tokens: 0, cache_write_tokens: 0, @@ -1492,9 +1502,10 @@ mod runs { id: "review-changes".into(), name: "Review Changes".into(), }, - model: Some(ModelReference { - id: "Codex 5.3".into(), - }), + model: Some(billing_model( + fabro_model::Provider::OpenAi, + "gpt-5.3-codex", + )), billing: BilledTokenCounts { cache_read_tokens: 0, cache_write_tokens: 0, @@ -1513,9 +1524,10 @@ mod runs { id: "apply-changes".into(), name: "Apply Changes".into(), }, - model: Some(ModelReference { - id: "Opus 4.6".into(), - }), + model: Some(billing_model( + fabro_model::Provider::Anthropic, + "claude-opus-4-6", + )), billing: BilledTokenCounts { cache_read_tokens: 0, cache_write_tokens: 0, @@ -1551,9 +1563,7 @@ mod runs { total_tokens: 43470, total_usd_micros: Some(1_350_000), }, - model: ModelReference { - id: "Opus 4.6".into(), - }, + model: billing_model(fabro_model::Provider::Anthropic, "claude-opus-4-6"), stages: 2, }, BillingByModel { @@ -1566,9 +1576,7 @@ mod runs { total_tokens: 37390, total_usd_micros: Some(720_000), }, - model: ModelReference { - id: "Gemini 3.1".into(), - }, + model: billing_model(fabro_model::Provider::Gemini, "gemini-3.1-pro-preview"), stages: 1, }, BillingByModel { @@ -1581,9 +1589,7 @@ mod runs { total_tokens: 11760, total_usd_micros: Some(190_000), }, - model: ModelReference { - id: "Codex 5.3".into(), - }, + model: billing_model(fabro_model::Provider::OpenAi, "gpt-5.3-codex"), stages: 1, }, ], @@ -1902,6 +1908,14 @@ mod workflows { mod billing { use fabro_api::types::*; + fn billing_model(provider: fabro_model::Provider, model_id: &str) -> BillingModelRef { + BillingModelRef { + provider: provider.id(), + model_id: model_id.into(), + speed: None, + } + } + pub(super) fn aggregate() -> AggregateBilling { AggregateBilling { totals: AggregateBillingTotals { @@ -1926,9 +1940,7 @@ mod billing { total_tokens: 391_230, total_usd_micros: Some(12_150_000), }, - model: ModelReference { - id: "Opus 4.6".into(), - }, + model: billing_model(fabro_model::Provider::Anthropic, "claude-opus-4-6"), stages: 18, }, BillingByModel { @@ -1941,9 +1953,7 @@ mod billing { total_tokens: 336_510, total_usd_micros: Some(6_480_000), }, - model: ModelReference { - id: "Gemini 3.1".into(), - }, + model: billing_model(fabro_model::Provider::Gemini, "gemini-3.1-pro-preview"), stages: 9, }, BillingByModel { @@ -1956,9 +1966,7 @@ mod billing { total_tokens: 105_840, total_usd_micros: Some(1_710_000), }, - model: ModelReference { - id: "Codex 5.3".into(), - }, + model: billing_model(fabro_model::Provider::OpenAi, "gpt-5.3-codex"), stages: 9, }, ], diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 2f8844406..017dec794 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -57,7 +57,7 @@ use fabro_llm::types::{ ToolDefinition, }; use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{BilledTokenCounts, Catalog, ModelTestMode, ProviderId}; +use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId}; use fabro_redact::redact_jsonl_line; use fabro_sandbox::daytona::{self, DaytonaSandbox}; use fabro_sandbox::details::sandbox_details; @@ -243,7 +243,7 @@ struct ModelBillingTotals { struct BillingAccumulator { total_runs: i64, total_runtime_secs: f64, - by_model: HashMap, + by_model: HashMap, } pub(crate) type RegistryFactoryOverride = @@ -614,10 +614,7 @@ fn accumulate_billing_rollup( accumulator.total_runs += 1; accumulator.total_runtime_secs += rollup.runtime_ms as f64 / 1000.0; for model in &rollup.by_model { - let entry = accumulator - .by_model - .entry(model.model.model_id.clone()) - .or_default(); + let entry = accumulator.by_model.entry(model.model.clone()).or_default(); entry.stages += model.stages; entry.billing.add_counts(&model.billing); } diff --git a/lib/crates/fabro-server/src/server/handler/billing.rs b/lib/crates/fabro-server/src/server/handler/billing.rs index d14d2cf90..6eee9bbfb 100644 --- a/lib/crates/fabro-server/src/server/handler/billing.rs +++ b/lib/crates/fabro-server/src/server/handler/billing.rs @@ -6,9 +6,8 @@ use fabro_types::{RunProjection, StageHandler, StageProjection, StageState}; use super::super::{ ApiError, AppState, BillingByModel, BillingStageRef, IntoResponse, Json, ListResponse, - ModelReference, PaginationParams, Path, Query, RequiredUser, Response, Router, RunBilling, - RunBillingStage, RunBillingTotals, RunId, State, StatusCode, get, parse_run_id_path, - run_stage_from_stage_id, + PaginationParams, Path, Query, RequiredUser, Response, Router, RunBilling, RunBillingStage, + RunBillingTotals, RunId, State, StatusCode, get, parse_run_id_path, run_stage_from_stage_id, }; pub(super) fn routes() -> Router> { @@ -86,9 +85,7 @@ async fn get_run_billing( .iter() .map(|model| BillingByModel { billing: model.billing.clone(), - model: ModelReference { - id: model.model.model_id.clone(), - }, + model: model.model.clone(), stages: model.stages, }) .collect::>(); @@ -108,11 +105,7 @@ async fn get_run_billing( billing: rollup_stage .map(|stage| stage.billing.clone()) .unwrap_or_default(), - model: rollup_stage - .and_then(|stage| stage.model.as_ref()) - .map(|model| ModelReference { - id: model.model_id.clone(), - }), + model: rollup_stage.and_then(|stage| stage.model.as_ref()).cloned(), runtime_secs: row.runtime_secs, stage: BillingStageRef { id: row.node_id.clone(), diff --git a/lib/crates/fabro-server/src/server/handler/system.rs b/lib/crates/fabro-server/src/server/handler/system.rs index 55809a6d4..785f86d11 100644 --- a/lib/crates/fabro-server/src/server/handler/system.rs +++ b/lib/crates/fabro-server/src/server/handler/system.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use super::super::{ AggregateBilling, AggregateBillingTotals, ApiError, AppState, BilledTokenCounts, - BillingByModel, DfParams, FABRO_VERSION, GithubIntegrationStrategy, IntoResponse, Json, - ModelReference, Path, PruneRunsRequest, PruneRunsResponse, Query, RequiredUser, Response, - Router, RunStatus, State, StatusCode, SystemInfoResponse, SystemRepairRunIssue, - SystemRepairRunsResponse, SystemRunCounts, build_disk_usage_response, build_prune_plan, - delete_run_internal, diagnostics, get, post, resolve_interp_string, spawn_blocking, - system_features, system_sandbox_provider, to_i64, + BillingByModel, DfParams, FABRO_VERSION, GithubIntegrationStrategy, IntoResponse, Json, Path, + PruneRunsRequest, PruneRunsResponse, Query, RequiredUser, Response, Router, RunStatus, State, + StatusCode, SystemInfoResponse, SystemRepairRunIssue, SystemRepairRunsResponse, + SystemRunCounts, build_disk_usage_response, build_prune_plan, delete_run_internal, diagnostics, + get, post, resolve_interp_string, spawn_blocking, system_features, system_sandbox_provider, + to_i64, }; pub(super) fn routes() -> Router> { @@ -502,7 +502,7 @@ async fn get_aggregate_billing( .iter() .map(|(model, totals)| BillingByModel { billing: totals.billing.clone(), - model: ModelReference { id: model.clone() }, + model: model.clone(), stages: totals.stages, }) .collect(); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index a5b967853..ec827967e 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -17,7 +17,7 @@ use fabro_interview::{ }; use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest}; use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelRef, Provider}; +use fabro_model::{Catalog, ModelRef, Provider, Speed}; use fabro_types::settings::ServerAuthMethod; use fabro_types::{ AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph, @@ -3015,7 +3015,8 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() { let stages = body["stages"].as_array().unwrap(); assert_eq!(stages.len(), 1); assert_eq!(stages[0]["stage"]["id"], "verify"); - assert_eq!(stages[0]["model"]["id"], "gpt-new"); + assert_eq!(stages[0]["model"]["provider"], "openai"); + assert_eq!(stages[0]["model"]["model_id"], "gpt-new"); assert_eq!(stages[0]["billing"]["input_tokens"], 300); assert_eq!(stages[0]["billing"]["output_tokens"], 30); assert_eq!(stages[0]["billing"]["total_usd_micros"], 330); @@ -3030,12 +3031,14 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() { assert_eq!(by_model.len(), 2); let old_model = by_model .iter() - .find(|entry| entry["model"]["id"] == "gpt-old") + .find(|entry| entry["model"]["model_id"] == "gpt-old") .unwrap(); let new_model = by_model .iter() - .find(|entry| entry["model"]["id"] == "gpt-new") + .find(|entry| entry["model"]["model_id"] == "gpt-new") .unwrap(); + assert_eq!(old_model["model"]["provider"], "openai"); + assert_eq!(new_model["model"]["provider"], "openai"); assert_eq!(old_model["stages"], 1); assert_eq!(old_model["billing"]["input_tokens"], 100); assert_eq!(new_model["stages"], 1); @@ -8209,6 +8212,86 @@ async fn get_aggregate_billing_returns_zeros_initially() { assert!(body["by_model"].as_array().unwrap().is_empty()); } +#[tokio::test] +async fn get_aggregate_billing_returns_provider_model_speed_identity() { + let state = test_app_state(); + { + let mut agg = state + .aggregate_billing + .lock() + .expect("aggregate billing lock"); + agg.total_runs = 1; + agg.by_model.insert( + ModelRef { + provider: Provider::Anthropic.id(), + model_id: "claude-opus-4-6".to_string(), + speed: None, + }, + ModelBillingTotals { + stages: 1, + billing: BilledTokenCounts { + input_tokens: 10, + output_tokens: 1, + total_tokens: 11, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_usd_micros: Some(11), + }, + }, + ); + agg.by_model.insert( + ModelRef { + provider: Provider::Anthropic.id(), + model_id: "claude-opus-4-6".to_string(), + speed: Some(Speed::Fast), + }, + ModelBillingTotals { + stages: 1, + billing: BilledTokenCounts { + input_tokens: 20, + output_tokens: 2, + total_tokens: 22, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_usd_micros: Some(22), + }, + }, + ); + } + 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; + let by_model = body["by_model"].as_array().unwrap(); + + assert_eq!(by_model.len(), 2); + let standard = by_model + .iter() + .find(|entry| entry["model"]["speed"].is_null()) + .unwrap(); + let fast = by_model + .iter() + .find(|entry| entry["model"]["speed"] == "fast") + .unwrap(); + assert_eq!(standard["model"]["provider"], "anthropic"); + assert_eq!(standard["model"]["model_id"], "claude-opus-4-6"); + assert_eq!(standard["billing"]["input_tokens"], 10); + assert_eq!(fast["model"]["provider"], "anthropic"); + assert_eq!(fast["model"]["model_id"], "claude-opus-4-6"); + assert_eq!(fast["billing"]["input_tokens"], 20); +} + #[test] fn aggregate_billing_counts_projection_rollup_usage_visits() { let mut accumulator = BillingAccumulator::default(); @@ -8227,7 +8310,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { fabro_workflow::ProjectionBillingByModel { model: ModelRef { provider: Provider::OpenAi.id(), - model_id: "gpt-old".to_string(), + model_id: "gpt-5.4".to_string(), speed: None, }, stages: 1, @@ -8244,8 +8327,8 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { fabro_workflow::ProjectionBillingByModel { model: ModelRef { provider: Provider::OpenAi.id(), - model_id: "gpt-new".to_string(), - speed: None, + model_id: "gpt-5.4".to_string(), + speed: Some(Speed::Fast), }, stages: 1, billing: BilledTokenCounts { @@ -8267,10 +8350,45 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!(accumulator.total_runs, 1); assert_eq!(accumulator.total_runtime_secs, 2.0); - assert_eq!(accumulator.by_model["gpt-old"].stages, 1); - assert_eq!(accumulator.by_model["gpt-old"].billing.input_tokens, 100); - assert_eq!(accumulator.by_model["gpt-new"].stages, 1); - assert_eq!(accumulator.by_model["gpt-new"].billing.input_tokens, 200); + assert_eq!(accumulator.by_model.len(), 2); + assert_eq!( + accumulator.by_model[&ModelRef { + provider: Provider::OpenAi.id(), + model_id: "gpt-5.4".to_string(), + speed: None, + }] + .stages, + 1 + ); + assert_eq!( + accumulator.by_model[&ModelRef { + provider: Provider::OpenAi.id(), + model_id: "gpt-5.4".to_string(), + speed: None, + }] + .billing + .input_tokens, + 100 + ); + assert_eq!( + accumulator.by_model[&ModelRef { + provider: Provider::OpenAi.id(), + model_id: "gpt-5.4".to_string(), + speed: Some(Speed::Fast), + }] + .stages, + 1 + ); + assert_eq!( + accumulator.by_model[&ModelRef { + provider: Provider::OpenAi.id(), + model_id: "gpt-5.4".to_string(), + speed: Some(Speed::Fast), + }] + .billing + .input_tokens, + 200 + ); } #[tokio::test] diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index da2af76fc..0d22fd66a 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use super::Event; use super::stored_fields::stored_event_fields; -use crate::outcome::billed_model_usage_from_llm; +use crate::outcome::unpriced_model_usage_from_llm; use crate::stage_scope::StageScope; fn stage_status_from_string(status: &str) -> StageOutcome { @@ -551,11 +551,7 @@ fn event_body_from_event(event: &Event) -> EventBody { usage, tool_call_count, } => { - let requested_speed = model.speed.map(<&'static str>::from); - let provider = fabro_model::Provider::from_id(&model.provider) - .expect("agent message billing currently requires a built-in provider ID"); - let billed = - billed_model_usage_from_llm(&model.model_id, provider, requested_speed, usage); + let billed = unpriced_model_usage_from_llm(model.clone(), usage); let billing = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&billed)); EventBody::AgentMessage(fabro_types::AgentMessageProps { text: text.clone(), @@ -1286,7 +1282,7 @@ mod tests { use chrono::Utc; use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_llm::types::TokenCounts as LlmTokenCounts; - use fabro_model::{ModelRef, Provider}; + use fabro_model::{ModelRef, Provider, ProviderId}; use super::*; use crate::error::Error; @@ -1979,6 +1975,39 @@ mod tests { }); } + #[test] + fn agent_assistant_message_with_custom_provider_keeps_tokens_without_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("custom_proxy"), + model_id: "proxy-model".to_string(), + speed: None, + }, + usage: LlmTokenCounts { + input_tokens: 12, + output_tokens: 34, + ..LlmTokenCounts::default() + }, + tool_call_count: 0, + }, + session_id: Some("ses_agent".to_string()), + parent_session_id: None, + }); + + let EventBody::AgentMessage(message) = stored.body else { + panic!("expected agent message body"); + }; + assert_eq!(message.model.provider, ProviderId::new("custom_proxy")); + assert_eq!(message.model.model_id, "proxy-model"); + assert_eq!(message.billing.input_tokens, 12); + assert_eq!(message.billing.output_tokens, 34); + assert_eq!(message.billing.total_usd_micros, None); + } + #[test] fn agent_cli_cancelled_maps_to_event_body_with_node_id() { let stored = to_run_event(&fixtures::RUN_1, &Event::AgentCliCancelled { diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 77a903d92..4c216e98d 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -14,7 +14,9 @@ use fabro_llm::client::Client; use fabro_llm::types::{Message, ReasoningEffort, Request, Speed, TokenCounts}; use fabro_mcp::config::McpServerSettings; use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter}; +use fabro_model::{ + AgentProfileKind, Catalog, FallbackTarget, ModelRef, Provider, ProviderId, adapter, +}; use fabro_types::settings::run::RunModelControls; use fabro_types::{SessionCapability, StageId}; use tokio::sync::Mutex as TokioMutex; @@ -108,9 +110,9 @@ struct ProviderContext { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct EffectiveRequestControls { - reasoning_effort: Option, - speed: Option, +pub(super) struct EffectiveRequestControls { + pub(super) reasoning_effort: Option, + pub(super) speed: Option, } fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentApiErrorDisposition { @@ -203,7 +205,7 @@ fn default_profile_kind(provider: Provider) -> AgentProfileKind { } } -fn effective_request_controls( +pub(super) fn effective_request_controls( catalog: &Catalog, run_model_controls: &RunModelControls, model: &str, @@ -752,7 +754,7 @@ impl CodergenBackend for AgentApiBackend { let default_provider = self.provider_id.to_string(); - let (response, actual_model, actual_provider) = match result { + let (response, actual_model, actual_provider, actual_speed) = match result { Ok(resp) => ( resp, request.model.clone(), @@ -760,6 +762,7 @@ impl CodergenBackend for AgentApiBackend { .provider .clone() .unwrap_or_else(|| default_provider.clone()), + controls.speed, ), Err(sdk_err) if sdk_err.failover_eligible() && !fallback_chain.is_empty() => { let error_msg = sdk_err.to_string(); @@ -803,7 +806,12 @@ impl CodergenBackend for AgentApiBackend { match client.complete(&fallback_request).await { Ok(resp) => { - found = Some((resp, target.model.clone(), target.provider.clone())); + found = Some(( + resp, + target.model.clone(), + target.provider.clone(), + fallback_controls.speed, + )); break; } Err(err) if err.failover_eligible() => { @@ -821,11 +829,13 @@ impl CodergenBackend for AgentApiBackend { Err(sdk_err) => return Err(Error::Llm(sdk_err)), }; - let actual_provider = actual_provider.parse::().unwrap_or(self.provider); let stage_usage = billed_model_usage_from_llm( - &actual_model, - actual_provider, - node.speed(), + self.catalog.as_ref(), + &ModelRef { + provider: ProviderId::from(actual_provider), + model_id: actual_model, + speed: actual_speed, + }, &response.usage, ); @@ -847,12 +857,6 @@ impl CodergenBackend for AgentApiBackend { let tool_hooks = request.tool_hooks; let cancel_token = request.cancel_token; - let actual_model = node.model().unwrap_or(&self.model).to_string(); - let _actual_provider = node - .provider() - .and_then(|p| p.parse::().ok()) - .unwrap_or(self.provider); - let fidelity = context.fidelity(); let reuse_key = if fidelity == Fidelity::Full { thread_id.map(String::from) @@ -1132,10 +1136,14 @@ impl CodergenBackend for AgentApiBackend { } } + let billing_controls = self.effective_request_controls(session.model(), node)?; let stage_usage = billed_model_usage_from_llm( - &actual_model, - _actual_provider, - node.speed(), + self.catalog.as_ref(), + &ModelRef { + provider: session.provider_id(), + model_id: session.model().to_string(), + speed: billing_controls.speed, + }, &total_usage, ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 129583c83..a510711ed 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -11,7 +11,9 @@ use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider, shell_quote}; use fabro_auth::CredentialResolver; use fabro_graphviz::graph::Node; use fabro_llm::types::TokenCounts; -use fabro_model::Provider; +use fabro_model::catalog::LlmCatalogSettings; +use fabro_model::{Catalog, ModelRef, Provider}; +use fabro_types::settings::run::RunModelControls; use fabro_types::{CommandOutputStream, CommandTermination, LlmBackend}; use fabro_util::time::elapsed_ms; use tokio_util::sync::CancellationToken; @@ -40,6 +42,7 @@ fn cli_failure_detail(stdout: &str, stderr: &str, command: &str) -> String { use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; use super::acp::AgentAcpBackend; +use super::api::effective_request_controls; use super::launch_env::{AgentLaunchEnvRequest, resolve_agent_launch_env}; use super::{changed_files, routing}; use crate::error::Error; @@ -335,6 +338,8 @@ pub struct AgentCliBackend { tool_env: Option>, github_token_refresh_managed: bool, resolver: Option, + run_model_controls: RunModelControls, + catalog: Arc, } impl AgentCliBackend { @@ -346,6 +351,8 @@ impl AgentCliBackend { tool_env: None, github_token_refresh_managed: false, resolver: Some(resolver), + run_model_controls: RunModelControls::default(), + catalog: default_catalog(), } } @@ -357,6 +364,8 @@ impl AgentCliBackend { tool_env: None, github_token_refresh_managed: false, resolver: None, + run_model_controls: RunModelControls::default(), + catalog: default_catalog(), } } @@ -376,6 +385,25 @@ impl AgentCliBackend { self.github_token_refresh_managed = github_token_refresh_managed; self } + + #[must_use] + pub fn with_run_model_controls(mut self, controls: RunModelControls) -> Self { + self.run_model_controls = controls; + self + } + + #[must_use] + pub fn with_catalog(mut self, catalog: Arc) -> Self { + self.catalog = catalog; + self + } +} + +fn default_catalog() -> Arc { + Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .expect("default catalog should build"), + ) } #[async_trait] @@ -408,6 +436,12 @@ impl CodergenBackend for AgentCliBackend { .provider() .and_then(|s| s.parse::().ok()) .unwrap_or(self.provider); + let controls = effective_request_controls( + self.catalog.as_ref(), + &self.run_model_controls, + model, + node, + )?; let cli = AgentCli::for_provider(provider); verify_cli_available(cli, sandbox, &cancel_token).await?; @@ -622,12 +656,19 @@ impl CodergenBackend for AgentCliBackend { let (files_touched, last_file_touched) = changed_files::files_touched_since(sandbox, &files_before).await; - let stage_usage = - billed_model_usage_from_llm(model, provider, node.speed(), &TokenCounts { + let stage_usage = billed_model_usage_from_llm( + self.catalog.as_ref(), + &ModelRef { + provider: provider.id(), + model_id: model.to_string(), + speed: controls.speed, + }, + &TokenCounts { input_tokens: parsed.input_tokens, output_tokens: parsed.output_tokens, ..TokenCounts::default() - }); + }, + ); Ok(CodergenResult::Text { text: parsed.text, diff --git a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs b/lib/crates/fabro-workflow/src/handler/llm/preamble.rs index 4777e150b..c5da0fd29 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/preamble.rs @@ -587,17 +587,25 @@ fn build_summary_preamble( mod tests { use fabro_graphviz::graph::AttrValue; use fabro_llm::types::TokenCounts; - use fabro_model::Provider; + use fabro_model::{Catalog, ModelRef, Provider}; use super::*; use crate::outcome::{BilledModelUsage, billed_model_usage_from_llm}; fn stage_usage(model: &str, input_tokens: i64, output_tokens: i64) -> BilledModelUsage { - billed_model_usage_from_llm(model, Provider::Anthropic, None, &TokenCounts { - input_tokens, - output_tokens, - ..TokenCounts::default() - }) + billed_model_usage_from_llm( + Catalog::builtin(), + &ModelRef { + provider: Provider::Anthropic.id(), + model_id: model.to_string(), + speed: None, + }, + &TokenCounts { + input_tokens, + output_tokens, + ..TokenCounts::default() + }, + ) } // --- truncate mode --- diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/crates/fabro-workflow/src/outcome.rs index 08c163438..2f14537d8 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/fabro-workflow/src/outcome.rs @@ -3,8 +3,7 @@ pub use fabro_core::outcome::{ }; use fabro_llm::types::TokenCounts as LlmTokenCounts; use fabro_model::{ - AnthropicBillingFacts, Catalog, ModelBillingFacts, ModelBillingInput, ModelRef, ModelUsage, - Provider, Speed, TokenCounts, + Catalog, ModelBillingFacts, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, }; pub use fabro_types::BilledModelUsage; @@ -14,20 +13,12 @@ pub type Outcome = fabro_core::Outcome>; #[must_use] pub fn billed_model_usage_from_llm( - model_id: &str, - provider: Provider, - requested_speed: Option<&str>, + catalog: &Catalog, + model: &ModelRef, usage: &LlmTokenCounts, ) -> BilledModelUsage { - let speed = parse_speed(requested_speed); - let provider_id = provider.id(); - let model = ModelRef { - provider: provider_id.clone(), - model_id: model_id.to_string(), - speed, - }; let tokens = token_counts_from_llm_usage(usage); - let facts = billing_facts_for_stage_usage(provider, &tokens); + let facts = catalog.billing_facts_for(model, &tokens); let input = ModelBillingInput { usage: ModelUsage { model: model.clone(), @@ -36,10 +27,8 @@ pub fn billed_model_usage_from_llm( facts, }; - let total_usd_micros = Catalog::builtin() - .get(model_id) - .filter(|candidate| candidate.provider == provider_id) - .and_then(|candidate| candidate.pricing_for(speed)) + let total_usd_micros = catalog + .pricing_for(model) .and_then(|pricing| pricing.bill(&input)) .map(|amount| amount.0); @@ -49,6 +38,19 @@ pub fn billed_model_usage_from_llm( } } +#[must_use] +pub fn unpriced_model_usage_from_llm(model: ModelRef, usage: &LlmTokenCounts) -> BilledModelUsage { + let tokens = token_counts_from_llm_usage(usage); + let facts = ModelBillingFacts::for_provider_id(&model.provider, &tokens); + BilledModelUsage { + input: ModelBillingInput { + usage: ModelUsage { model, tokens }, + facts, + }, + total_usd_micros: None, + } +} + pub trait OutcomeExt: Sized { fn fail_deterministic(reason: impl Into) -> Self; fn fail_classify(reason: impl Into) -> Self; @@ -137,31 +139,26 @@ pub fn format_cost(cost: f64) -> String { format!("${cost:.2}") } -fn parse_speed(speed: Option<&str>) -> Option { - speed.and_then(|value| value.parse::().ok()) -} - fn token_counts_from_llm_usage(usage: &LlmTokenCounts) -> TokenCounts { usage.clone() } -fn billing_facts_for_stage_usage(provider: Provider, tokens: &TokenCounts) -> ModelBillingFacts { - match provider { - Provider::Anthropic => ModelBillingFacts::Anthropic(AnthropicBillingFacts { - cache_write_5m_tokens: tokens.cache_write_tokens, - cache_write_1h_tokens: 0, - }), - other => ModelBillingFacts::for_provider(other), - } -} - #[cfg(test)] mod tests { use fabro_llm::types::TokenCounts; - use fabro_model::Provider; + use fabro_model::catalog::LlmCatalogSettings; + use fabro_model::{Catalog, ModelRef, Provider, ProviderId, Speed}; use super::{OutcomeExt, billed_model_usage_from_llm}; + fn model_ref(provider: ProviderId, model_id: &str, speed: Option) -> ModelRef { + ModelRef { + provider, + model_id: model_id.to_string(), + speed, + } + } + #[test] fn billed_model_usage_from_llm_bills_openai_cached_input_and_reasoning_output() { let usage = TokenCounts { @@ -171,7 +168,11 @@ mod tests { cache_read_tokens: 250_000, ..TokenCounts::default() }; - let billed = billed_model_usage_from_llm("gpt-5.4", Provider::OpenAi, None, &usage); + let billed = billed_model_usage_from_llm( + Catalog::builtin(), + &model_ref(Provider::OpenAi.id(), "gpt-5.4", None), + &usage, + ); assert_eq!(billed.total_usd_micros, Some(3_562_500)); assert_eq!(billed.tokens().output_tokens, 125_000); @@ -198,15 +199,112 @@ mod tests { cache_write_tokens: 30_000, }; let billed = billed_model_usage_from_llm( - "claude-opus-4-6", - Provider::Anthropic, - Some("fast"), + Catalog::builtin(), + &model_ref( + Provider::Anthropic.id(), + "claude-opus-4-6", + Some(Speed::Fast), + ), &usage, ); assert_eq!(billed.total_usd_micros, Some(6_435_000)); } + #[test] + fn billed_model_usage_from_llm_uses_injected_custom_catalog() { + let settings: LlmCatalogSettings = toml::from_str( + r#" +[providers.proxy] +display_name = "Proxy" +adapter = "openai_compatible" +base_url = "https://proxy.example/v1" + +[models.canonical-model] +provider = "proxy" +api_id = "wire-model" +display_name = "Canonical Model" +family = "proxy" +default = true + +[models.canonical-model.limits] +context_window = 1000 + +[models.canonical-model.features] +tools = true +vision = false +reasoning = false + +[models.canonical-model.costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 2.0 +"#, + ) + .unwrap(); + let catalog = Catalog::from_settings(&settings).unwrap(); + let usage = TokenCounts { + input_tokens: 500_000, + output_tokens: 250_000, + ..TokenCounts::default() + }; + + let billed = billed_model_usage_from_llm( + &catalog, + &model_ref(ProviderId::new("proxy"), "canonical-model", None), + &usage, + ); + + assert_eq!(&billed.model().provider, &ProviderId::new("proxy")); + assert_eq!(billed.model_id(), "canonical-model"); + assert_eq!(billed.total_usd_micros, Some(1_000_000)); + } + + #[test] + fn billed_model_usage_from_llm_does_not_bill_provider_api_id() { + let settings: LlmCatalogSettings = toml::from_str( + r#" +[providers.proxy] +display_name = "Proxy" +adapter = "openai_compatible" +base_url = "https://proxy.example/v1" + +[models.canonical-model] +provider = "proxy" +api_id = "wire-model" +display_name = "Canonical Model" +family = "proxy" +default = true + +[models.canonical-model.limits] +context_window = 1000 + +[models.canonical-model.features] +tools = true +vision = false +reasoning = false + +[models.canonical-model.costs] +input_cost_per_mtok = 1.0 +output_cost_per_mtok = 2.0 +"#, + ) + .unwrap(); + let catalog = Catalog::from_settings(&settings).unwrap(); + + let billed = billed_model_usage_from_llm( + &catalog, + &model_ref(ProviderId::new("proxy"), "wire-model", None), + &TokenCounts { + input_tokens: 500_000, + output_tokens: 250_000, + ..TokenCounts::default() + }, + ); + + assert_eq!(billed.model_id(), "wire-model"); + assert_eq!(billed.total_usd_micros, None); + } + #[test] fn billed_model_usage_round_trips_dense_token_counts() { let usage = TokenCounts { @@ -216,8 +314,11 @@ mod tests { cache_read_tokens: 20, cache_write_tokens: 10, }; - let billed = - billed_model_usage_from_llm("claude-opus-4-6", Provider::Anthropic, None, &usage); + let billed = billed_model_usage_from_llm( + Catalog::builtin(), + &model_ref(Provider::Anthropic.id(), "claude-opus-4-6", None), + &usage, + ); assert_eq!(billed.tokens().clone(), usage); } diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index fc3f5003b..3b9250644 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -182,6 +182,8 @@ async fn build_registry( || AgentCliBackend::new_from_env(model.clone(), provider), |resolver| AgentCliBackend::new(model.clone(), provider, resolver), ) + .with_catalog(Arc::clone(&catalog_for_api)) + .with_run_model_controls(model_controls.clone()) .with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed); let acp = cli_resolver .clone() diff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts index 8d28f601f..1f119b1d1 100644 --- a/lib/packages/fabro-api-client/src/api/models-api.ts +++ b/lib/packages/fabro-api-client/src/api/models-api.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech diff --git a/lib/packages/fabro-api-client/src/models/billing-by-model.ts b/lib/packages/fabro-api-client/src/models/billing-by-model.ts index 74f074401..0678eae9e 100644 --- a/lib/packages/fabro-api-client/src/models/billing-by-model.ts +++ b/lib/packages/fabro-api-client/src/models/billing-by-model.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,17 +18,16 @@ import type { BilledTokenCounts } from './billed-token-counts'; // May contain unused imports in some cases // @ts-ignore -import type { ModelReference } from './model-reference'; +import type { BillingModelRef } from './billing-model-ref'; /** * Billing statistics grouped by model. */ export interface BillingByModel { - 'model': ModelReference; + 'model': BillingModelRef; /** * Number of usage-bearing stage visits that used this model. */ 'stages': number; 'billing': BilledTokenCounts; } - diff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts index 37154fb75..4b03f415b 100644 --- a/lib/packages/fabro-api-client/src/models/model.ts +++ b/lib/packages/fabro-api-client/src/models/model.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -67,9 +67,7 @@ export interface Model { */ 'default': boolean; /** - * Whether credential material is present for this model\'s provider on the server (vault entry or environment variable). Does NOT imply the credential is valid or that requests will succeed; call `POST /models/{id}/test` to verify usability. + * Whether credential material is present for this model\'s provider on the server (vault entry or environment variable). Does NOT imply the credential is valid or that requests will succeed; call `POST /models/{id}/test` to verify usability. */ 'configured': boolean; } - - diff --git a/lib/packages/fabro-api-client/src/models/run-billing-stage.ts b/lib/packages/fabro-api-client/src/models/run-billing-stage.ts index a8401d116..5cef24796 100644 --- a/lib/packages/fabro-api-client/src/models/run-billing-stage.ts +++ b/lib/packages/fabro-api-client/src/models/run-billing-stage.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,10 +18,10 @@ import type { BilledTokenCounts } from './billed-token-counts'; // May contain unused imports in some cases // @ts-ignore -import type { BillingStageRef } from './billing-stage-ref'; +import type { BillingModelRef } from './billing-model-ref'; // May contain unused imports in some cases // @ts-ignore -import type { ModelReference } from './model-reference'; +import type { BillingStageRef } from './billing-stage-ref'; // May contain unused imports in some cases // @ts-ignore import type { StageState } from './stage-state'; @@ -31,7 +31,7 @@ import type { StageState } from './stage-state'; */ export interface RunBillingStage { 'stage': BillingStageRef; - 'model': ModelReference | null; + 'model': BillingModelRef | null; 'billing': BilledTokenCounts; /** * Wall-clock runtime in seconds, summed across every visit of this node. @@ -43,6 +43,3 @@ export interface RunBillingStage { 'started_at'?: string | null; 'state'?: StageState | null; } - - -