返回 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):
10 //!
11 //! ```text
12 //! bundled Models.dev snapshot (legacy seed, not competing truth)
13 //! < bundled Codewhale catalog (Codewhale-owned offline snapshot)
14 //! < live Models.dev (public catalog, external enrichment)
15 //! < signed cloud facts (curated correction, off by default)
16 //! < live provider `/v1/models` (credential-scoped workspace list)
17 //! < config.toml / user overrides
18 //! ```
19 //!
20 //! The two live layers are not the same kind of claim. A provider roster is a
21 //! fact about an endpoint the caller authenticated to; models.dev is a public
22 //! third-party catalog that is merely fresher than the bundled copy of itself.
23 //! Only the first outranks a signed correction — see
24 //! [`CatalogSource::ModelsDevLive`].
25 //!
26 //! After #4187, live Models.dev rows are preferred over the bundled seed. The
27 //! bundled asset remains so offline startup and failed refreshes still resolve
28 //! defaults.
29 //!
30 //! Invariants preserved from #2608 / #3497:
31 //! - A catalog row is **not** an executable route. Rows still compile through
32 //! `RouteResolver` into a `ReadyRouteCandidate` before execution.
33 //! - `wire_model_id` is kept separate from `canonical_model`; a provider row may
34 //! not expose a canonical `base_model` join, and a prefix never proves
35 //! canonical ownership.
36 //! - Unknown / custom / local rows are supported with explicit provenance and a
37 //! `None` canonical model.
38 //!
39 //! The on-disk cache format intentionally uses plain `String` identity fields
40 //! rather than the internal route newtypes, so the persisted shape is decoupled
41 //! from internal types and trivially auditable for "no secrets" (see
42 //! [`ProviderCatalogCache`] tests).
43
44 use std::collections::BTreeMap;
45 use std::sync::OnceLock;
46 use std::time::{SystemTime, UNIX_EPOCH};
47
48 use serde::{Deserialize, Serialize};
49 use serde_json::Value;
50
51 use crate::models_dev::{ModelsDevCatalog, ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
52 use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
53
54 pub mod configured;
55
56 /// Provenance of a catalog row. Drives layer precedence and UI provenance.
57 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58 #[serde(tag = "kind", rename_all = "snake_case")]
59 pub enum CatalogSource {
60 /// Offline/stale bundled seed (Models.dev-shaped snapshot). Not competing
61 /// truth — live Models.dev rows override this layer (#4188).
62 #[default]
63 Bundled,
64 /// A provider live `/models` row, scoped to a base-URL fingerprint and the
65 /// unix timestamp it was fetched at.
66 ///
67 /// This is a **provider fact**: the row exists because that endpoint, asked
68 /// under the caller's own credential, said so. It therefore outranks the
69 /// signed cloud layer and is never patched by it, and its fingerprint names
70 /// the billing surface the row prices. A third-party catalog describing the
71 /// same model is [`Self::ModelsDevLive`], whatever URL it was fetched from.
72 Live {
73 base_url_fingerprint: String,
74 fetched_at: u64,
75 },
76 /// A user / custom override (custom endpoint, pinned model, explicit facts).
77 UserOverride,
78 /// Live models.dev refresh (layer 10). Distinct from provider `/v1/models`.
79 ///
80 /// **External enrichment, not a provider fact.** Models.dev is a public
81 /// catalog nobody authenticates to, so these rows sit *below* the signed
82 /// cloud layer and a fresh signed correction may replace their limits and
83 /// prices. Carrying no endpoint fingerprint is deliberate: like the bundled
84 /// seed this layer refreshes, the row describes a model, not an endpoint.
85 ModelsDevLive { fetched_at: u64 },
86 /// `config.toml` `[providers.*]` override (layer 30).
87 ConfigOverride,
88 /// Codewhale-owned bundled catalog snapshot (offline authority seed).
89 CodewhaleBundled { revision: String },
90 /// Signed field patch, below provider-owned rows and explicit overrides.
91 ///
92 /// This is the only online catalog authority in the client. A second one
93 /// (`CodewhaleLive`, a layer-25 "signed CWC catalog" declared in #5783 and
94 /// never given a fetcher) was removed once signed cloud facts shipped as
95 /// the implemented signed layer: two signed catalogs on opposite sides of
96 /// the provider roster is exactly the split this product exists not to be.
97 CloudFacts {
98 facts_version: u64,
99 key_id: String,
100 fetched_at: u64,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 valid_until: Option<u64>,
103 },
104 }
105
106 /// One catalog-layer offering row.
107 ///
108 /// This carries the routing identity (provider + wire id + optional canonical
109 /// model + endpoint) plus the offering-owned Models.dev facts CodeWhale wants to
110 /// preserve (family, limits, cost, reasoning support/options). It is a superset
111 /// of [`ProviderModelOffering`]; use [`CatalogOffering::to_offering`] to project
112 /// the minimal routing identity the resolver consumes.
113 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
114 pub struct CatalogOffering {
115 /// Provider id serving this offering.
116 pub provider: String,
117 /// Provider-owned wire id sent on the request (verbatim).
118 pub wire_model_id: String,
119 /// Canonical model identity, only when an explicit join exists.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub canonical_model: Option<String>,
122 /// Endpoint key the offering is served on (e.g. `chat`).
123 pub endpoint_key: String,
124 /// Whether this is the provider's default offering.
125 #[serde(default)]
126 pub default_for_provider: bool,
127 /// Model family/series as exposed for this offering (e.g. `glm`, `deepseek`).
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub family: Option<String>,
130 /// Token limits for this offering, when known.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub limit: Option<ModelsDevLimit>,
133 /// Provider-scoped pricing, when known.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub cost: Option<ModelsDevCost>,
136 /// Price authority stays separate when a layer changes only capabilities.
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub cost_source: Option<CatalogSource>,
139 /// Input/output modalities for this offering, when known. Carried as the
140 /// raw Models.dev shape so a factual `text` vs `multimodal` label can be
141 /// derived without guessing; `None` means the layer did not state it (an
142 /// unknown, not "text-only").
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub modalities: Option<ModelsDevModalities>,
145 /// Whether this provider offering accepts attachments, when known.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub attachment: Option<bool>,
148 /// Whether this offering supports reasoning, when known.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub reasoning: Option<bool>,
151 /// Whether tool calling is supported, when known (#4115).
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub tool_call: Option<bool>,
154 /// Whether structured output is supported, when known.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub structured_output: Option<bool>,
157 /// Provider-scoped reasoning controls / accepted effort metadata. Kept as
158 /// raw JSON so the same model family served through different gateways can
159 /// expose different effort vocabularies without lossy collapsing.
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub reasoning_options: Vec<Value>,
162 /// Where this row came from.
163 pub source: CatalogSource,
164 }
165
166 impl CatalogOffering {
167 #[must_use]
168 pub fn pricing_source(&self) -> &CatalogSource {
169 self.cost_source.as_ref().unwrap_or(&self.source)
170 }
171
172 /// The provider id as a route newtype.
173 #[must_use]
174 pub fn provider_id(&self) -> ProviderId {
175 ProviderId::from(self.provider.clone())
176 }
177
178 /// The wire model id as a route newtype.
179 #[must_use]
180 pub fn wire_id(&self) -> WireModelId {
181 WireModelId::from(self.wire_model_id.clone())
182 }
183
184 /// Project the minimal routing identity the resolver consumes.
185 ///
186 /// The catalog deliberately carries richer facts than routing needs; this
187 /// drops most of them so `RouteResolver::from_offerings` stays the single
188 /// seam. The route-facing pricing meter is the exception: it is projected
189 /// here (where the offering's sourced `cost` is in scope) via
190 /// [`crate::pricing::route_pricing_sku`] so a resolved candidate can carry
191 /// honest pricing without the route layer ever seeing raw cost (#3085).
192 #[must_use]
193 pub fn to_offering(&self) -> ProviderModelOffering {
194 ProviderModelOffering {
195 provider: self.provider_id(),
196 canonical_model: self.canonical_model.clone().map(ModelId::from),
197 wire_model_id: self.wire_id(),
198 endpoint_key: self.endpoint_key.clone(),
199 default_for_provider: self.default_for_provider,
200 limits: self
201 .limit
202 .as_ref()
203 .map(RouteLimits::from)
204 .unwrap_or_default(),
205 capabilities: crate::route::RouteCapabilities {
206 attachments: crate::route::CapabilityState::from_optional_bool(self.attachment),
207 image_input: crate::models_dev::image_input_support(self.modalities.as_ref()),
208 reasoning: crate::route::CapabilityState::from_optional_bool(self.reasoning),
209 native_tool_calls: crate::route::CapabilityState::from_optional_bool(
210 self.tool_call,
211 ),
212 structured_output: crate::route::CapabilityState::from_optional_bool(
213 self.structured_output,
214 ),
215 server_side_web_search: crate::route::documented_server_side_web_search(
216 &self.provider,
217 &self.wire_model_id,
218 ),
219 ..crate::route::RouteCapabilities::default()
220 },
221 pricing: crate::pricing::route_pricing_sku(self),
222 }
223 }
224
225 /// Stable identity key for de-duplication and layer merging.
226 fn merge_key(&self) -> (String, String) {
227 (self.provider.clone(), self.wire_model_id.clone())
228 }
229 }
230
231 /// Committed offline/stale Models.dev-shaped catalog snapshot (#3385 / #4188).
232 ///
233 /// This is **not** a competing curated source of truth. Preferred metadata comes
234 /// from the live Models.dev catalog (#4187). The bundled asset is a compact
235 /// network-free seed of verified in-repo defaults (context/output from
236 /// `crates/tui/src/models.rs`, USD pricing from `crates/tui/src/pricing.rs`) so
237 /// [`crate::route::RouteResolver::new`] and pickers still work offline or after
238 /// a failed refresh. See the asset's `_meta.role` / `_meta.source` and the
239 /// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero).
240 pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json");
241
242 /// Parse-once cache for the committed bundled Models.dev snapshot.
243 ///
244 /// The bundled asset is compile-time constant (`include_str!`), so its parsed
245 /// form is immutable and safe to share process-wide. Before this cache, every
246 /// call site parsed the full snapshot independently — the client route path,
247 /// pickers, provider lake, and fleet identity each paid a full serde parse of
248 /// ~50KB on their own first use (perf-attributed during the 0.9.x perf
249 /// gauntlet: `ModelsDevCost` serde frames in startup profiles).
250 static BUNDLED_MODELS_DEV_CATALOG: OnceLock<ModelsDevCatalog> = OnceLock::new();
251
252 /// Parse the committed bundled Models.dev snapshot.
253 ///
254 /// The first call parses; later calls return the shared parsed catalog.
255 ///
256 /// # Panics
257 /// Panics only if the committed asset is not valid Models.dev JSON. The
258 /// `tests::bundled_asset_parses` guard makes that a build-time failure, so this
259 /// never panics in shipped builds.
260 #[must_use]
261 pub fn bundled_models_dev_catalog() -> &'static ModelsDevCatalog {
262 BUNDLED_MODELS_DEV_CATALOG.get_or_init(|| {
263 ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
264 .expect("committed bundled Models.dev asset must be valid JSON")
265 })
266 }
267
268 /// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188).
269 ///
270 /// Lowest-precedence catalog layer: every text-chat row from
271 /// [`BUNDLED_MODELS_DEV_JSON`], tagged [`CatalogSource::Bundled`]. Live Models.dev
272 /// rows override these on `(provider, wire_model_id)` when available.
273 #[must_use]
274 pub fn bundled_catalog_offerings() -> Vec<CatalogOffering> {
275 bundled_offerings_from_models_dev(bundled_models_dev_catalog())
276 }
277
278 /// Hydrate bundled [`CatalogOffering`] rows from a parsed Models.dev catalog.
279 ///
280 /// Only text-chat offerings are emitted (TTS/audio-only rows stay in the parsed
281 /// catalog but are excluded from route candidates, matching
282 /// [`ModelsDevCatalog::provider_offerings`]). Each row is tagged
283 /// [`CatalogSource::Bundled`]. No canonical model is inferred from a prefix; the
284 /// canonical link is set only from an explicit `base_model`.
285 ///
286 /// Provider ids are kept verbatim from the Models.dev payload (the committed
287 /// bundled asset already uses CodeWhale ids). Live refresh normalizes aliases
288 /// via [`live_offerings_from_models_dev`].
289 #[must_use]
290 pub fn bundled_offerings_from_models_dev(catalog: &ModelsDevCatalog) -> Vec<CatalogOffering> {
291 offerings_from_models_dev(catalog, CatalogSource::Bundled, false)
292 }
293
294 /// Hydrate live [`CatalogOffering`] rows from a fetched Models.dev catalog (#4187).
295 ///
296 /// Same text-chat filter as [`bundled_offerings_from_models_dev`], but each row
297 /// is tagged [`CatalogSource::ModelsDevLive`] with the fetch timestamp, so a
298 /// refresh lands on layer 10: above the bundled seed it supersedes, below the
299 /// signed cloud layer that may correct it, and far below a provider roster.
300 /// Provider keys are normalized onto CodeWhale [`crate::ProviderKind`] ids when
301 /// an alias match exists (`moonshotai` → `moonshot`, `togetherai` → `together`,
302 /// `zhipuai` → `zai`, …); unknown Models.dev providers keep their upstream id so
303 /// they stay discoverable without becoming executable routes.
304 ///
305 /// These rows deliberately carry no base-URL fingerprint. This function used to
306 /// stamp [`CatalogSource::Live`] with the models.dev URL's fingerprint, which
307 /// made every enriched row claim to be a provider-owned roster fetched from an
308 /// endpoint nobody bills against: signed patches were skipped as "from a higher
309 /// layer", the price was labelled `ProviderLive` and then failed its endpoint
310 /// check, and route lookup dropped the row for the same mismatch. Models.dev is
311 /// a public catalog scoped to a model, exactly like the layer-0 seed.
312 #[must_use]
313 pub fn live_offerings_from_models_dev(
314 catalog: &ModelsDevCatalog,
315 fetched_at: u64,
316 ) -> Vec<CatalogOffering> {
317 offerings_from_models_dev(catalog, CatalogSource::ModelsDevLive { fetched_at }, true)
318 }
319
320 fn offerings_from_models_dev(
321 catalog: &ModelsDevCatalog,
322 source: CatalogSource,
323 normalize_provider_ids: bool,
324 ) -> Vec<CatalogOffering> {
325 let mut out = Vec::new();
326 for (provider_key, provider) in &catalog.providers {
327 let raw_id = if provider.id.trim().is_empty() {
328 provider_key.trim()
329 } else {
330 provider.id.trim()
331 };
332 if raw_id.is_empty() {
333 continue;
334 }
335 let provider_id = if normalize_provider_ids {
336 // Normalize Models.dev provider ids onto CodeWhale kinds when known
337 // (#4186). Unknown upstream ids are kept verbatim for catalog browsing.
338 crate::ProviderKind::parse(raw_id)
339 .map(|kind| kind.as_str().to_string())
340 .unwrap_or_else(|| raw_id.to_string())
341 } else {
342 raw_id.to_string()
343 };
344 for model in provider.models.values() {
345 if !model.supports_text_chat() {
346 continue;
347 }
348 out.push(CatalogOffering {
349 provider: provider_id.clone(),
350 wire_model_id: model.id.clone(),
351 canonical_model: model.base_model.clone(),
352 endpoint_key: "chat".to_string(),
353 default_for_provider: model.default_for_provider,
354 family: model.family.clone(),
355 limit: model.limit.clone(),
356 cost: model.cost.clone(),
357 modalities: model.modalities.clone(),
358 attachment: model.attachment,
359 reasoning: model.reasoning,
360 tool_call: model.tool_call,
361 structured_output: model.structured_output,
362 reasoning_options: model.reasoning_options.clone(),
363 source: source.clone(),
364 cost_source: None,
365 });
366 }
367 }
368 out
369 }
370
371 /// A provider's live `/models` refresh result, scoped to a base-URL fingerprint.
372 ///
373 /// Returned as a delta rather than mutating any global model state directly, per
374 /// the #3385 architecture contract.
375 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376 pub struct ProviderCatalogDelta {
377 /// Provider this delta belongs to.
378 pub provider: String,
379 /// Fingerprint of the base URL the rows were fetched from.
380 pub base_url_fingerprint: String,
381 /// Unix seconds the rows were fetched at.
382 pub fetched_at: u64,
383 /// Live offering rows. Sources are normalized to `Live` on ingest.
384 pub offerings: Vec<CatalogOffering>,
385 }
386
387 /// Why a provider live catalog refresh did not produce usable rows.
388 ///
389 /// Every variant must leave previously cached / bundled / configured rows
390 /// available; a refresh failure is never fatal to model selection.
391 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392 #[serde(rename_all = "snake_case")]
393 pub enum CatalogRefreshError {
394 /// 401 — auth missing or invalid.
395 Unauthorized,
396 /// 403 — auth present but not permitted.
397 Forbidden,
398 /// 404 — provider does not expose `/models` at this base URL.
399 NotFound,
400 /// 429 — rate limited.
401 RateLimited,
402 /// Response was not parseable as a model listing.
403 InvalidResponse,
404 /// Provider returned an empty model list.
405 EmptyList,
406 /// Transport / network failure.
407 Network,
408 }
409
410 /// Freshness / health of a provider's cached live catalog.
411 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412 #[serde(tag = "state", rename_all = "snake_case")]
413 pub enum CatalogStatus {
414 /// Cached rows are within their TTL.
415 Fresh,
416 /// Cached rows exist but are past their TTL.
417 Stale { age_secs: u64 },
418 /// The last refresh failed; any rows present are from an earlier success.
419 Failed { reason: CatalogRefreshError },
420 /// No refresh has been attempted for this provider + base URL.
421 Unknown,
422 }
423
424 /// A secret-free cached provider catalog for one provider + base-URL fingerprint.
425 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
426 pub struct CachedProviderCatalog {
427 /// Provider id.
428 pub provider: String,
429 /// Base-URL fingerprint the rows were fetched from.
430 pub base_url_fingerprint: String,
431 /// Unix seconds of the last successful fetch (unchanged on failure).
432 pub fetched_at: u64,
433 /// Time-to-live, in seconds, after which rows are considered stale.
434 pub ttl_secs: u64,
435 /// Cached live offering rows (possibly empty after a failure with no prior).
436 pub offerings: Vec<CatalogOffering>,
437 /// Last known status of this entry.
438 pub status: CatalogStatus,
439 }
440
441 impl CachedProviderCatalog {
442 /// Age in seconds relative to `now_unix`, saturating at zero for clock skew.
443 #[must_use]
444 pub fn age_secs(&self, now_unix: u64) -> u64 {
445 now_unix.saturating_sub(self.fetched_at)
446 }
447
448 /// Whether the cached rows are past their TTL at `now_unix`.
449 ///
450 /// A `ttl_secs` of zero means "always stale" (never serve as fresh).
451 #[must_use]
452 pub fn is_stale(&self, now_unix: u64) -> bool {
453 self.age_secs(now_unix) >= self.ttl_secs
454 }
455
456 /// Whether this entry may contribute live offerings at `now_unix`.
457 ///
458 /// An entry is fresh only when it is within its TTL **and** its last
459 /// recorded refresh succeeded. A `Failed` entry is never fresh even inside
460 /// its TTL window — its rows survive a failed refresh for explicit fallback
461 /// display via [`ProviderCatalogCache::get`], but they are not served as
462 /// current live data.
463 #[must_use]
464 pub fn is_fresh(&self, now_unix: u64) -> bool {
465 !self.is_stale(now_unix) && !matches!(self.status, CatalogStatus::Failed { .. })
466 }
467 }
468
469 /// A secret-free store of cached provider catalogs, keyed by provider + base-URL
470 /// fingerprint.
471 ///
472 /// Scoping rule (#3385): the SAME provider on DIFFERENT base URLs must not share
473 /// rows, and DIFFERENT providers on the same base URL must not share rows.
474 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
475 pub struct ProviderCatalogCache {
476 /// Entries keyed by [`ProviderCatalogCache::cache_key`].
477 #[serde(default)]
478 pub entries: BTreeMap<String, CachedProviderCatalog>,
479 }
480
481 impl ProviderCatalogCache {
482 /// Construct an empty cache.
483 #[must_use]
484 pub fn new() -> Self {
485 Self::default()
486 }
487
488 /// Compute the composite cache key for a provider + base-URL fingerprint.
489 #[must_use]
490 pub fn cache_key(provider: &str, base_url_fingerprint: &str) -> String {
491 // Unit separator avoids ambiguity between provider and fingerprint.
492 format!("{}\u{1f}{}", provider.trim(), base_url_fingerprint.trim())
493 }
494
495 /// Look up a cached entry by provider + base-URL fingerprint.
496 #[must_use]
497 pub fn get(
498 &self,
499 provider: &str,
500 base_url_fingerprint: &str,
501 ) -> Option<&CachedProviderCatalog> {
502 self.entries
503 .get(&Self::cache_key(provider, base_url_fingerprint))
504 }
505
506 /// Record a successful refresh, replacing any prior entry for this scope.
507 ///
508 /// Offering sources are normalized to [`CatalogSource::Live`] with the
509 /// delta's fingerprint and `fetched_at`, so cached rows always carry honest
510 /// provenance regardless of how the delta was assembled.
511 pub fn record_success(&mut self, delta: ProviderCatalogDelta, ttl_secs: u64) {
512 let ProviderCatalogDelta {
513 provider,
514 base_url_fingerprint,
515 fetched_at,
516 offerings,
517 } = delta;
518 let offerings = offerings
519 .into_iter()
520 .map(|mut row| {
521 row.source = CatalogSource::Live {
522 base_url_fingerprint: base_url_fingerprint.clone(),
523 fetched_at,
524 };
525 row
526 })
527 .collect();
528 let key = Self::cache_key(&provider, &base_url_fingerprint);
529 self.entries.insert(
530 key,
531 CachedProviderCatalog {
532 provider,
533 base_url_fingerprint,
534 fetched_at,
535 ttl_secs,
536 offerings,
537 status: CatalogStatus::Fresh,
538 },
539 );
540 }
541
542 /// Record a refresh failure.
543 ///
544 /// Previously cached rows for this scope are preserved (so the UI can still
545 /// offer them with a visible "stale/failed" status); only the status is
546 /// updated. When no prior entry exists, an empty `Failed` entry is created so
547 /// the failure is observable.
548 pub fn record_failure(
549 &mut self,
550 provider: &str,
551 base_url_fingerprint: &str,
552 reason: CatalogRefreshError,
553 ) {
554 let key = Self::cache_key(provider, base_url_fingerprint);
555 match self.entries.get_mut(&key) {
556 Some(entry) => entry.status = CatalogStatus::Failed { reason },
557 None => {
558 self.entries.insert(
559 key,
560 CachedProviderCatalog {
561 provider: provider.trim().to_string(),
562 base_url_fingerprint: base_url_fingerprint.trim().to_string(),
563 fetched_at: 0,
564 ttl_secs: 0,
565 offerings: Vec::new(),
566 status: CatalogStatus::Failed { reason },
567 },
568 );
569 }
570 }
571 }
572
573 /// The resolved status of an entry at `now_unix`.
574 ///
575 /// A `Fresh`-recorded entry that has since aged past its TTL reports
576 /// `Stale`; `Failed`/`Unknown` are returned as stored.
577 #[must_use]
578 pub fn status(
579 &self,
580 provider: &str,
581 base_url_fingerprint: &str,
582 now_unix: u64,
583 ) -> CatalogStatus {
584 match self.get(provider, base_url_fingerprint) {
585 None => CatalogStatus::Unknown,
586 Some(entry) => match &entry.status {
587 CatalogStatus::Failed { reason } => CatalogStatus::Failed { reason: *reason },
588 CatalogStatus::Unknown => CatalogStatus::Unknown,
589 CatalogStatus::Fresh | CatalogStatus::Stale { .. } => {
590 if entry.is_stale(now_unix) {
591 CatalogStatus::Stale {
592 age_secs: entry.age_secs(now_unix),
593 }
594 } else {
595 CatalogStatus::Fresh
596 }
597 }
598 },
599 }
600 }
601
602 /// Fresh (within-TTL) live offerings for one provider + base URL at
603 /// `now_unix`. Stale or failed entries contribute nothing here; callers fall
604 /// back to bundled/configured rows and surface the status separately.
605 #[must_use]
606 pub fn fresh_offerings(
607 &self,
608 provider: &str,
609 base_url_fingerprint: &str,
610 now_unix: u64,
611 ) -> Vec<CatalogOffering> {
612 match self.get(provider, base_url_fingerprint) {
613 Some(entry) if entry.is_fresh(now_unix) => entry.offerings.clone(),
614 _ => Vec::new(),
615 }
616 }
617
618 /// All fresh live offerings across every cached provider + base URL.
619 #[must_use]
620 pub fn all_fresh_offerings(&self, now_unix: u64) -> Vec<CatalogOffering> {
621 self.entries
622 .values()
623 .filter(|entry| entry.is_fresh(now_unix))
624 .flat_map(|entry| entry.offerings.clone())
625 .collect()
626 }
627
628 /// Live offerings that pickers may still show: fresh rows plus stale / prior
629 /// rows that survived a failed refresh (#4139).
630 ///
631 /// Unlike [`Self::all_fresh_offerings`], this keeps past-TTL and
632 /// `Failed`-status entries as long as they still hold offering rows. Empty
633 /// entries contribute nothing; callers fall back to the bundled snapshot.
634 /// `now_unix` is accepted for API symmetry with the fresh helper (age chips
635 /// live above this layer).
636 #[must_use]
637 pub fn all_visible_offerings(&self, _now_unix: u64) -> Vec<CatalogOffering> {
638 self.entries
639 .values()
640 .filter(|entry| !entry.offerings.is_empty())
641 .flat_map(|entry| entry.offerings.clone())
642 .collect()
643 }
644 }
645
646 /// A compiled, layer-merged catalog snapshot.
647 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
648 pub struct CatalogSnapshot {
649 /// Merged offerings, de-duplicated by (provider, wire id), in stable order.
650 pub offerings: Vec<CatalogOffering>,
651 }
652
653 impl CatalogSnapshot {
654 /// Project routing offerings for `RouteResolver::from_offerings`.
655 #[must_use]
656 pub fn to_offerings(&self) -> Vec<ProviderModelOffering> {
657 self.offerings
658 .iter()
659 .map(CatalogOffering::to_offering)
660 .collect()
661 }
662
663 /// All offerings for one provider id.
664 #[must_use]
665 pub fn offerings_for_provider(&self, provider: &str) -> Vec<&CatalogOffering> {
666 self.offerings
667 .iter()
668 .filter(|row| row.provider == provider)
669 .collect()
670 }
671 }
672
673 /// Builds a [`CatalogSnapshot`] by merging layers in precedence order.
674 ///
675 /// Last writer wins per `(provider, wire id)` field. Policy DENY is applied
676 /// after every layer and is never overridden:
677 ///
678 /// ```text
679 /// 0 bundled committed models.dev-shaped snapshot
680 /// 5 codewhale bundled Codewhale-owned offline snapshot
681 /// 10 live models.dev models.dev refresh
682 /// 15 cloud facts verified field patches (default off)
683 /// 20 provider per-provider /v1/models refresh
684 /// 30 config config.toml [providers.*] overrides
685 /// 40 user user approved set
686 /// policy DENY last, never overridden
687 /// ```
688 ///
689 /// Layer 25 in `docs/CATALOG_REFRESH.md` — the Codewhale account roster — is
690 /// deliberately absent here: an account-scoped roster is entitlement, not a
691 /// public catalog layer, so it is enforced where the credential is known
692 /// (`provider_lake`'s endpoint-authoritative path) and never compiled into a
693 /// shared snapshot.
694 ///
695 /// [`Self::with_live`] remains the combined live bucket so existing callers
696 /// keep working; prefer [`Self::with_models_dev_live`] / [`Self::with_provider_live`]
697 /// for the split.
698 #[derive(Debug, Clone, Default)]
699 pub struct CatalogCompiler {
700 bundled: Vec<CatalogOffering>,
701 codewhale_bundled: Vec<CatalogOffering>,
702 models_dev_live: Vec<CatalogOffering>,
703 cloud_facts: Option<(crate::cloud_facts::ScopedFacts, u64)>,
704 provider_live: Vec<CatalogOffering>,
705 config: Vec<CatalogOffering>,
706 overrides: Vec<CatalogOffering>,
707 policy: crate::route::CatalogPolicy,
708 }
709
710 impl CatalogCompiler {
711 /// Start an empty compiler.
712 #[must_use]
713 pub fn new() -> Self {
714 Self::default()
715 }
716
717 /// Add bundled (lowest-precedence) rows.
718 #[must_use]
719 pub fn with_bundled(mut self, rows: Vec<CatalogOffering>) -> Self {
720 self.bundled.extend(rows);
721 self
722 }
723
724 /// Seed bundled rows from a parsed Models.dev catalog.
725 #[must_use]
726 pub fn with_models_dev(mut self, catalog: &ModelsDevCatalog) -> Self {
727 self.bundled
728 .extend(bundled_offerings_from_models_dev(catalog));
729 self
730 }
731
732 /// Add live models.dev refresh rows (layer 10).
733 #[must_use]
734 pub fn with_models_dev_live(mut self, rows: Vec<CatalogOffering>) -> Self {
735 self.models_dev_live.extend(rows);
736 self
737 }
738
739 /// Add live (combined models.dev + provider) rows.
740 ///
741 /// Prefer [`Self::with_models_dev_live`] / [`Self::with_provider_live`].
742 /// Source ownership places each row on the corresponding side of the
743 /// signed cloud layer; a legacy provider row never becomes a lower layer.
744 #[must_use]
745 pub fn with_live(mut self, rows: Vec<CatalogOffering>) -> Self {
746 for row in rows {
747 if matches!(row.source, CatalogSource::ModelsDevLive { .. }) {
748 self.models_dev_live.push(row);
749 } else {
750 self.provider_live.push(row);
751 }
752 }
753 self
754 }
755
756 /// Apply signed facts between generic catalogs and provider-owned rows.
757 #[must_use]
758 pub fn with_cloud_facts(
759 mut self,
760 facts: &crate::cloud_facts::ScopedFacts,
761 fetched_at: u64,
762 ) -> Self {
763 self.cloud_facts = Some((facts.clone(), fetched_at));
764 self
765 }
766
767 /// Add per-provider `/v1/models` refresh rows (layer 20).
768 #[must_use]
769 pub fn with_provider_live(mut self, rows: Vec<CatalogOffering>) -> Self {
770 self.provider_live.extend(rows);
771 self
772 }
773
774 /// Add `config.toml` `[providers.*]` override rows (layer 30).
775 #[must_use]
776 pub fn with_config(mut self, rows: Vec<CatalogOffering>) -> Self {
777 self.config.extend(rows);
778 self
779 }
780
781 /// Add user/custom override (highest catalog-layer precedence) rows.
782 #[must_use]
783 pub fn with_overrides(mut self, rows: Vec<CatalogOffering>) -> Self {
784 self.overrides.extend(rows);
785 self
786 }
787
788 /// Attach policy evaluated after every layer. DENY is never overridden.
789 #[must_use]
790 pub fn with_policy(mut self, policy: crate::route::CatalogPolicy) -> Self {
791 self.policy = policy;
792 self
793 }
794
795 /// Merge all layers into a deterministic snapshot, then apply policy DENY.
796 #[must_use]
797 pub fn compile(self) -> CatalogSnapshot {
798 let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
799 for row in self
800 .bundled
801 .into_iter()
802 .chain(self.codewhale_bundled)
803 .chain(self.models_dev_live)
804 {
805 merged.insert(row.merge_key(), row);
806 }
807 if let Some((facts, fetched_at)) = self.cloud_facts {
808 crate::cloud_facts::catalog_patch::apply_model_patches(&mut merged, &facts, fetched_at);
809 }
810 for row in self
811 .provider_live
812 .into_iter()
813 .chain(self.config)
814 .chain(self.overrides)
815 {
816 merged.insert(row.merge_key(), row);
817 }
818 let offerings = merged
819 .into_values()
820 .filter(|row| self.policy.allows(&row.provider, &row.wire_model_id))
821 .collect();
822 CatalogSnapshot { offerings }
823 }
824 }
825
826 /// Normalize a base URL and fingerprint it for cache scoping.
827 ///
828 /// Normalization folds case in the scheme/host, trims trailing slashes, and
829 /// drops a default-port suffix, so cosmetically different spellings of the same
830 /// endpoint share a cache scope while genuinely different endpoints do not. The
831 /// fingerprint is a SHA-256 digest. Secret-bearing URLs are mapped to one
832 /// constant redacted input before hashing, so userinfo, query credentials, and
833 /// fragments never enter the digest function at all.
834 #[must_use]
835 pub fn base_url_fingerprint(base_url: &str) -> String {
836 use sha2::Digest as _;
837
838 let normalized = secret_free_fingerprint_input(base_url);
839 let digest = sha2::Sha256::digest(normalized.as_bytes());
840 let mut out = String::with_capacity(digest.len() * 2);
841 for byte in digest {
842 use std::fmt::Write as _;
843 let _ = write!(&mut out, "{byte:02x}");
844 }
845 out
846 }
847
848 /// The conventional provider-table id for the Baseten known-good host.
849 ///
850 /// Baseten is an ordinary named `[providers.baseten]` row (#6289); this
851 /// string is the identity the live-catalog path serves, not a wire-fact
852 /// switch — every runtime behavior keys off [`endpoint_is_baseten`].
853 pub const BASETEN_PROVIDER_ID: &str = "baseten";
854
855 /// Baseten Model APIs endpoint: the one hosted Chat Completions host whose
856 /// wire facts differ from the generic shape (#6289).
857 ///
858 /// Baseten's `/models` uses its own response schema and returns an
859 /// account-scoped roster, so response parsing, account-scoped cache
860 /// isolation, and the reviewed per-token billing contract all key off this
861 /// endpoint. Recognition is by endpoint fingerprint — never by what the user
862 /// named the `[providers.<name>]` table — so renames and aliases cannot
863 /// change wire handling.
864 pub const BASETEN_BASE_URL: &str = "https://inference.baseten.co/v1";
865
866 /// The documented default model for the Baseten known-good host
867 /// (`docs/PROVIDERS.md`). The live-catalog offering builder marks a
868 /// discovered row with this wire id as the provider default.
869 pub const BASETEN_DEFAULT_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
870
871 /// Whether `base_url` is Baseten's Model APIs endpoint.
872 ///
873 /// Compares fingerprints, not spellings, so a trailing slash or case
874 /// difference in a user-configured URL still recognizes the host.
875 #[must_use]
876 pub fn endpoint_is_baseten(base_url: &str) -> bool {
877 base_url_fingerprint(base_url) == base_url_fingerprint(BASETEN_BASE_URL)
878 }
879
880 fn secret_free_fingerprint_input(base_url: &str) -> String {
881 const REDACTED: &str = "invalid-or-secret-bearing-url";
882 let trimmed = base_url.trim();
883 if let Some((scheme, rest)) = trimmed.split_once("://") {
884 let scheme = scheme.to_ascii_lowercase();
885 if !matches!(scheme.as_str(), "http" | "https") {
886 return REDACTED.to_string();
887 }
888 let authority_end = rest.find('/').unwrap_or(rest.len());
889 let authority_with_userinfo = &rest[..authority_end];
890 if authority_with_userinfo.contains(['?', '#']) {
891 return REDACTED.to_string();
892 }
893 let authority = authority_with_userinfo
894 .rsplit_once('@')
895 .map_or(authority_with_userinfo, |(_, host)| host);
896 if authority.is_empty() {
897 return REDACTED.to_string();
898 }
899 let path = rest[authority_end..]
900 .split(['?', '#'])
901 .next()
902 .unwrap_or_default();
903 return normalize_base_url(&format!("{scheme}://{authority}{path}"));
904 }
905 // Scheme-less input still has an authority, and it can still carry
906 // `user:pass@` userinfo. Strip it exactly as the scheme branch does, so the
907 // digest input never contains a credential.
908 let without_query = trimmed.split(['?', '#']).next().unwrap_or_default();
909 let authority_end = without_query.find('/').unwrap_or(without_query.len());
910 let authority = &without_query[..authority_end];
911 let authority = authority
912 .rsplit_once('@')
913 .map_or(authority, |(_, host)| host);
914 if authority.is_empty() {
915 return REDACTED.to_string();
916 }
917 normalize_base_url(&format!("{authority}{}", &without_query[authority_end..]))
918 }
919
920 fn normalize_base_url(base_url: &str) -> String {
921 let trimmed = base_url.trim().trim_end_matches('/');
922 // Lowercase only the scheme://host authority; leave the path case-sensitive.
923 if let Some(idx) = trimmed.find("://") {
924 let (scheme, rest) = trimmed.split_at(idx);
925 let scheme = scheme.to_ascii_lowercase();
926 let rest = &rest[3..];
927 let (authority, path) = match rest.find('/') {
928 Some(p) => (&rest[..p], &rest[p..]),
929 None => (rest, ""),
930 };
931 let authority = authority.to_ascii_lowercase();
932 // Strip only the scheme's own default port, so a non-default pairing
933 // such as `http://host:443` stays distinct from `http://host`.
934 let default_port = match scheme.as_str() {
935 "https" => Some(":443"),
936 "http" => Some(":80"),
937 _ => None,
938 };
939 let authority = default_port
940 .and_then(|port| authority.strip_suffix(port))
941 .unwrap_or(&authority);
942 format!("{scheme}://{authority}{path}")
943 } else {
944 trimmed.to_ascii_lowercase()
945 }
946 }
947
948 /// Current unix time in seconds, for callers assembling deltas / cache entries.
949 ///
950 /// Pure cache logic takes `now_unix` explicitly so it stays deterministic in
951 /// tests; this helper is the one place that reads the wall clock.
952 #[must_use]
953 pub fn now_unix() -> u64 {
954 SystemTime::now()
955 .duration_since(UNIX_EPOCH)
956 .map(|d| d.as_secs())
957 .unwrap_or(0)
958 }
959
960 #[cfg(test)]
961 mod tests;
962
962 lines RUST