mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-24 00:51:19 +00:00
feat(billing): price usage by catalog model speed
This commit is contained in:
parent
b969b02026
commit
c300d32a82
24 changed files with 1050 additions and 274 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", &[]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue