feat(billing): price usage by catalog model speed

This commit is contained in:
Bryan Helmkamp 2026-05-12 21:46:02 -04:00
parent b969b02026
commit c300d32a82
No known key found for this signature in database
24 changed files with 1050 additions and 274 deletions

View file

@ -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");

View file

@ -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,

View file

@ -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.

View file

@ -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", &[]),

View file

@ -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,

View file

@ -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::<BillingModelRef, ModelRef>();
assert_same_type::<BillingSpeed, Speed>();
}
fn assert_same_type<A: 'static, B: 'static>() {
assert_eq!(
TypeId::of::<A>(),
TypeId::of::<B>(),
"{} should be the same type as {}",
type_name::<A>(),
type_name::<B>()
);
}
#[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!({

View file

@ -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,

View file

@ -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<ModelPricing> {
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<Speed>,
) -> Option<ModelCosts> {
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<Speed>) -> ModelRef {
@ -417,104 +526,164 @@ impl Model {
#[must_use]
pub fn pricing_for(&self, speed: Option<Speed>) -> Option<ModelPricing> {
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<Speed>,
costs: &ModelCosts,
) -> Option<ModelPricing> {
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<PricePerMTok>,
) -> 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<PricePerMTok>,
) -> 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<PricePerMTok>,
) -> 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<UsdMicros> {
@ -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 {

View file

@ -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"

View file

@ -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,
},
],

View file

@ -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<String, ModelBillingTotals>,
by_model: HashMap<ModelRef, ModelBillingTotals>,
}
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);
}

View file

@ -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<Arc<AppState>> {
@ -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::<Vec<_>>();
@ -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(),

View file

@ -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<Arc<AppState>> {
@ -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();

View file

@ -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]

View file

@ -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 {

View file

@ -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<ReasoningEffort>,
speed: Option<Speed>,
pub(super) struct EffectiveRequestControls {
pub(super) reasoning_effort: Option<ReasoningEffort>,
pub(super) speed: Option<Speed>,
}
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::<Provider>().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::<Provider>().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,
);

View file

@ -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<Arc<dyn ToolEnvProvider>>,
github_token_refresh_managed: bool,
resolver: Option<CredentialResolver>,
run_model_controls: RunModelControls,
catalog: Arc<Catalog>,
}
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<Catalog>) -> Self {
self.catalog = catalog;
self
}
}
fn default_catalog() -> Arc<Catalog> {
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::<Provider>().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,

View file

@ -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 ---

View file

@ -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<Option<BilledModelUsage>>;
#[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<String>) -> Self;
fn fail_classify(reason: impl Into<String>) -> Self;
@ -137,31 +139,26 @@ pub fn format_cost(cost: f64) -> String {
format!("${cost:.2}")
}
fn parse_speed(speed: Option<&str>) -> Option<Speed> {
speed.and_then(|value| value.parse::<Speed>().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<Speed>) -> 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);
}

View file

@ -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()

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}