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