返回 CodeWhale
pricing.rs
根目录 / crates / config / src / 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
21 use serde::{Deserialize, Serialize};
22
23 use crate::catalog::{CatalogOffering, CatalogSource};
24 use crate::models_dev::ModelsDevCost;
25 use 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")]
31 pub 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")]
43 pub 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 impl PricingProvenance {
59 /// Stable, non-localized identifier for logs, JSON, and scorecards.
60 #[must_use]
61 pub fn label(&self) -> &'static str {
62 match self {
63 Self::ModelsDevBundled => "models_dev_bundled",
64 Self::ProviderLive => "provider_live",
65 Self::ProviderDocs => "provider_docs",
66 Self::UserOverride => "user_override",
67 Self::Unknown => "unknown",
68 }
69 }
70
71 /// Whether this provenance may be presented as an authoritative published
72 /// price without further freshness checks.
73 ///
74 /// [`Self::ProviderLive`] is deliberately excluded: a live row is only
75 /// authoritative while it is fresh *and* was fetched from the endpoint the
76 /// turn was actually served on. Callers must clear it through
77 /// [`OfferingPricing::live_pricing_defect`] first.
78 #[must_use]
79 pub fn is_authoritative_without_freshness_check(&self) -> bool {
80 matches!(
81 self,
82 Self::ModelsDevBundled | Self::ProviderDocs | Self::UserOverride
83 )
84 }
85 }
86
87 /// Default freshness window for a `ProviderLive` pricing row, in seconds.
88 ///
89 /// A provider `/models` refresh is a snapshot of a mutable price list. Past
90 /// this age CodeWhale stops calling the row authoritative rather than billing
91 /// against a rate the provider may have already changed.
92 pub const LIVE_PRICING_MAX_AGE_SECS: u64 = 24 * 60 * 60;
93
94 /// Why a `ProviderLive` pricing row cannot be treated as authoritative.
95 ///
96 /// Each variant is a non-secret receipt: fingerprints are FNV digests of a
97 /// normalized base URL (see [`crate::catalog::base_url_fingerprint`]), never the
98 /// URL itself, so these can be logged and serialized freely.
99 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100 #[serde(tag = "defect", rename_all = "snake_case")]
101 pub enum LivePricingDefect {
102 /// The row is older than the caller's freshness window.
103 Stale { age_secs: u64, max_age_secs: u64 },
104 /// The row was fetched from a different endpoint than the turn was served
105 /// on, so it prices a different billing surface.
106 EndpointMismatch {
107 row_fingerprint: String,
108 route_fingerprint: String,
109 },
110 /// The row claims live provenance but carries no endpoint fingerprint, so
111 /// it cannot be matched to the route that is being priced.
112 MissingEndpointFingerprint,
113 /// The row claims live provenance but carries no fetch timestamp, so its
114 /// age cannot be established.
115 MissingTimestamp,
116 /// The caller could not establish which endpoint the turn was served on, so
117 /// a live row cannot be confirmed to price that route.
118 UnknownRouteEndpoint,
119 }
120
121 impl LivePricingDefect {
122 /// Stable, non-localized identifier for logs, JSON, and scorecards.
123 #[must_use]
124 pub fn label(&self) -> &'static str {
125 match self {
126 Self::Stale { .. } => "live_pricing_stale",
127 Self::EndpointMismatch { .. } => "live_pricing_endpoint_mismatch",
128 Self::MissingEndpointFingerprint => "live_pricing_missing_endpoint_fingerprint",
129 Self::MissingTimestamp => "live_pricing_missing_timestamp",
130 Self::UnknownRouteEndpoint => "live_pricing_unknown_route_endpoint",
131 }
132 }
133 }
134
135 /// Normalized token usage for a single turn, in canonical billable classes.
136 ///
137 /// Producing this from provider-specific usage payloads (Chat Completions,
138 /// Responses, Anthropic) is a separate concern; this layer only consumes it.
139 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
140 pub struct TokenUsage {
141 /// Non-cached input (prompt) tokens.
142 pub input: u64,
143 /// Total billable output (completion) tokens.
144 ///
145 /// Providers report reasoning tokens as a *subset* of the completion token
146 /// count (OpenAI `output_tokens_details.reasoning_tokens` ⊆ `output_tokens`,
147 /// Chat Completions `completion_tokens_details.reasoning_tokens` ⊆
148 /// `completion_tokens`), so a normalizer must never add reasoning tokens on
149 /// top of this field — that double-bills every reasoning turn.
150 pub output: u64,
151 /// Cache-read (cache-hit) input tokens, billed at the cache-read rate.
152 pub cache_read: u64,
153 /// Cache-write (cache-creation) tokens, billed at the cache-write rate.
154 pub cache_write: u64,
155 }
156
157 /// A canonical billable token class.
158 ///
159 /// Used to report *which* class of a turn's usage lacked a published price, so
160 /// an unpriced turn can be audited instead of silently dropping out of a total.
161 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
162 #[serde(rename_all = "snake_case")]
163 pub enum TokenClass {
164 Input,
165 Output,
166 CacheRead,
167 CacheWrite,
168 }
169
170 impl TokenClass {
171 /// Every class, in reporting order.
172 pub const ALL: [Self; 4] = [Self::Input, Self::Output, Self::CacheRead, Self::CacheWrite];
173
174 /// Stable, non-localized identifier for logs, JSON, and scorecards.
175 #[must_use]
176 pub fn label(self) -> &'static str {
177 match self {
178 Self::Input => "input",
179 Self::Output => "output",
180 Self::CacheRead => "cache_read",
181 Self::CacheWrite => "cache_write",
182 }
183 }
184
185 /// This class's token count within `usage`.
186 #[must_use]
187 pub fn tokens(self, usage: &TokenUsage) -> u64 {
188 match self {
189 Self::Input => usage.input,
190 Self::Output => usage.output,
191 Self::CacheRead => usage.cache_read,
192 Self::CacheWrite => usage.cache_write,
193 }
194 }
195 }
196
197 /// A provider/offering-scoped pricing row.
198 ///
199 /// Prices are per million tokens in [`Currency`]. Any field may be unknown
200 /// (`None`); [`OfferingPricing::estimate_cost`] refuses to invent a number for a
201 /// used class whose price is unknown.
202 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203 pub struct OfferingPricing {
204 /// Provider id serving the offering.
205 pub provider: String,
206 /// Provider-owned wire id the price applies to.
207 pub wire_model_id: String,
208 /// Canonical model identity, when the offering carries one.
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub canonical_model: Option<String>,
211 /// Billing currency.
212 pub currency: Currency,
213 /// Input price per million tokens.
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub input_per_million: Option<f64>,
216 /// Output price per million tokens.
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub output_per_million: Option<f64>,
219 /// Cache-read price per million tokens.
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub cache_read_per_million: Option<f64>,
222 /// Cache-write price per million tokens.
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub cache_write_per_million: Option<f64>,
225 /// Where the price came from.
226 pub provenance: PricingProvenance,
227 /// Unix seconds the price was fetched / became effective, when known.
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub effective_at: Option<u64>,
230 /// Fingerprint of the base URL this price was fetched from, for
231 /// [`PricingProvenance::ProviderLive`] rows.
232 ///
233 /// This is the same non-secret SHA-256 digest the catalog cache scopes on
234 /// (see [`crate::catalog::base_url_fingerprint`]) — never the URL itself.
235 /// It exists so a live row can be proven to price the endpoint a turn was
236 /// actually served on; a row whose fingerprint does not match the route
237 /// is a different billing surface, not a fresher price for this one.
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub endpoint_fingerprint: Option<String>,
240 }
241
242 impl OfferingPricing {
243 /// Derive a pricing row from a catalog offering's `cost`, when priced.
244 ///
245 /// Returns `None` when the offering carries no cost, or a cost object with
246 /// no concrete price field — those routes are *unknown*, not free, and the
247 /// caller should render them as such (see [`route_pricing_sku`]).
248 ///
249 /// Models.dev `cost` values are USD per million tokens, so the currency is
250 /// [`Currency::Usd`]; provenance and `effective_at` follow the offering's
251 /// [`CatalogSource`].
252 #[must_use]
253 pub fn from_catalog_offering(offering: &CatalogOffering) -> Option<Self> {
254 let cost = offering.cost.as_ref()?;
255 // A provider/catalog price is untrusted numeric input. Reject the
256 // entire row when any published class is NaN, infinite, or negative;
257 // accepting only the apparently valid fields would turn a malformed
258 // row into a silently incomplete (or negative) bill.
259 if !catalog_cost_is_valid(cost) {
260 return None;
261 }
262 if cost.input.is_none()
263 && cost.output.is_none()
264 && cost.cache_read.is_none()
265 && cost.cache_write.is_none()
266 {
267 return None;
268 }
269 Some(Self {
270 provider: offering.provider.clone(),
271 wire_model_id: offering.wire_model_id.clone(),
272 canonical_model: offering.canonical_model.clone(),
273 currency: Currency::Usd,
274 input_per_million: cost.input,
275 output_per_million: cost.output,
276 cache_read_per_million: cost.cache_read,
277 cache_write_per_million: cost.cache_write,
278 provenance: provenance_from_source(&offering.source),
279 effective_at: effective_at_from_source(&offering.source),
280 endpoint_fingerprint: endpoint_fingerprint_from_source(&offering.source),
281 })
282 }
283
284 /// Whether any per-token price is known.
285 #[must_use]
286 pub fn has_any_price(&self) -> bool {
287 self.input_per_million.is_some()
288 || self.output_per_million.is_some()
289 || self.cache_read_per_million.is_some()
290 || self.cache_write_per_million.is_some()
291 }
292
293 /// Whether this price is older than `max_age_secs` at `now_unix`.
294 ///
295 /// Rows without an `effective_at` (bundled snapshot / user override) carry
296 /// no fetch clock and are not considered age-stale here; live rows are.
297 #[must_use]
298 pub fn is_stale(&self, now_unix: u64, max_age_secs: u64) -> bool {
299 match self.effective_at {
300 Some(t) => now_unix.saturating_sub(t) >= max_age_secs,
301 None => false,
302 }
303 }
304
305 /// Why this row cannot be trusted as an authoritative live price, if so.
306 ///
307 /// Returns `None` for rows that are not [`PricingProvenance::ProviderLive`]
308 /// (a bundled snapshot, a documented hand price, or a user override carries
309 /// no fetch clock to go stale against) and for live rows that are both
310 /// fresh and fingerprint-matched to `route_endpoint_fingerprint`.
311 ///
312 /// A live row with any defect must not be labelled `provider_live` nor used
313 /// as complete pricing: it is either older than `max_age_secs` or priced for
314 /// a different endpoint. Callers fail closed and receipt the returned
315 /// defect. `route_endpoint_fingerprint` of `None` means the caller could not
316 /// determine the endpoint at all, which is itself a defect — a live row can
317 /// never be *confirmed* to price an unknown route.
318 #[must_use]
319 pub fn live_pricing_defect(
320 &self,
321 route_endpoint_fingerprint: Option<&str>,
322 now_unix: Option<u64>,
323 max_age_secs: u64,
324 ) -> Option<LivePricingDefect> {
325 if self.provenance != PricingProvenance::ProviderLive {
326 return None;
327 }
328 let Some(row_fingerprint) = self.endpoint_fingerprint.as_deref() else {
329 return Some(LivePricingDefect::MissingEndpointFingerprint);
330 };
331 let Some(route_fingerprint) = route_endpoint_fingerprint else {
332 return Some(LivePricingDefect::UnknownRouteEndpoint);
333 };
334 if row_fingerprint != route_fingerprint {
335 return Some(LivePricingDefect::EndpointMismatch {
336 row_fingerprint: row_fingerprint.to_string(),
337 route_fingerprint: route_fingerprint.to_string(),
338 });
339 }
340 let Some(effective_at) = self.effective_at else {
341 return Some(LivePricingDefect::MissingTimestamp);
342 };
343 // Without a clock the age is unknowable, so the row stays unproven
344 // rather than being assumed fresh.
345 let Some(now_unix) = now_unix else {
346 return Some(LivePricingDefect::MissingTimestamp);
347 };
348 let age_secs = now_unix.saturating_sub(effective_at);
349 if age_secs >= max_age_secs {
350 return Some(LivePricingDefect::Stale {
351 age_secs,
352 max_age_secs,
353 });
354 }
355 None
356 }
357
358 /// Per-million price for one canonical class, when published.
359 #[must_use]
360 pub fn price_per_million(&self, class: TokenClass) -> Option<f64> {
361 match class {
362 TokenClass::Input => self.input_per_million,
363 TokenClass::Output => self.output_per_million,
364 TokenClass::CacheRead => self.cache_read_per_million,
365 TokenClass::CacheWrite => self.cache_write_per_million,
366 }
367 }
368
369 /// Classes this turn actually used that carry no published price.
370 ///
371 /// Non-empty means [`Self::estimate_cost`] fails closed for this usage; the
372 /// returned classes are exactly the reason why, so callers can report the
373 /// gap instead of presenting a silently under-counted total.
374 #[must_use]
375 pub fn unpriced_used_classes(&self, usage: &TokenUsage) -> Vec<TokenClass> {
376 TokenClass::ALL
377 .into_iter()
378 .filter(|class| class.tokens(usage) > 0 && self.price_per_million(*class).is_none())
379 .collect()
380 }
381
382 /// Estimate the cost of `usage` in this row's [`Currency`].
383 ///
384 /// Returns `None` if any usage class with a non-zero token count has an
385 /// unknown price — the estimate would otherwise silently under-report. With
386 /// all-zero usage the cost is `Some(0.0)`.
387 #[must_use]
388 pub fn estimate_cost(&self, usage: &TokenUsage) -> Option<f64> {
389 let mut total = 0.0_f64;
390 for class in TokenClass::ALL {
391 let tokens = class.tokens(usage);
392 if tokens > 0 {
393 let price = self.price_per_million(class)?;
394 // Per-turn token counts are far below 2^53, so this cast is
395 // exact; revisit if TokenUsage ever aggregates across sessions.
396 let component = (tokens as f64 / 1_000_000.0) * price;
397 if !component.is_finite() || component < 0.0 {
398 return None;
399 }
400 total += component;
401 if !total.is_finite() || total < 0.0 {
402 return None;
403 }
404 }
405 }
406 Some(total)
407 }
408
409 /// Project to the coarse route-facing meter shape.
410 ///
411 /// Returns [`PricingSku::Token`] only when an input or output rate is known.
412 /// The route-layer `Token` shape carries only input/output rates, so a row
413 /// priced *only* on cache classes would become a `Token` with no visible
414 /// rates — misleading at the route layer. Such rows degrade to
415 /// [`PricingSku::UnknownOrStale`] here while their cache rates remain usable
416 /// through [`OfferingPricing::estimate_cost`].
417 #[must_use]
418 pub fn to_route_sku(&self) -> PricingSku {
419 if self.input_per_million.is_none() && self.output_per_million.is_none() {
420 return PricingSku::UnknownOrStale;
421 }
422 PricingSku::Token {
423 input_per_mtok: self.input_per_million,
424 output_per_mtok: self.output_per_million,
425 }
426 }
427 }
428
429 /// The honest route-facing pricing meter for a catalog offering.
430 ///
431 /// An offering with a usable input/output rate becomes [`PricingSku::Token`];
432 /// everything else — no cost, a cost object with no concrete price, or a
433 /// cache-only price — becomes [`PricingSku::UnknownOrStale`] rather than a
434 /// fabricated zero price. (`from_catalog_offering` collapses the unpriced case
435 /// to `None`; `to_route_sku` collapses the cache-only case.)
436 #[must_use]
437 pub fn route_pricing_sku(offering: &CatalogOffering) -> PricingSku {
438 OfferingPricing::from_catalog_offering(offering)
439 .map_or(PricingSku::UnknownOrStale, |pricing| pricing.to_route_sku())
440 }
441
442 /// The honest route-facing pricing meter for a raw Models.dev `cost` block.
443 ///
444 /// Same honesty rule as [`route_pricing_sku`], but for callers that hold a
445 /// [`ModelsDevCost`] directly (the route-offering builders in
446 /// [`crate::models_dev`]) rather than a full [`CatalogOffering`]. An absent or
447 /// concretely-empty cost, or a cache-only cost, yields
448 /// [`PricingSku::UnknownOrStale`]; only a usable input/output rate yields
449 /// [`PricingSku::Token`].
450 #[must_use]
451 pub(crate) fn route_pricing_sku_from_cost(cost: Option<&ModelsDevCost>) -> PricingSku {
452 let Some(cost) = cost else {
453 return PricingSku::UnknownOrStale;
454 };
455 if !catalog_cost_is_valid(cost) {
456 return PricingSku::UnknownOrStale;
457 }
458 if cost.input.is_none() && cost.output.is_none() {
459 // No input/output rate: a cache-only or empty cost would render as a
460 // rate-less `Token` at the route layer, so it stays honestly unknown.
461 return PricingSku::UnknownOrStale;
462 }
463 PricingSku::Token {
464 input_per_mtok: cost.input,
465 output_per_mtok: cost.output,
466 }
467 }
468
469 /// Upper bound, per million tokens, on a price CodeWhale will treat as real.
470 ///
471 /// Published frontier rates are in the single-to-triple digits per million.
472 /// A value four orders of magnitude above that is not an expensive model, it is
473 /// a unit error — a per-token price parsed as per-million, or a minor-unit
474 /// integer (cents, fen) read as a major unit. Both mistakes bill the user
475 /// 10^6 or 10^2 times over, so the row is rejected rather than believed.
476 ///
477 /// The bound is deliberately generous: it exists to catch impossible
478 /// magnitudes, not to second-guess a provider's pricing.
479 pub const MAX_PLAUSIBLE_PRICE_PER_MILLION: f64 = 100_000.0;
480
481 /// Whether every numeric field in a catalog price is finite, non-negative, and
482 /// of a plausible magnitude.
483 ///
484 /// Kept at the catalog boundary so every projection (routing SKU and runtime
485 /// cost audit) applies the same validation rule. Catalog prices are untrusted
486 /// numeric input: they arrive from a bundled snapshot, a live provider
487 /// `/models` response, or a user override file, and any of the three can carry
488 /// a malformed value. The whole row is rejected on a single bad field —
489 /// accepting the fields that happen to parse would turn a malformed row into a
490 /// silently under-counted bill, which is worse than no price at all.
491 #[must_use]
492 pub fn catalog_cost_is_valid(cost: &ModelsDevCost) -> bool {
493 [cost.input, cost.output, cost.cache_read, cost.cache_write]
494 .into_iter()
495 .flatten()
496 .all(|price| price.is_finite() && (0.0..=MAX_PLAUSIBLE_PRICE_PER_MILLION).contains(&price))
497 }
498
499 fn provenance_from_source(source: &CatalogSource) -> PricingProvenance {
500 match source {
501 CatalogSource::Bundled => PricingProvenance::ModelsDevBundled,
502 CatalogSource::Live { .. } => PricingProvenance::ProviderLive,
503 CatalogSource::UserOverride => PricingProvenance::UserOverride,
504 }
505 }
506
507 fn effective_at_from_source(source: &CatalogSource) -> Option<u64> {
508 match source {
509 CatalogSource::Live { fetched_at, .. } => Some(*fetched_at),
510 CatalogSource::Bundled | CatalogSource::UserOverride => None,
511 }
512 }
513
514 fn endpoint_fingerprint_from_source(source: &CatalogSource) -> Option<String> {
515 match source {
516 CatalogSource::Live {
517 base_url_fingerprint,
518 ..
519 } => Some(base_url_fingerprint.clone()),
520 CatalogSource::Bundled | CatalogSource::UserOverride => None,
521 }
522 }
523
524 #[cfg(test)]
525 mod tests;
526
526 lines RUST