Skip to main content

codewhale_config/
pricing.rs

1//! Provider/offering-scoped pricing projection with provenance (#3085).
2//!
3//! Network-free. Maps Models.dev offering `cost` (and live / user-override
4//! rows) into pricing rows that carry explicit **provenance**, **currency**, and
5//! **effective-at** metadata, plus a pure cost estimator over normalized token
6//! usage. UI display (`CostDisplay`) and provider usage-payload parsing live
7//! above this layer and are out of scope here.
8//!
9//! Boundary with the route layer: this models *pricing* — offering-owned,
10//! per-token unit prices. The coarse route-facing meter shape already exists as
11//! [`crate::route::PricingSku`]
12//! (`Token` / `SubscriptionQuota` / `AccountCredits` / `LocalOrNotApplicable` /
13//! `UnknownOrStale`); [`OfferingPricing::to_route_sku`] and
14//! [`route_pricing_sku`] bridge to it.
15//!
16//! Honesty rule (#2608 / #3085): pricing is never assumed. A route with no
17//! sourced price yields `None` here and `UnknownOrStale` at the route layer —
18//! never a fabricated token price, and never an implicit "free" for
19//! local/custom/subscription routes.
20
21use serde::{Deserialize, Serialize};
22
23use crate::catalog::{CatalogOffering, CatalogSource};
24use crate::models_dev::ModelsDevCost;
25use crate::route::PricingSku;
26
27/// Billing currency for a pricing row. Models.dev publishes USD per-million
28/// costs; other currencies arrive via provider docs or user overrides.
29#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Currency {
32    #[default]
33    Usd,
34    Cny,
35    /// An ISO-4217-style code CodeWhale does not special-case.
36    Other(String),
37}
38
39/// Where a pricing row came from. Retained so the UI can show provenance and so
40/// stale/unknown prices are never silently treated as authoritative.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "source", rename_all = "snake_case")]
43pub enum PricingProvenance {
44    /// Seeded from a bundled Models.dev catalog snapshot.
45    ModelsDevBundled,
46    /// From a provider live `/models` (or pricing) refresh.
47    ProviderLive,
48    /// From provider documentation / a hand-sourced seed. Set only by callers
49    /// constructing rows directly; `from_catalog_offering` never produces this
50    /// (Models.dev-sourced rows map to `ModelsDevBundled` / `ProviderLive`).
51    ProviderDocs,
52    /// User-supplied override (custom endpoint, enterprise terms, local route).
53    UserOverride,
54    /// No sourced price.
55    Unknown,
56}
57
58/// Normalized token usage for a single turn, in canonical billable classes.
59///
60/// Producing this from provider-specific usage payloads (Chat Completions,
61/// Responses, Anthropic) is a separate concern; this layer only consumes it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
63pub struct TokenUsage {
64    /// Non-cached input (prompt) tokens.
65    pub input: u64,
66    /// Output (completion) tokens, including reasoning output.
67    pub output: u64,
68    /// Cache-read (cache-hit) input tokens, billed at the cache-read rate.
69    pub cache_read: u64,
70    /// Cache-write (cache-creation) tokens, billed at the cache-write rate.
71    pub cache_write: u64,
72}
73
74/// A provider/offering-scoped pricing row.
75///
76/// Prices are per million tokens in [`Currency`]. Any field may be unknown
77/// (`None`); [`OfferingPricing::estimate_cost`] refuses to invent a number for a
78/// used class whose price is unknown.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct OfferingPricing {
81    /// Provider id serving the offering.
82    pub provider: String,
83    /// Provider-owned wire id the price applies to.
84    pub wire_model_id: String,
85    /// Canonical model identity, when the offering carries one.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub canonical_model: Option<String>,
88    /// Billing currency.
89    pub currency: Currency,
90    /// Input price per million tokens.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub input_per_million: Option<f64>,
93    /// Output price per million tokens.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub output_per_million: Option<f64>,
96    /// Cache-read price per million tokens.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub cache_read_per_million: Option<f64>,
99    /// Cache-write price per million tokens.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub cache_write_per_million: Option<f64>,
102    /// Where the price came from.
103    pub provenance: PricingProvenance,
104    /// Unix seconds the price was fetched / became effective, when known.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub effective_at: Option<u64>,
107}
108
109impl OfferingPricing {
110    /// Derive a pricing row from a catalog offering's `cost`, when priced.
111    ///
112    /// Returns `None` when the offering carries no cost, or a cost object with
113    /// no concrete price field — those routes are *unknown*, not free, and the
114    /// caller should render them as such (see [`route_pricing_sku`]).
115    ///
116    /// Models.dev `cost` values are USD per million tokens, so the currency is
117    /// [`Currency::Usd`]; provenance and `effective_at` follow the offering's
118    /// [`CatalogSource`].
119    #[must_use]
120    pub fn from_catalog_offering(offering: &CatalogOffering) -> Option<Self> {
121        let cost = offering.cost.as_ref()?;
122        if cost.input.is_none()
123            && cost.output.is_none()
124            && cost.cache_read.is_none()
125            && cost.cache_write.is_none()
126        {
127            return None;
128        }
129        Some(Self {
130            provider: offering.provider.clone(),
131            wire_model_id: offering.wire_model_id.clone(),
132            canonical_model: offering.canonical_model.clone(),
133            currency: Currency::Usd,
134            input_per_million: cost.input,
135            output_per_million: cost.output,
136            cache_read_per_million: cost.cache_read,
137            cache_write_per_million: cost.cache_write,
138            provenance: provenance_from_source(&offering.source),
139            effective_at: effective_at_from_source(&offering.source),
140        })
141    }
142
143    /// Whether any per-token price is known.
144    #[must_use]
145    pub fn has_any_price(&self) -> bool {
146        self.input_per_million.is_some()
147            || self.output_per_million.is_some()
148            || self.cache_read_per_million.is_some()
149            || self.cache_write_per_million.is_some()
150    }
151
152    /// Whether this price is older than `max_age_secs` at `now_unix`.
153    ///
154    /// Rows without an `effective_at` (bundled snapshot / user override) carry
155    /// no fetch clock and are not considered age-stale here; live rows are.
156    #[must_use]
157    pub fn is_stale(&self, now_unix: u64, max_age_secs: u64) -> bool {
158        match self.effective_at {
159            Some(t) => now_unix.saturating_sub(t) >= max_age_secs,
160            None => false,
161        }
162    }
163
164    /// Estimate the cost of `usage` in this row's [`Currency`].
165    ///
166    /// Returns `None` if any usage class with a non-zero token count has an
167    /// unknown price — the estimate would otherwise silently under-report. With
168    /// all-zero usage the cost is `Some(0.0)`.
169    #[must_use]
170    pub fn estimate_cost(&self, usage: &TokenUsage) -> Option<f64> {
171        let mut total = 0.0_f64;
172        for (tokens, price) in [
173            (usage.input, self.input_per_million),
174            (usage.output, self.output_per_million),
175            (usage.cache_read, self.cache_read_per_million),
176            (usage.cache_write, self.cache_write_per_million),
177        ] {
178            if tokens > 0 {
179                let price = price?;
180                // Per-turn token counts are far below 2^53, so this cast is
181                // exact; revisit if TokenUsage ever aggregates across sessions.
182                total += (tokens as f64 / 1_000_000.0) * price;
183            }
184        }
185        Some(total)
186    }
187
188    /// Project to the coarse route-facing meter shape.
189    ///
190    /// Returns [`PricingSku::Token`] only when an input or output rate is known.
191    /// The route-layer `Token` shape carries only input/output rates, so a row
192    /// priced *only* on cache classes would become a `Token` with no visible
193    /// rates — misleading at the route layer. Such rows degrade to
194    /// [`PricingSku::UnknownOrStale`] here while their cache rates remain usable
195    /// through [`OfferingPricing::estimate_cost`].
196    #[must_use]
197    pub fn to_route_sku(&self) -> PricingSku {
198        if self.input_per_million.is_none() && self.output_per_million.is_none() {
199            return PricingSku::UnknownOrStale;
200        }
201        PricingSku::Token {
202            input_per_mtok: self.input_per_million,
203            output_per_mtok: self.output_per_million,
204        }
205    }
206}
207
208/// The honest route-facing pricing meter for a catalog offering.
209///
210/// An offering with a usable input/output rate becomes [`PricingSku::Token`];
211/// everything else — no cost, a cost object with no concrete price, or a
212/// cache-only price — becomes [`PricingSku::UnknownOrStale`] rather than a
213/// fabricated zero price. (`from_catalog_offering` collapses the unpriced case
214/// to `None`; `to_route_sku` collapses the cache-only case.)
215#[must_use]
216pub fn route_pricing_sku(offering: &CatalogOffering) -> PricingSku {
217    OfferingPricing::from_catalog_offering(offering)
218        .map_or(PricingSku::UnknownOrStale, |pricing| pricing.to_route_sku())
219}
220
221/// The honest route-facing pricing meter for a raw Models.dev `cost` block.
222///
223/// Same honesty rule as [`route_pricing_sku`], but for callers that hold a
224/// [`ModelsDevCost`] directly (the route-offering builders in
225/// [`crate::models_dev`]) rather than a full [`CatalogOffering`]. An absent or
226/// concretely-empty cost, or a cache-only cost, yields
227/// [`PricingSku::UnknownOrStale`]; only a usable input/output rate yields
228/// [`PricingSku::Token`].
229#[must_use]
230pub(crate) fn route_pricing_sku_from_cost(cost: Option<&ModelsDevCost>) -> PricingSku {
231    let Some(cost) = cost else {
232        return PricingSku::UnknownOrStale;
233    };
234    if cost.input.is_none() && cost.output.is_none() {
235        // No input/output rate: a cache-only or empty cost would render as a
236        // rate-less `Token` at the route layer, so it stays honestly unknown.
237        return PricingSku::UnknownOrStale;
238    }
239    PricingSku::Token {
240        input_per_mtok: cost.input,
241        output_per_mtok: cost.output,
242    }
243}
244
245fn provenance_from_source(source: &CatalogSource) -> PricingProvenance {
246    match source {
247        CatalogSource::Bundled => PricingProvenance::ModelsDevBundled,
248        CatalogSource::Live { .. } => PricingProvenance::ProviderLive,
249        CatalogSource::UserOverride => PricingProvenance::UserOverride,
250    }
251}
252
253fn effective_at_from_source(source: &CatalogSource) -> Option<u64> {
254    match source {
255        CatalogSource::Live { fetched_at, .. } => Some(*fetched_at),
256        CatalogSource::Bundled | CatalogSource::UserOverride => None,
257    }
258}
259
260#[cfg(test)]
261mod tests;