| 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 |