返回 CodeWhale
catalog.rs
根目录 / crates / config / src / 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
34 use std::collections::BTreeMap;
35 use std::time::{SystemTime, UNIX_EPOCH};
36
37 use serde::{Deserialize, Serialize};
38 use serde_json::Value;
39
40 use crate::models_dev::{ModelsDevCatalog, ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
41 use 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")]
46 pub 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)]
69 pub 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
118 impl 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).
187 pub 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]
196 pub 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]
207 pub 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]
223 pub 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]
236 pub 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
251 fn 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)]
306 pub 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")]
323 pub 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")]
343 pub 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)]
356 pub 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
371 impl 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)]
405 pub struct ProviderCatalogCache {
406 /// Entries keyed by [`ProviderCatalogCache::cache_key`].
407 #[serde(default)]
408 pub entries: BTreeMap<String, CachedProviderCatalog>,
409 }
410
411 impl 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)]
578 pub struct CatalogSnapshot {
579 /// Merged offerings, de-duplicated by (provider, wire id), in stable order.
580 pub offerings: Vec<CatalogOffering>,
581 }
582
583 impl 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)]
607 pub struct CatalogCompiler {
608 bundled: Vec<CatalogOffering>,
609 live: Vec<CatalogOffering>,
610 overrides: Vec<CatalogOffering>,
611 }
612
613 impl 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]
676 pub 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
689 fn 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
717 fn 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]
750 pub 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)]
758 mod tests;
759
759 lines RUST