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