Skip to main content

systemprompt_api/services/gateway/
pricing.rs

1//! Pricing resolution for gateway requests.
2//!
3//! `candidates` is tried in priority order — typically the provider-echoed
4//! served model first, then the route's upstream model, then the
5//! client-requested model. A provider that echoes a dated alias
6//! (`gpt-5-mini-2025-08-07`) absent from the catalog must still bill against
7//! the configured model, so the first candidate that resolves wins. For each
8//! candidate, resolution is top-down:
9//!   1. Profile `GatewayRoute.pricing` whose `model_pattern` matches (operator
10//!      override, the strongest "we pay a custom rate here" signal).
11//!   2. The matching `ProviderModel.pricing` in `profile.providers` — the route
12//!      provider's catalog entry, else any provider that serves it. The
13//!      provider registry is the single source of model pricing.
14//!
15//! If no candidate resolves, emit a WARN and return zero pricing — a real
16//! configuration gap, not noise to silence.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use systemprompt_models::profile::{GatewayConfig, ProviderRegistry};
22use systemprompt_models::services::ModelPricing;
23
24pub fn resolve(
25    provider: &str,
26    candidates: &[&str],
27    gateway: Option<&GatewayConfig>,
28    registry: &ProviderRegistry,
29) -> ModelPricing {
30    for model in candidates.iter().filter(|m| !m.is_empty()) {
31        if let Some(p) = lookup(model, gateway, registry) {
32            return p;
33        }
34    }
35
36    tracing::warn!(
37        provider = provider,
38        candidates = ?candidates,
39        "Gateway pricing lookup: no override and no registry entry — cost_microdollars will be 0"
40    );
41    ModelPricing::default()
42}
43
44fn lookup(
45    model: &str,
46    gateway: Option<&GatewayConfig>,
47    registry: &ProviderRegistry,
48) -> Option<ModelPricing> {
49    if let Some(gw) = gateway
50        && let Some(route) = gw.find_route(model)
51        && let Some(p) = route.pricing
52    {
53        return Some(p);
54    }
55    registry_pricing(registry, gateway, model)
56}
57
58fn registry_pricing(
59    registry: &ProviderRegistry,
60    gateway: Option<&GatewayConfig>,
61    model: &str,
62) -> Option<ModelPricing> {
63    if let Some(route) = gateway.and_then(|gw| gw.find_route(model))
64        && let Some(m) = route
65            .resolve(registry)
66            .and_then(|entry| entry.find_model(model))
67    {
68        return Some(m.pricing);
69    }
70    registry
71        .providers
72        .iter()
73        .find_map(|entry| entry.find_model(model))
74        .map(|m| m.pricing)
75}
76
77#[must_use]
78pub fn cost_microdollars(pricing: ModelPricing, tokens: CostTokens) -> i64 {
79    let rate = |count: u32, per_million: f64| (f64::from(count) / 1_000_000.0) * per_million;
80    let total = rate(tokens.input, pricing.input_per_million)
81        + rate(tokens.output, pricing.output_per_million)
82        + rate(tokens.cache_read, pricing.cache_read_per_million)
83        + rate(tokens.cache_creation, pricing.cache_write_per_million);
84    (total * 1_000_000.0).round() as i64
85}
86
87/// The four billable token counts of one request, kept as a struct so a caller
88/// cannot silently transpose two same-typed arguments.
89#[derive(Debug, Clone, Copy, Default)]
90pub struct CostTokens {
91    pub input: u32,
92    pub output: u32,
93    pub cache_read: u32,
94    pub cache_creation: u32,
95}