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 the services provider registry
12//!      — the route provider's catalog entry, else any provider that serves it.
13//!      The 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::services::{GatewayConfig, ModelPricing, ProviderRegistry};
22
23pub fn resolve(
24    provider: &str,
25    candidates: &[&str],
26    gateway: Option<&GatewayConfig>,
27    registry: &ProviderRegistry,
28) -> ModelPricing {
29    for model in candidates.iter().filter(|m| !m.is_empty()) {
30        if let Some(p) = lookup(model, gateway, registry) {
31            return p;
32        }
33    }
34
35    tracing::warn!(
36        provider = provider,
37        candidates = ?candidates,
38        "Gateway pricing lookup: no override and no registry entry — cost_microdollars will be 0"
39    );
40    ModelPricing::default()
41}
42
43fn lookup(
44    model: &str,
45    gateway: Option<&GatewayConfig>,
46    registry: &ProviderRegistry,
47) -> Option<ModelPricing> {
48    if let Some(gw) = gateway
49        && let Some(route) = gw.find_route(model)
50        && let Some(p) = route.pricing
51    {
52        return Some(p);
53    }
54    registry_pricing(registry, gateway, model)
55}
56
57fn registry_pricing(
58    registry: &ProviderRegistry,
59    gateway: Option<&GatewayConfig>,
60    model: &str,
61) -> Option<ModelPricing> {
62    if let Some(route) = gateway.and_then(|gw| gw.find_route(model))
63        && let Some(m) = route
64            .resolve(registry)
65            .and_then(|entry| entry.find_model(model))
66    {
67        return Some(m.pricing);
68    }
69    registry
70        .providers
71        .iter()
72        .find_map(|entry| entry.find_model(model))
73        .map(|m| m.pricing)
74}
75
76#[must_use]
77pub fn cost_microdollars(pricing: ModelPricing, tokens: CostTokens) -> i64 {
78    let rate = |count: u32, per_million: f64| (f64::from(count) / 1_000_000.0) * per_million;
79    let total = rate(tokens.input, pricing.input_per_million)
80        + rate(tokens.output, pricing.output_per_million)
81        + rate(tokens.cache_read, pricing.cache_read_per_million)
82        + rate(tokens.cache_creation, pricing.cache_write_per_million);
83    (total * 1_000_000.0).round() as i64
84}
85
86/// The four billable token counts of one request, kept as a struct so a caller
87/// cannot silently transpose two same-typed arguments.
88#[derive(Debug, Clone, Copy, Default)]
89pub struct CostTokens {
90    pub input: u32,
91    pub output: u32,
92    pub cache_read: u32,
93    pub cache_creation: u32,
94}