Skip to main content

codewhale_config/
catalog.rs

1//! Models.dev-backed provider catalog snapshots and a secret-free live cache
2//! (#3385, feeding EPIC #2608 and #3383).
3//!
4//! This module is **network-free** by construction. Callers supply parsed
5//! [`crate::models_dev::ModelsDevCatalog`] JSON (bundled snapshot or live
6//! refresh) and live [`ProviderCatalogDelta`]s; the HTTP `/models` fetch layer
7//! lives above this module. Nothing here performs I/O or reads credentials.
8//!
9//! Layering (lowest precedence first; #4188):
10//!
11//! ```text
12//! bundled Models.dev snapshot       (offline/stale fallback only — not competing truth)
13//!   < live Models.dev / provider `/models` cache
14//!   < user / custom overrides        (custom endpoints, pinned models, explicit facts)
15//! ```
16//!
17//! After #4187, live Models.dev rows are preferred whenever present. The bundled
18//! asset remains so offline startup and failed refreshes still resolve defaults.
19//!
20//! Invariants preserved from #2608 / #3497:
21//! - A catalog row is **not** an executable route. Rows still compile through
22//!   `RouteResolver` into a `ReadyRouteCandidate` before execution.
23//! - `wire_model_id` is kept separate from `canonical_model`; a provider row may
24//!   not expose a canonical `base_model` join, and a prefix never proves
25//!   canonical ownership.
26//! - Unknown / custom / local rows are supported with explicit provenance and a
27//!   `None` canonical model.
28//!
29//! The on-disk cache format intentionally uses plain `String` identity fields
30//! rather than the internal route newtypes, so the persisted shape is decoupled
31//! from internal types and trivially auditable for "no secrets" (see
32//! [`ProviderCatalogCache`] tests).
33
34use std::collections::BTreeMap;
35use std::time::{SystemTime, UNIX_EPOCH};
36
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39
40use crate::models_dev::{ModelsDevCatalog, ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
41use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
42
43/// Provenance of a catalog row. Drives layer precedence and UI provenance.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "kind", rename_all = "snake_case")]
46pub enum CatalogSource {
47    /// Offline/stale bundled seed (Models.dev-shaped snapshot). Not competing
48    /// truth — live Models.dev rows override this layer (#4188).
49    #[default]
50    Bundled,
51    /// A provider live `/models` row, scoped to a base-URL fingerprint and the
52    /// unix timestamp it was fetched at.
53    Live {
54        base_url_fingerprint: String,
55        fetched_at: u64,
56    },
57    /// A user / custom override (custom endpoint, pinned model, explicit facts).
58    UserOverride,
59}
60
61/// One catalog-layer offering row.
62///
63/// This carries the routing identity (provider + wire id + optional canonical
64/// model + endpoint) plus the offering-owned Models.dev facts CodeWhale wants to
65/// preserve (family, limits, cost, reasoning support/options). It is a superset
66/// of [`ProviderModelOffering`]; use [`CatalogOffering::to_offering`] to project
67/// the minimal routing identity the resolver consumes.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
69pub struct CatalogOffering {
70    /// Provider id serving this offering.
71    pub provider: String,
72    /// Provider-owned wire id sent on the request (verbatim).
73    pub wire_model_id: String,
74    /// Canonical model identity, only when an explicit join exists.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub canonical_model: Option<String>,
77    /// Endpoint key the offering is served on (e.g. `chat`).
78    pub endpoint_key: String,
79    /// Whether this is the provider's default offering.
80    #[serde(default)]
81    pub default_for_provider: bool,
82    /// Model family/series as exposed for this offering (e.g. `glm`, `deepseek`).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub family: Option<String>,
85    /// Token limits for this offering, when known.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub limit: Option<ModelsDevLimit>,
88    /// Provider-scoped pricing, when known.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub cost: Option<ModelsDevCost>,
91    /// Input/output modalities for this offering, when known. Carried as the
92    /// raw Models.dev shape so a factual `text` vs `multimodal` label can be
93    /// derived without guessing; `None` means the layer did not state it (an
94    /// unknown, not "text-only").
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub modalities: Option<ModelsDevModalities>,
97    /// Whether this provider offering accepts attachments, when known.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub attachment: Option<bool>,
100    /// Whether this offering supports reasoning, when known.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub reasoning: Option<bool>,
103    /// Whether tool calling is supported, when known (#4115).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub tool_call: Option<bool>,
106    /// Whether structured output is supported, when known.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub structured_output: Option<bool>,
109    /// Provider-scoped reasoning controls / accepted effort metadata. Kept as
110    /// raw JSON so the same model family served through different gateways can
111    /// expose different effort vocabularies without lossy collapsing.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub reasoning_options: Vec<Value>,
114    /// Where this row came from.
115    pub source: CatalogSource,
116}
117
118impl CatalogOffering {
119    /// The provider id as a route newtype.
120    #[must_use]
121    pub fn provider_id(&self) -> ProviderId {
122        ProviderId::from(self.provider.clone())
123    }
124
125    /// The wire model id as a route newtype.
126    #[must_use]
127    pub fn wire_id(&self) -> WireModelId {
128        WireModelId::from(self.wire_model_id.clone())
129    }
130
131    /// Project the minimal routing identity the resolver consumes.
132    ///
133    /// The catalog deliberately carries richer facts than routing needs; this
134    /// drops most of them so `RouteResolver::from_offerings` stays the single
135    /// seam. The route-facing pricing meter is the exception: it is projected
136    /// here (where the offering's sourced `cost` is in scope) via
137    /// [`crate::pricing::route_pricing_sku`] so a resolved candidate can carry
138    /// honest pricing without the route layer ever seeing raw cost (#3085).
139    #[must_use]
140    pub fn to_offering(&self) -> ProviderModelOffering {
141        ProviderModelOffering {
142            provider: self.provider_id(),
143            canonical_model: self.canonical_model.clone().map(ModelId::from),
144            wire_model_id: self.wire_id(),
145            endpoint_key: self.endpoint_key.clone(),
146            default_for_provider: self.default_for_provider,
147            limits: self
148                .limit
149                .as_ref()
150                .map(RouteLimits::from)
151                .unwrap_or_default(),
152            capabilities: crate::route::RouteCapabilities {
153                attachments: crate::route::CapabilityState::from_optional_bool(self.attachment),
154                image_input: crate::models_dev::image_input_support(self.modalities.as_ref()),
155                reasoning: crate::route::CapabilityState::from_optional_bool(self.reasoning),
156                native_tool_calls: crate::route::CapabilityState::from_optional_bool(
157                    self.tool_call,
158                ),
159                structured_output: crate::route::CapabilityState::from_optional_bool(
160                    self.structured_output,
161                ),
162                server_side_web_search: crate::route::documented_server_side_web_search(
163                    &self.provider,
164                    &self.wire_model_id,
165                ),
166                ..crate::route::RouteCapabilities::default()
167            },
168            pricing: crate::pricing::route_pricing_sku(self),
169        }
170    }
171
172    /// Stable identity key for de-duplication and layer merging.
173    fn merge_key(&self) -> (String, String) {
174        (self.provider.clone(), self.wire_model_id.clone())
175    }
176}
177
178/// Committed offline/stale Models.dev-shaped catalog snapshot (#3385 / #4188).
179///
180/// This is **not** a competing curated source of truth. Preferred metadata comes
181/// from the live Models.dev catalog (#4187). The bundled asset is a compact
182/// network-free seed of verified in-repo defaults (context/output from
183/// `crates/tui/src/models.rs`, USD pricing from `crates/tui/src/pricing.rs`) so
184/// [`crate::route::RouteResolver::new`] and pickers still work offline or after
185/// a failed refresh. See the asset's `_meta.role` / `_meta.source` and the
186/// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero).
187pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json");
188
189/// Parse the committed bundled Models.dev snapshot.
190///
191/// # Panics
192/// Panics only if the committed asset is not valid Models.dev JSON. The
193/// `tests::bundled_asset_parses` guard makes that a build-time failure, so this
194/// never panics in shipped builds.
195#[must_use]
196pub fn bundled_models_dev_catalog() -> ModelsDevCatalog {
197    ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
198        .expect("committed bundled Models.dev asset must be valid JSON")
199}
200
201/// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188).
202///
203/// Lowest-precedence catalog layer: every text-chat row from
204/// [`BUNDLED_MODELS_DEV_JSON`], tagged [`CatalogSource::Bundled`]. Live Models.dev
205/// rows override these on `(provider, wire_model_id)` when available.
206#[must_use]
207pub fn bundled_catalog_offerings() -> Vec<CatalogOffering> {
208    bundled_offerings_from_models_dev(&bundled_models_dev_catalog())
209}
210
211/// Hydrate bundled [`CatalogOffering`] rows from a parsed Models.dev catalog.
212///
213/// Only text-chat offerings are emitted (TTS/audio-only rows stay in the parsed
214/// catalog but are excluded from route candidates, matching
215/// [`ModelsDevCatalog::provider_offerings`]). Each row is tagged
216/// [`CatalogSource::Bundled`]. No canonical model is inferred from a prefix; the
217/// canonical link is set only from an explicit `base_model`.
218///
219/// Provider ids are kept verbatim from the Models.dev payload (the committed
220/// bundled asset already uses CodeWhale ids). Live refresh normalizes aliases
221/// via [`live_offerings_from_models_dev`].
222#[must_use]
223pub fn bundled_offerings_from_models_dev(catalog: &ModelsDevCatalog) -> Vec<CatalogOffering> {
224    offerings_from_models_dev(catalog, CatalogSource::Bundled, false)
225}
226
227/// Hydrate live [`CatalogOffering`] rows from a fetched Models.dev catalog (#4187).
228///
229/// Same text-chat filter as [`bundled_offerings_from_models_dev`], but each row is
230/// tagged [`CatalogSource::Live`] with the Models.dev URL fingerprint and fetch
231/// timestamp. Provider keys are normalized onto CodeWhale [`crate::ProviderKind`]
232/// ids when an alias match exists (`moonshotai` → `moonshot`, `togetherai` →
233/// `together`, `zhipuai` → `zai`, …); unknown Models.dev providers keep their
234/// upstream id so they stay discoverable without becoming executable routes.
235#[must_use]
236pub fn live_offerings_from_models_dev(
237    catalog: &ModelsDevCatalog,
238    base_url_fingerprint: &str,
239    fetched_at: u64,
240) -> Vec<CatalogOffering> {
241    offerings_from_models_dev(
242        catalog,
243        CatalogSource::Live {
244            base_url_fingerprint: base_url_fingerprint.to_string(),
245            fetched_at,
246        },
247        true,
248    )
249}
250
251fn offerings_from_models_dev(
252    catalog: &ModelsDevCatalog,
253    source: CatalogSource,
254    normalize_provider_ids: bool,
255) -> Vec<CatalogOffering> {
256    let mut out = Vec::new();
257    for (provider_key, provider) in &catalog.providers {
258        let raw_id = if provider.id.trim().is_empty() {
259            provider_key.trim()
260        } else {
261            provider.id.trim()
262        };
263        if raw_id.is_empty() {
264            continue;
265        }
266        let provider_id = if normalize_provider_ids {
267            // Normalize Models.dev provider ids onto CodeWhale kinds when known
268            // (#4186). Unknown upstream ids are kept verbatim for catalog browsing.
269            crate::ProviderKind::parse(raw_id)
270                .map(|kind| kind.as_str().to_string())
271                .unwrap_or_else(|| raw_id.to_string())
272        } else {
273            raw_id.to_string()
274        };
275        for model in provider.models.values() {
276            if !model.supports_text_chat() {
277                continue;
278            }
279            out.push(CatalogOffering {
280                provider: provider_id.clone(),
281                wire_model_id: model.id.clone(),
282                canonical_model: model.base_model.clone(),
283                endpoint_key: "chat".to_string(),
284                default_for_provider: model.default_for_provider,
285                family: model.family.clone(),
286                limit: model.limit.clone(),
287                cost: model.cost.clone(),
288                modalities: model.modalities.clone(),
289                attachment: model.attachment,
290                reasoning: model.reasoning,
291                tool_call: model.tool_call,
292                structured_output: model.structured_output,
293                reasoning_options: model.reasoning_options.clone(),
294                source: source.clone(),
295            });
296        }
297    }
298    out
299}
300
301/// A provider's live `/models` refresh result, scoped to a base-URL fingerprint.
302///
303/// Returned as a delta rather than mutating any global model state directly, per
304/// the #3385 architecture contract.
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
306pub struct ProviderCatalogDelta {
307    /// Provider this delta belongs to.
308    pub provider: String,
309    /// Fingerprint of the base URL the rows were fetched from.
310    pub base_url_fingerprint: String,
311    /// Unix seconds the rows were fetched at.
312    pub fetched_at: u64,
313    /// Live offering rows. Sources are normalized to `Live` on ingest.
314    pub offerings: Vec<CatalogOffering>,
315}
316
317/// Why a provider live catalog refresh did not produce usable rows.
318///
319/// Every variant must leave previously cached / bundled / configured rows
320/// available; a refresh failure is never fatal to model selection.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum CatalogRefreshError {
324    /// 401 — auth missing or invalid.
325    Unauthorized,
326    /// 403 — auth present but not permitted.
327    Forbidden,
328    /// 404 — provider does not expose `/models` at this base URL.
329    NotFound,
330    /// 429 — rate limited.
331    RateLimited,
332    /// Response was not parseable as a model listing.
333    InvalidResponse,
334    /// Provider returned an empty model list.
335    EmptyList,
336    /// Transport / network failure.
337    Network,
338}
339
340/// Freshness / health of a provider's cached live catalog.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(tag = "state", rename_all = "snake_case")]
343pub enum CatalogStatus {
344    /// Cached rows are within their TTL.
345    Fresh,
346    /// Cached rows exist but are past their TTL.
347    Stale { age_secs: u64 },
348    /// The last refresh failed; any rows present are from an earlier success.
349    Failed { reason: CatalogRefreshError },
350    /// No refresh has been attempted for this provider + base URL.
351    Unknown,
352}
353
354/// A secret-free cached provider catalog for one provider + base-URL fingerprint.
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356pub struct CachedProviderCatalog {
357    /// Provider id.
358    pub provider: String,
359    /// Base-URL fingerprint the rows were fetched from.
360    pub base_url_fingerprint: String,
361    /// Unix seconds of the last successful fetch (unchanged on failure).
362    pub fetched_at: u64,
363    /// Time-to-live, in seconds, after which rows are considered stale.
364    pub ttl_secs: u64,
365    /// Cached live offering rows (possibly empty after a failure with no prior).
366    pub offerings: Vec<CatalogOffering>,
367    /// Last known status of this entry.
368    pub status: CatalogStatus,
369}
370
371impl CachedProviderCatalog {
372    /// Age in seconds relative to `now_unix`, saturating at zero for clock skew.
373    #[must_use]
374    pub fn age_secs(&self, now_unix: u64) -> u64 {
375        now_unix.saturating_sub(self.fetched_at)
376    }
377
378    /// Whether the cached rows are past their TTL at `now_unix`.
379    ///
380    /// A `ttl_secs` of zero means "always stale" (never serve as fresh).
381    #[must_use]
382    pub fn is_stale(&self, now_unix: u64) -> bool {
383        self.age_secs(now_unix) >= self.ttl_secs
384    }
385
386    /// Whether this entry may contribute live offerings at `now_unix`.
387    ///
388    /// An entry is fresh only when it is within its TTL **and** its last
389    /// recorded refresh succeeded. A `Failed` entry is never fresh even inside
390    /// its TTL window — its rows survive a failed refresh for explicit fallback
391    /// display via [`ProviderCatalogCache::get`], but they are not served as
392    /// current live data.
393    #[must_use]
394    pub fn is_fresh(&self, now_unix: u64) -> bool {
395        !self.is_stale(now_unix) && !matches!(self.status, CatalogStatus::Failed { .. })
396    }
397}
398
399/// A secret-free store of cached provider catalogs, keyed by provider + base-URL
400/// fingerprint.
401///
402/// Scoping rule (#3385): the SAME provider on DIFFERENT base URLs must not share
403/// rows, and DIFFERENT providers on the same base URL must not share rows.
404#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
405pub struct ProviderCatalogCache {
406    /// Entries keyed by [`ProviderCatalogCache::cache_key`].
407    #[serde(default)]
408    pub entries: BTreeMap<String, CachedProviderCatalog>,
409}
410
411impl ProviderCatalogCache {
412    /// Construct an empty cache.
413    #[must_use]
414    pub fn new() -> Self {
415        Self::default()
416    }
417
418    /// Compute the composite cache key for a provider + base-URL fingerprint.
419    #[must_use]
420    pub fn cache_key(provider: &str, base_url_fingerprint: &str) -> String {
421        // Unit separator avoids ambiguity between provider and fingerprint.
422        format!("{}\u{1f}{}", provider.trim(), base_url_fingerprint.trim())
423    }
424
425    /// Look up a cached entry by provider + base-URL fingerprint.
426    #[must_use]
427    pub fn get(
428        &self,
429        provider: &str,
430        base_url_fingerprint: &str,
431    ) -> Option<&CachedProviderCatalog> {
432        self.entries
433            .get(&Self::cache_key(provider, base_url_fingerprint))
434    }
435
436    /// Record a successful refresh, replacing any prior entry for this scope.
437    ///
438    /// Offering sources are normalized to [`CatalogSource::Live`] with the
439    /// delta's fingerprint and `fetched_at`, so cached rows always carry honest
440    /// provenance regardless of how the delta was assembled.
441    pub fn record_success(&mut self, delta: ProviderCatalogDelta, ttl_secs: u64) {
442        let ProviderCatalogDelta {
443            provider,
444            base_url_fingerprint,
445            fetched_at,
446            offerings,
447        } = delta;
448        let offerings = offerings
449            .into_iter()
450            .map(|mut row| {
451                row.source = CatalogSource::Live {
452                    base_url_fingerprint: base_url_fingerprint.clone(),
453                    fetched_at,
454                };
455                row
456            })
457            .collect();
458        let key = Self::cache_key(&provider, &base_url_fingerprint);
459        self.entries.insert(
460            key,
461            CachedProviderCatalog {
462                provider,
463                base_url_fingerprint,
464                fetched_at,
465                ttl_secs,
466                offerings,
467                status: CatalogStatus::Fresh,
468            },
469        );
470    }
471
472    /// Record a refresh failure.
473    ///
474    /// Previously cached rows for this scope are preserved (so the UI can still
475    /// offer them with a visible "stale/failed" status); only the status is
476    /// updated. When no prior entry exists, an empty `Failed` entry is created so
477    /// the failure is observable.
478    pub fn record_failure(
479        &mut self,
480        provider: &str,
481        base_url_fingerprint: &str,
482        reason: CatalogRefreshError,
483    ) {
484        let key = Self::cache_key(provider, base_url_fingerprint);
485        match self.entries.get_mut(&key) {
486            Some(entry) => entry.status = CatalogStatus::Failed { reason },
487            None => {
488                self.entries.insert(
489                    key,
490                    CachedProviderCatalog {
491                        provider: provider.trim().to_string(),
492                        base_url_fingerprint: base_url_fingerprint.trim().to_string(),
493                        fetched_at: 0,
494                        ttl_secs: 0,
495                        offerings: Vec::new(),
496                        status: CatalogStatus::Failed { reason },
497                    },
498                );
499            }
500        }
501    }
502
503    /// The resolved status of an entry at `now_unix`.
504    ///
505    /// A `Fresh`-recorded entry that has since aged past its TTL reports
506    /// `Stale`; `Failed`/`Unknown` are returned as stored.
507    #[must_use]
508    pub fn status(
509        &self,
510        provider: &str,
511        base_url_fingerprint: &str,
512        now_unix: u64,
513    ) -> CatalogStatus {
514        match self.get(provider, base_url_fingerprint) {
515            None => CatalogStatus::Unknown,
516            Some(entry) => match &entry.status {
517                CatalogStatus::Failed { reason } => CatalogStatus::Failed { reason: *reason },
518                CatalogStatus::Unknown => CatalogStatus::Unknown,
519                CatalogStatus::Fresh | CatalogStatus::Stale { .. } => {
520                    if entry.is_stale(now_unix) {
521                        CatalogStatus::Stale {
522                            age_secs: entry.age_secs(now_unix),
523                        }
524                    } else {
525                        CatalogStatus::Fresh
526                    }
527                }
528            },
529        }
530    }
531
532    /// Fresh (within-TTL) live offerings for one provider + base URL at
533    /// `now_unix`. Stale or failed entries contribute nothing here; callers fall
534    /// back to bundled/configured rows and surface the status separately.
535    #[must_use]
536    pub fn fresh_offerings(
537        &self,
538        provider: &str,
539        base_url_fingerprint: &str,
540        now_unix: u64,
541    ) -> Vec<CatalogOffering> {
542        match self.get(provider, base_url_fingerprint) {
543            Some(entry) if entry.is_fresh(now_unix) => entry.offerings.clone(),
544            _ => Vec::new(),
545        }
546    }
547
548    /// All fresh live offerings across every cached provider + base URL.
549    #[must_use]
550    pub fn all_fresh_offerings(&self, now_unix: u64) -> Vec<CatalogOffering> {
551        self.entries
552            .values()
553            .filter(|entry| entry.is_fresh(now_unix))
554            .flat_map(|entry| entry.offerings.clone())
555            .collect()
556    }
557
558    /// Live offerings that pickers may still show: fresh rows plus stale / prior
559    /// rows that survived a failed refresh (#4139).
560    ///
561    /// Unlike [`Self::all_fresh_offerings`], this keeps past-TTL and
562    /// `Failed`-status entries as long as they still hold offering rows. Empty
563    /// entries contribute nothing; callers fall back to the bundled snapshot.
564    /// `now_unix` is accepted for API symmetry with the fresh helper (age chips
565    /// live above this layer).
566    #[must_use]
567    pub fn all_visible_offerings(&self, _now_unix: u64) -> Vec<CatalogOffering> {
568        self.entries
569            .values()
570            .filter(|entry| !entry.offerings.is_empty())
571            .flat_map(|entry| entry.offerings.clone())
572            .collect()
573    }
574}
575
576/// A compiled, layer-merged catalog snapshot.
577#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
578pub struct CatalogSnapshot {
579    /// Merged offerings, de-duplicated by (provider, wire id), in stable order.
580    pub offerings: Vec<CatalogOffering>,
581}
582
583impl CatalogSnapshot {
584    /// Project routing offerings for `RouteResolver::from_offerings`.
585    #[must_use]
586    pub fn to_offerings(&self) -> Vec<ProviderModelOffering> {
587        self.offerings
588            .iter()
589            .map(CatalogOffering::to_offering)
590            .collect()
591    }
592
593    /// All offerings for one provider id.
594    #[must_use]
595    pub fn offerings_for_provider(&self, provider: &str) -> Vec<&CatalogOffering> {
596        self.offerings
597            .iter()
598            .filter(|row| row.provider == provider)
599            .collect()
600    }
601}
602
603/// Builds a [`CatalogSnapshot`] by merging layers in precedence order:
604/// bundled < live < user overrides. Later layers override earlier rows that
605/// share a (provider, wire id) identity.
606#[derive(Debug, Clone, Default)]
607pub struct CatalogCompiler {
608    bundled: Vec<CatalogOffering>,
609    live: Vec<CatalogOffering>,
610    overrides: Vec<CatalogOffering>,
611}
612
613impl CatalogCompiler {
614    /// Start an empty compiler.
615    #[must_use]
616    pub fn new() -> Self {
617        Self::default()
618    }
619
620    /// Add bundled (lowest-precedence) rows.
621    #[must_use]
622    pub fn with_bundled(mut self, rows: Vec<CatalogOffering>) -> Self {
623        self.bundled.extend(rows);
624        self
625    }
626
627    /// Seed bundled rows from a parsed Models.dev catalog.
628    #[must_use]
629    pub fn with_models_dev(mut self, catalog: &ModelsDevCatalog) -> Self {
630        self.bundled
631            .extend(bundled_offerings_from_models_dev(catalog));
632        self
633    }
634
635    /// Add live (middle-precedence) rows.
636    #[must_use]
637    pub fn with_live(mut self, rows: Vec<CatalogOffering>) -> Self {
638        self.live.extend(rows);
639        self
640    }
641
642    /// Add user/custom override (highest-precedence) rows.
643    #[must_use]
644    pub fn with_overrides(mut self, rows: Vec<CatalogOffering>) -> Self {
645        self.overrides.extend(rows);
646        self
647    }
648
649    /// Merge all layers into a deterministic snapshot.
650    #[must_use]
651    pub fn compile(self) -> CatalogSnapshot {
652        let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
653        for row in self
654            .bundled
655            .into_iter()
656            .chain(self.live)
657            .chain(self.overrides)
658        {
659            merged.insert(row.merge_key(), row);
660        }
661        CatalogSnapshot {
662            offerings: merged.into_values().collect(),
663        }
664    }
665}
666
667/// Normalize a base URL and fingerprint it for cache scoping.
668///
669/// Normalization folds case in the scheme/host, trims trailing slashes, and
670/// drops a default-port suffix, so cosmetically different spellings of the same
671/// endpoint share a cache scope while genuinely different endpoints do not. The
672/// fingerprint is a SHA-256 digest. Secret-bearing URLs are mapped to one
673/// constant redacted input before hashing, so userinfo, query credentials, and
674/// fragments never enter the digest function at all.
675#[must_use]
676pub fn base_url_fingerprint(base_url: &str) -> String {
677    use sha2::Digest as _;
678
679    let normalized = secret_free_fingerprint_input(base_url);
680    let digest = sha2::Sha256::digest(normalized.as_bytes());
681    let mut out = String::with_capacity(digest.len() * 2);
682    for byte in digest {
683        use std::fmt::Write as _;
684        let _ = write!(&mut out, "{byte:02x}");
685    }
686    out
687}
688
689fn secret_free_fingerprint_input(base_url: &str) -> String {
690    const REDACTED: &str = "invalid-or-secret-bearing-url";
691    let trimmed = base_url.trim();
692    if let Some((scheme, rest)) = trimmed.split_once("://") {
693        let scheme = scheme.to_ascii_lowercase();
694        if !matches!(scheme.as_str(), "http" | "https") {
695            return REDACTED.to_string();
696        }
697        let authority_end = rest.find('/').unwrap_or(rest.len());
698        let authority_with_userinfo = &rest[..authority_end];
699        if authority_with_userinfo.contains(['?', '#']) {
700            return REDACTED.to_string();
701        }
702        let authority = authority_with_userinfo
703            .rsplit_once('@')
704            .map_or(authority_with_userinfo, |(_, host)| host);
705        if authority.is_empty() {
706            return REDACTED.to_string();
707        }
708        let path = rest[authority_end..]
709            .split(['?', '#'])
710            .next()
711            .unwrap_or_default();
712        return normalize_base_url(&format!("{scheme}://{authority}{path}"));
713    }
714    normalize_base_url(trimmed.split(['?', '#']).next().unwrap_or(REDACTED))
715}
716
717fn normalize_base_url(base_url: &str) -> String {
718    let trimmed = base_url.trim().trim_end_matches('/');
719    // Lowercase only the scheme://host authority; leave the path case-sensitive.
720    if let Some(idx) = trimmed.find("://") {
721        let (scheme, rest) = trimmed.split_at(idx);
722        let scheme = scheme.to_ascii_lowercase();
723        let rest = &rest[3..];
724        let (authority, path) = match rest.find('/') {
725            Some(p) => (&rest[..p], &rest[p..]),
726            None => (rest, ""),
727        };
728        let authority = authority.to_ascii_lowercase();
729        // Strip only the scheme's own default port, so a non-default pairing
730        // such as `http://host:443` stays distinct from `http://host`.
731        let default_port = match scheme.as_str() {
732            "https" => Some(":443"),
733            "http" => Some(":80"),
734            _ => None,
735        };
736        let authority = default_port
737            .and_then(|port| authority.strip_suffix(port))
738            .unwrap_or(&authority);
739        format!("{scheme}://{authority}{path}")
740    } else {
741        trimmed.to_ascii_lowercase()
742    }
743}
744
745/// Current unix time in seconds, for callers assembling deltas / cache entries.
746///
747/// Pure cache logic takes `now_unix` explicitly so it stays deterministic in
748/// tests; this helper is the one place that reads the wall clock.
749#[must_use]
750pub fn now_unix() -> u64 {
751    SystemTime::now()
752        .duration_since(UNIX_EPOCH)
753        .map(|d| d.as_secs())
754        .unwrap_or(0)
755}
756
757#[cfg(test)]
758mod tests;