返回 CodeWhale
provider_lake.rs
根目录 / crates / tui / src / provider_lake.rs
1 //! Configured provider/model lake facade (#3830, Wave 5b / #4188).
2 //!
3 //! Single seam over the Models.dev catalog layers and the configured-provider
4 //! predicate shared with `/provider`. Precedence is **provider-scoped live >
5 //! live Models.dev > bundled offline snapshot > legacy hardcoded fallback**.
6 //! Pickers, hotbar route slots, [`crate::model_inventory::ModelInventory`],
7 //! slash completions, and subagent validation should read model lists from here.
8 //!
9 //! [`crate::config::model_completion_names_for_provider`] is retained only as a
10 //! compatibility fallback for CodeWhale-only / local providers that Models.dev
11 //! does not represent (and for unbundled gateways until the live catalog covers
12 //! them).
13
14 use std::borrow::Cow;
15 use std::collections::BTreeMap;
16 use std::sync::atomic::{AtomicU64, Ordering};
17 use std::sync::{Arc, RwLock};
18
19 use codewhale_config::catalog::{
20 CatalogOffering, CatalogSnapshot, CatalogSource, CatalogStatus, base_url_fingerprint,
21 bundled_catalog_offerings,
22 };
23 use codewhale_config::route::{ProviderModelOffering, RouteResolver, bundled_offerings};
24
25 use crate::codex_model_cache;
26 use crate::config::{
27 Config, ProviderIdentity, ProviderKind, model_completion_names_for_provider,
28 opencode_go_model_id, provider_is_configured_for_active,
29 };
30
31 static BUNDLED_SNAPSHOT: std::sync::OnceLock<SharedSnapshot> = std::sync::OnceLock::new();
32
33 /// A catalog layer whose rows are reference-counted so the merged view can
34 /// share them instead of deep-cloning every offering.
35 ///
36 /// The Models.dev layer is several thousand rows. Holding the merge as owned
37 /// `CatalogOffering`s kept a second full copy of that layer (and of the
38 /// bundled layer) resident for the life of the process; sharing rows means
39 /// only the rows a merge actually changes (cutlines, signed-facts patches,
40 /// provider-roster completion) are materialized again.
41 #[derive(Debug, Default)]
42 struct SharedSnapshot {
43 offerings: Vec<Arc<CatalogOffering>>,
44 }
45
46 impl SharedSnapshot {
47 fn from_owned(snapshot: CatalogSnapshot) -> Self {
48 Self {
49 offerings: snapshot.offerings.into_iter().map(Arc::new).collect(),
50 }
51 }
52
53 fn offerings_for_provider(&self, provider: &str) -> Vec<&CatalogOffering> {
54 self.offerings
55 .iter()
56 .map(Arc::as_ref)
57 .filter(|row| row.provider == provider)
58 .collect()
59 }
60 }
61
62 /// Source tag for live-catalog rows. Models.dev is a cross-provider catalog
63 /// that serves as the primary live layer; per-provider refreshes (e.g.
64 /// TelecomJS `/v1/models`) are a secondary layer that must coexist alongside
65 /// Models.dev rows without being wiped by a Models.dev refresh.
66 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67 pub enum LiveSource {
68 /// The cross-provider Models.dev catalog refresh.
69 ModelsDev,
70 /// A per-provider `/v1/models` catalog refresh (e.g. TelecomJS TokenHub).
71 PerProvider,
72 }
73
74 /// Optional live catalog snapshot(s), source-scoped (#4188 race fix).
75 ///
76 /// Models.dev and every provider fetch maintain distinct partitions of live
77 /// rows. A Models.dev refresh replaces only Models.dev-sourced rows; a
78 /// per-provider merge adds/replaces only that provider's rows. This prevents a
79 /// later Models.dev `set_live_snapshot` from erasing TelecomJS rows and keeps
80 /// independent provider refreshes from erasing each other.
81 static LIVE_SNAPSHOT: RwLock<LiveSnapshotPartitions> = RwLock::new(LiveSnapshotPartitions {
82 models_dev: None,
83 per_provider: BTreeMap::new(),
84 });
85
86 /// Internal partition map: one Models.dev snapshot plus one snapshot per
87 /// provider-specific live fetch.
88 #[derive(Default)]
89 struct LiveSnapshotPartitions {
90 models_dev: Option<SharedSnapshot>,
91 per_provider: BTreeMap<LivePartitionOwner, CatalogSnapshot>,
92 }
93
94 /// Internal ownership key for one provider-owned live roster.
95 ///
96 /// Catalog rows intentionally keep their public provider string for receipts and
97 /// cache compatibility. The storage key carries the route kind separately so an
98 /// exact custom table named `openai` cannot overwrite, suppress, or borrow the
99 /// built-in OpenAI partition.
100 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
101 enum LivePartitionOwner {
102 BuiltIn(String),
103 Custom(String),
104 }
105
106 impl LivePartitionOwner {
107 fn identity(&self) -> &str {
108 match self {
109 Self::BuiltIn(identity) | Self::Custom(identity) => identity,
110 }
111 }
112 }
113
114 fn live_partition_owner_for_route(
115 provider: ProviderKind,
116 provider_identity: Option<&str>,
117 ) -> LivePartitionOwner {
118 let identity = catalog_provider_id_for_identity(provider, provider_identity);
119 if provider == ProviderKind::Custom {
120 LivePartitionOwner::Custom(catalog_partition_key(identity.as_ref()))
121 } else {
122 LivePartitionOwner::BuiltIn(catalog_partition_key(identity.as_ref()))
123 }
124 }
125
126 fn inferred_live_partition_owner(provider: &str) -> LivePartitionOwner {
127 let identity = catalog_partition_key(provider);
128 ProviderKind::parse(&identity).map_or_else(
129 || LivePartitionOwner::Custom(identity),
130 |provider| {
131 LivePartitionOwner::BuiltIn(catalog_partition_key(catalog_provider_id(provider)))
132 },
133 )
134 }
135
136 fn offerings_by_provider(
137 offerings: Vec<CatalogOffering>,
138 ) -> BTreeMap<LivePartitionOwner, Vec<CatalogOffering>> {
139 let mut grouped = BTreeMap::new();
140 for mut offering in offerings {
141 let owner = inferred_live_partition_owner(&offering.provider);
142 offering.provider = owner.identity().to_string();
143 grouped.entry(owner).or_insert_with(Vec::new).push(offering);
144 }
145 grouped
146 }
147
148 /// Generation stamp for the live snapshot. Bumped (under the `LIVE_SNAPSHOT`
149 /// write lock) by [`set_live_snapshot`], [`merge_live_offerings`], and
150 /// [`clear_live_snapshot`] so the memoized merged snapshot below can detect
151 /// staleness without re-merging.
152 static LIVE_GENERATION: AtomicU64 = AtomicU64::new(0);
153
154 type MergedCacheEntry = ((u64, u64), Arc<SharedSnapshot>);
155
156 /// Memoized result of [`merged_snapshot`], tagged with the `LIVE_GENERATION`
157 /// it was computed from. Re-merging ~5,700 offerings per call made every
158 /// `/model` open pay a multi-second, UI-thread-blocking cost; the merge result
159 /// only changes when the live snapshot changes, so cache it.
160 static MERGED_CACHE: RwLock<Option<MergedCacheEntry>> = RwLock::new(None);
161
162 /// Generation/freshness-scoped route resolvers for provider-owned catalogs.
163 /// Picker calls read the merged snapshot directly; execution projects that
164 /// snapshot into the immutable `RouteResolver` seam and must not rebuild a
165 /// 600+ row OpenRouter catalog for every route candidate.
166 static RUNTIME_RESOLVER_CACHE: RwLock<BTreeMap<String, RuntimeResolverCacheEntry>> =
167 RwLock::new(BTreeMap::new());
168 /// Most distinct endpoints the resolver cache holds at once. Entries from an
169 /// older catalog generation can never hit again and are dropped on insert;
170 /// this caps the endpoints of the current generation.
171 const MAX_RUNTIME_RESOLVER_CACHE_ENTRIES: usize = 64;
172
173 #[derive(Clone)]
174 struct RuntimeResolverCacheEntry {
175 generation: u64,
176 cloud_generation: u64,
177 status_is_fresh: bool,
178 endpoint_catalog_authoritative: bool,
179 resolver: RouteResolver,
180 }
181
182 #[derive(Clone)]
183 pub(crate) struct RuntimeCatalogResolver {
184 pub(crate) resolver: RouteResolver,
185 pub(crate) endpoint_catalog_authoritative: bool,
186 }
187
188 fn bundled_snapshot() -> &'static SharedSnapshot {
189 BUNDLED_SNAPSHOT.get_or_init(|| {
190 SharedSnapshot::from_owned(CatalogSnapshot {
191 offerings: bundled_catalog_offerings(),
192 })
193 })
194 }
195
196 /// Remove catalog rows that cannot use the selected provider's wire protocol.
197 ///
198 /// OpenCode Go publishes one `/models` roster for both Chat Completions and
199 /// Anthropic Messages and Responses. Keep saved and live Go rows on the same
200 /// documented protocol roster, correcting stale endpoint metadata.
201 fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapshot {
202 let mut is_opencode_go = provider_parse_memo();
203 snapshot.offerings = snapshot
204 .offerings
205 .into_iter()
206 .filter_map(|mut offering| {
207 if is_opencode_go(&offering.provider) {
208 canonicalize_opencode_go_row(&mut offering)?;
209 }
210 Some(offering)
211 })
212 .collect();
213 snapshot
214 }
215
216 /// [`apply_provider_model_cutlines`] over shared rows: only the rows the
217 /// cutline rewrites are copied; every other row stays shared with its layer.
218 fn apply_provider_model_cutlines_shared(rows: Vec<Arc<CatalogOffering>>) -> SharedSnapshot {
219 let mut is_opencode_go = provider_parse_memo();
220 let offerings = rows
221 .into_iter()
222 .filter_map(|mut offering| {
223 if is_opencode_go(&offering.provider) {
224 canonicalize_opencode_go_row(Arc::make_mut(&mut offering))?;
225 }
226 Some(offering)
227 })
228 .collect();
229 SharedSnapshot { offerings }
230 }
231
232 /// `ProviderKind::parse` scans every provider and alias list per call; the
233 /// distinct provider strings in a catalog are few, so resolve each distinct
234 /// string once instead of once per offering (boot-path profiles showed this
235 /// loop as the largest post-parse compute block).
236 fn provider_parse_memo() -> impl FnMut(&str) -> bool {
237 let mut resolved: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
238 move |provider: &str| {
239 if let Some(hit) = resolved.get(provider) {
240 return *hit;
241 }
242 let hit = ProviderKind::parse(provider) == Some(ProviderKind::OpencodeGo);
243 resolved.insert(provider.to_string(), hit);
244 hit
245 }
246 }
247
248 /// Canonicalize one OpenCode Go row onto its documented protocol roster.
249 /// `None` means the row is not on that roster and must be dropped.
250 fn canonicalize_opencode_go_row(offering: &mut CatalogOffering) -> Option<()> {
251 let canonical = opencode_go_model_id(&offering.wire_model_id)?;
252 let endpoint_key = codewhale_config::opencode_go_endpoint_key(canonical)?;
253 offering.provider = ProviderKind::OpencodeGo.as_str().to_string();
254 offering.wire_model_id = canonical.to_string();
255 offering.endpoint_key = endpoint_key.to_string();
256 Some(())
257 }
258
259 /// Set the live-catalog snapshot for a given source (#4188 race fix).
260 ///
261 /// Source-scoped: a Models.dev refresh replaces only Models.dev-sourced rows;
262 /// a per-provider refresh replaces only the layers for providers represented
263 /// in that snapshot. Other providers and sources are preserved. This
264 /// eliminates the race where a Models.dev `set_live_snapshot` would erase
265 /// TelecomJS rows merged earlier.
266 pub fn set_live_snapshot(snapshot: CatalogSnapshot, source: LiveSource) {
267 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
268 let snapshot = apply_provider_model_cutlines(snapshot);
269 let changed = match source {
270 LiveSource::ModelsDev => {
271 guard.models_dev = Some(SharedSnapshot::from_owned(snapshot));
272 true
273 }
274 LiveSource::PerProvider => {
275 let grouped = offerings_by_provider(snapshot.offerings);
276 let changed = !grouped.is_empty();
277 for (provider, offerings) in grouped {
278 guard
279 .per_provider
280 .insert(provider, CatalogSnapshot { offerings });
281 }
282 changed
283 }
284 };
285 // Invalidate the memoized merged snapshot while still holding the
286 // write lock so no reader can cache the old merge against the new
287 // generation.
288 if changed {
289 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
290 }
291 }
292 }
293
294 /// Replace one exact provider-owned live partition, including with no rows.
295 ///
296 /// The generic [`set_live_snapshot`] derives partitions from rows, so an empty
297 /// snapshot cannot say which previous partition should disappear. Endpoint-
298 /// scoped persistent caches need that distinction: switching Baseten to a new
299 /// base URL with no matching cache must remove the old URL's Baseten rows
300 /// immediately instead of presenting them as if they belonged to the new host.
301 #[cfg(test)]
302 pub fn replace_provider_live_snapshot(provider: &str, snapshot: CatalogSnapshot) {
303 let provider = provider.trim();
304 if provider.is_empty() {
305 return;
306 }
307 let owner = inferred_live_partition_owner(provider);
308 replace_provider_live_snapshot_for_owner(owner, snapshot);
309 }
310
311 /// Replace one provider-owned partition with an explicit route-kind boundary.
312 ///
313 /// Callers that know the concrete route must use this form. The legacy
314 /// string-only wrapper above remains for built-in publishers and older generic
315 /// tests, where a built-in-looking string necessarily denotes the built-in.
316 pub(crate) fn replace_provider_live_snapshot_for_identity(
317 provider: ProviderKind,
318 provider_identity: &str,
319 snapshot: CatalogSnapshot,
320 ) {
321 let owner = live_partition_owner_for_route(provider, Some(provider_identity));
322 if owner.identity().is_empty() {
323 return;
324 }
325 replace_provider_live_snapshot_for_owner(owner, snapshot);
326 }
327
328 fn replace_provider_live_snapshot_for_owner(owner: LivePartitionOwner, snapshot: CatalogSnapshot) {
329 let provider_key = owner.identity().to_string();
330 let mut snapshot = if matches!(&owner, LivePartitionOwner::Custom(_)) {
331 snapshot
332 } else {
333 apply_provider_model_cutlines(snapshot)
334 };
335 snapshot.offerings.retain_mut(|row| {
336 if catalog_partition_key(&row.provider) != provider_key {
337 return false;
338 }
339 row.provider.clone_from(&provider_key);
340 true
341 });
342
343 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
344 let previous = guard.per_provider.remove(&owner);
345 let next = (!snapshot.offerings.is_empty()).then_some(snapshot);
346 if let Some(next) = next.clone() {
347 guard.per_provider.insert(owner, next);
348 }
349 if previous != next {
350 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
351 }
352 }
353 }
354
355 /// Clear all live snapshots (both Models.dev and per-provider partitions).
356 /// Used by tests and shutdown paths that need a full reset.
357 #[cfg_attr(not(test), expect(dead_code))]
358 pub fn clear_live_snapshot() {
359 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
360 guard.models_dev = None;
361 guard.per_provider.clear();
362 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
363 }
364 }
365
366 /// Merge additional live offerings into provider-scoped live partitions (#4188).
367 ///
368 /// Unlike [`set_live_snapshot`] for `LiveSource::PerProvider` (which replaces
369 /// each represented provider's partition), this merges new rows by
370 /// `(provider, wire_model_id)` identity within that provider's partition,
371 /// preserving every other provider and the Models.dev partition. Production
372 /// rosters publish through `provider_catalog_live` (the guided-setup key probe
373 /// included), so this remains a fixture seam for lake tests.
374 #[cfg(test)]
375 pub fn merge_live_offerings(new_offerings: Vec<CatalogOffering>) {
376 if new_offerings.is_empty() {
377 return;
378 }
379 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
380 for (provider, new_rows) in offerings_by_provider(new_offerings) {
381 let existing = guard.per_provider.remove(&provider).unwrap_or_default();
382 let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
383 for row in existing.offerings {
384 merged.insert((row.provider.clone(), row.wire_model_id.clone()), row);
385 }
386 for row in new_rows {
387 merged.insert((row.provider.clone(), row.wire_model_id.clone()), row);
388 }
389 guard.per_provider.insert(
390 provider,
391 CatalogSnapshot {
392 offerings: merged.into_values().collect(),
393 },
394 );
395 }
396 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
397 }
398 }
399
400 /// Which live partition currently holds `(provider, wire_model_id)`, if any.
401 ///
402 /// Per-provider `/models` rows win on collision, matching merge precedence.
403 /// Pricing uses this so a Models.dev capabilities overlay is never treated as
404 /// a rate source (#5241).
405 #[must_use]
406 pub fn live_catalog_origin(provider: ProviderKind, wire_model_id: &str) -> Option<LiveSource> {
407 let catalog_id = catalog_provider_id(provider);
408 let owner = LivePartitionOwner::BuiltIn(catalog_partition_key(catalog_id));
409 let needle = wire_model_id.trim();
410 if needle.is_empty() {
411 return None;
412 }
413 let Ok(guard) = LIVE_SNAPSHOT.read() else {
414 return None;
415 };
416 // Wire ids are opaque and case-sensitive: an exact row in either
417 // partition decides before a case-folded one does.
418 for exact in [true, false] {
419 let matches = |row: &CatalogOffering| {
420 row.provider.eq_ignore_ascii_case(catalog_id)
421 && if exact {
422 row.wire_model_id == needle
423 } else {
424 row.wire_model_id.eq_ignore_ascii_case(needle)
425 }
426 };
427 if guard
428 .per_provider
429 .get(&owner)
430 .is_some_and(|snap| snap.offerings.iter().any(matches))
431 {
432 return Some(LiveSource::PerProvider);
433 }
434 if guard
435 .models_dev
436 .as_ref()
437 .is_some_and(|snap| snap.offerings.iter().any(|row| matches(row)))
438 {
439 return Some(LiveSource::ModelsDev);
440 }
441 }
442 None
443 }
444
445 /// The row whose wire id is exactly `needle`, else the one wire id equal to
446 /// it ignoring ASCII case. Wire ids are opaque and case-sensitive, so the
447 /// fallback only forgives a case slip when it cannot choose between two
448 /// distinct ids; otherwise it answers nothing rather than another model's
449 /// metadata.
450 fn find_wire_model<'a>(
451 rows: impl IntoIterator<Item = &'a CatalogOffering>,
452 needle: &str,
453 ) -> Option<&'a CatalogOffering> {
454 let mut folded: Option<&'a CatalogOffering> = None;
455 let mut ambiguous = false;
456 for row in rows {
457 if row.wire_model_id == needle {
458 return Some(row);
459 }
460 if row.wire_model_id.eq_ignore_ascii_case(needle) {
461 match folded {
462 None => folded = Some(row),
463 Some(first) if first.wire_model_id != row.wire_model_id => ambiguous = true,
464 Some(_) => {}
465 }
466 }
467 }
468 folded.filter(|_| !ambiguous)
469 }
470
471 /// Serialize tests that mutate the process-wide live snapshot.
472 ///
473 /// Lock ordering: this takes the test env barrier FIRST (skipped when the
474 /// calling thread already sealed the environment). Under `#[cfg(test)]` every
475 /// `codewhale_env_var` read blocks on that barrier, so a thread holding the
476 /// live-snapshot mutex while it waits for the barrier deadlocks against a
477 /// thread holding the barrier while it waits for this mutex — and libtest has
478 /// no per-test timeout, so one inverted pair hangs the whole test binary.
479 /// Acquiring the barrier here, before the mutex, makes that inversion
480 /// impossible for every caller at once.
481 #[cfg(test)]
482 pub(crate) struct LiveSnapshotLock {
483 _live: std::sync::MutexGuard<'static, ()>,
484 _env: Option<crate::test_support::TestEnvLock>,
485 }
486
487 #[cfg(test)]
488 pub(crate) fn lock_live_snapshot() -> LiveSnapshotLock {
489 let env = if crate::test_support::current_thread_holds_test_env_lock() {
490 None
491 } else {
492 Some(crate::test_support::lock_test_env())
493 };
494 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
495 let live = LOCK
496 .get_or_init(|| std::sync::Mutex::new(()))
497 .lock()
498 .unwrap_or_else(|poisoned| poisoned.into_inner());
499 LiveSnapshotLock {
500 _live: live,
501 _env: env,
502 }
503 }
504
505 /// The merged catalog snapshot: Models.dev rows override bundled rows on
506 /// `(provider, wire_model_id)` identity (#4188). A provider-owned live
507 /// partition is authoritative for that provider's complete roster, so it
508 /// suppresses both bundled and Models.dev rows for the provider rather than
509 /// merely overlaying matching ids. This is what lets a successful
510 /// `/v1/models` refresh remove models retired upstream. Failed refreshes retain
511 /// the last successful provider partition; clearing a partition restores the
512 /// offline/cross-provider fallbacks. The one row a provider partition does not
513 /// suppress is a signed row the payload explicitly attests is unlisted — see
514 /// the `retain` in [`compute_merged_snapshot`]. Roster rows are completed, not
515 /// replaced, where the roster itself stated nothing.
516 ///
517 /// Memoized: the merge is recomputed only after a live-layer mutation bumps
518 /// `LIVE_GENERATION`; every other call returns the cached `Arc` (the picker
519 /// calls this per row, so it must be cheap).
520 fn merged_snapshot() -> Arc<SharedSnapshot> {
521 let generation = (
522 LIVE_GENERATION.load(Ordering::SeqCst),
523 codewhale_config::cloud_facts::overlay::snapshot().generation,
524 );
525 if let Ok(guard) = MERGED_CACHE.read()
526 && let Some((cached_generation, cached)) = guard.as_ref()
527 && *cached_generation == generation
528 {
529 return Arc::clone(cached);
530 }
531 let merged = Arc::new(compute_merged_snapshot());
532 if let Ok(mut guard) = MERGED_CACHE.write() {
533 // `generation` was sampled before the live snapshot was read, so a
534 // concurrent set/clear leaves this entry stale-tagged and the next
535 // reader recomputes; the merge itself is always internally consistent.
536 *guard = Some((generation, Arc::clone(&merged)));
537 }
538 merged
539 }
540
541 /// Uncached merge (see [`merged_snapshot`] for the caching seam).
542 fn compute_merged_snapshot() -> SharedSnapshot {
543 let cloud = codewhale_config::cloud_facts::overlay::snapshot();
544 let Ok(live) = LIVE_SNAPSHOT.read() else {
545 return apply_provider_model_cutlines_shared(bundled_snapshot().offerings.clone());
546 };
547 if live.models_dev.is_none() && live.per_provider.is_empty() && cloud.facts.is_none() {
548 return apply_provider_model_cutlines_shared(bundled_snapshot().offerings.clone());
549 }
550
551 let authoritative_providers: std::collections::BTreeSet<&str> = live
552 .per_provider
553 .keys()
554 .filter_map(|owner| match owner {
555 LivePartitionOwner::BuiltIn(identity) => Some(identity.as_str()),
556 LivePartitionOwner::Custom(_) => None,
557 })
558 .collect();
559 let is_authoritative = |provider: &str| {
560 let key = catalog_partition_key(provider);
561 authoritative_providers.contains(key.as_str())
562 };
563 let mut merged: BTreeMap<(String, String), Arc<CatalogOffering>> = BTreeMap::new();
564 for row in &bundled_snapshot().offerings {
565 if !is_authoritative(&row.provider) {
566 merged.insert(
567 (row.provider.clone(), row.wire_model_id.clone()),
568 Arc::clone(row),
569 );
570 }
571 }
572 if let Some(models_dev) = &live.models_dev {
573 for row in &models_dev.offerings {
574 if !is_authoritative(&row.provider) {
575 merged.insert(
576 (row.provider.clone(), row.wire_model_id.clone()),
577 Arc::clone(row),
578 );
579 }
580 }
581 }
582 if let Some(facts) = &cloud.facts {
583 // The patcher only reads, writes, or removes the keys a signed fact
584 // names, so materialize just those rows as owned values and share the
585 // rest untouched.
586 let mut patched: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
587 for fact in &facts.models {
588 let key = (fact.provider.clone(), fact.id.clone());
589 if let Some(row) = merged.remove(&key) {
590 patched.insert(key, Arc::unwrap_or_clone(row));
591 }
592 }
593 codewhale_config::cloud_facts::catalog_patch::apply_model_patches(
594 &mut patched,
595 facts,
596 cloud.fetched_at.unwrap_or(0),
597 );
598 merged.extend(patched.into_iter().map(|(key, row)| (key, Arc::new(row))));
599 // A provider roster owns its omissions as well as the ids it lists, and
600 // the loops above already withheld the lower layers for such a provider
601 // — so a signed row surviving here would be one this client cannot
602 // otherwise justify. Only an explicit `allow_unlisted` assertion keeps
603 // it; without one the roster stands. The partition loop below still
604 // owns every id the roster does list.
605 merged.retain(|(provider, model), row| {
606 if !is_authoritative(provider) {
607 return true;
608 }
609 matches!(row.source, CatalogSource::CloudFacts { .. })
610 && codewhale_config::cloud_facts::catalog_patch::is_unlisted_attested(
611 facts, provider, model,
612 )
613 });
614 }
615 for provider_snapshot in live
616 .per_provider
617 .iter()
618 .filter_map(|(owner, snapshot)| match owner {
619 LivePartitionOwner::BuiltIn(_) => Some(snapshot),
620 LivePartitionOwner::Custom(identity) if ProviderKind::parse(identity).is_none() => {
621 Some(snapshot)
622 }
623 LivePartitionOwner::Custom(_) => None,
624 })
625 {
626 for row in &provider_snapshot.offerings {
627 let mut row = row.clone();
628 // The roster owns this id. Where it stated a fact, that fact wins;
629 // where it said nothing, the signed layer may still complete the
630 // row instead of leaving the picker and the executor with an
631 // unknown it does not have to have.
632 if let Some(facts) = &cloud.facts {
633 codewhale_config::cloud_facts::catalog_patch::complete_provider_live_row(
634 &mut row, facts,
635 );
636 }
637 merged.insert(
638 (row.provider.clone(), row.wire_model_id.clone()),
639 Arc::new(row),
640 );
641 }
642 }
643 apply_provider_model_cutlines_shared(merged.into_values().collect())
644 }
645
646 fn apply_cloud_facts_for_provider(
647 rows: &mut BTreeMap<(String, String), CatalogOffering>,
648 provider: &str,
649 cloud: &codewhale_config::cloud_facts::overlay::OverlaySnapshot,
650 ) {
651 if let Some(facts) = &cloud.facts {
652 let mut scoped = (**facts).clone();
653 scoped.models.retain(|model| model.provider == provider);
654 codewhale_config::cloud_facts::catalog_patch::apply_model_patches(
655 rows,
656 &scoped,
657 cloud.fetched_at.unwrap_or(0),
658 );
659 }
660 }
661
662 /// Does the signed cloud layer describe this exact route?
663 ///
664 /// Every condition is load-bearing:
665 /// - the captured route identity is canonical; `deepseek-cn` remains a
666 /// separate identity even though its transport shares the DeepSeek kind;
667 /// - the identity the signer names is this route's own. Catalog rows collapse
668 /// regional and dual-wire aliases onto a vendor primary
669 /// ([`catalog_provider_id`]), so SiliconFlow China and DeepSeek's
670 /// Anthropic-wire route read the `siliconflow` / `deepseek` partitions — a
671 /// fact signed for the primary is not a fact about those other endpoints and
672 /// only an exact identity match may consume it. This is the same exact-
673 /// identity keying `cloud_default_model_for_route` already uses for defaults;
674 /// - the base URL is on that provider's official HTTPS contract, so a custom,
675 /// proxied or redirected endpoint never inherits signed facts.
676 ///
677 /// `cloud_facts::scope` stays the single authority for which providers and
678 /// hosts are in scope at all (it is what excludes custom/local routes and the
679 /// Codex account roster); this must not grow a second copy of that table.
680 pub(crate) fn cloud_facts_apply_to_route(
681 provider: ProviderKind,
682 identity: &str,
683 base_url: &str,
684 ) -> bool {
685 identity == provider.as_str()
686 && provider.as_str() == catalog_provider_id(provider)
687 && codewhale_config::cloud_facts::scope::base_url_allowed(provider.as_str(), base_url)
688 }
689
690 /// Signed rows for `provider` on this endpoint that the payload explicitly
691 /// attests exist despite the provider roster omitting them.
692 ///
693 /// A provider `/v1/models` roster is authoritative for every id it lists **and
694 /// for its own omissions**: this client keeps no history of past rosters, so it
695 /// cannot tell a never-listed preview from a model the provider retired, and it
696 /// does not guess. The single exception is an explicit signed `allow_unlisted`
697 /// assertion, which the signer must renew as it expires (`not_after` is
698 /// mandatory for one). Everything else the payload says about this provider is
699 /// still a patch on rows that exist — never a reason to add one back.
700 ///
701 /// The assertion carries exactly that: existence of that exact id. It is
702 /// filtered here by the same route gate as every other signed fact, so it
703 /// cannot reach a custom, proxied, regional or dual-wire endpoint, and it does
704 /// not touch account entitlement (an OAuth/account roster provider is outside
705 /// the signed scope entirely).
706 fn cloud_unlisted_offerings_for_route(
707 provider: ProviderKind,
708 identity: &str,
709 base_url: &str,
710 ) -> BTreeMap<(String, String), CatalogOffering> {
711 let mut rows = BTreeMap::new();
712 if !cloud_facts_apply_to_route(provider, identity, base_url) {
713 return rows;
714 }
715 let cloud = codewhale_config::cloud_facts::overlay::snapshot();
716 let Some(facts) = cloud.facts.as_ref() else {
717 return rows;
718 };
719 let catalog_id = catalog_provider_id(provider);
720 apply_cloud_facts_for_provider(&mut rows, catalog_id, &cloud);
721 rows.retain(|(row_provider, row_id), _| {
722 codewhale_config::cloud_facts::catalog_patch::is_unlisted_attested(
723 facts,
724 row_provider,
725 row_id,
726 )
727 });
728 rows
729 }
730
731 /// Maps an [`ProviderKind`] to its bundled-catalog provider id.
732 fn catalog_provider_id(provider: ProviderKind) -> &'static str {
733 match provider {
734 ProviderKind::DeepseekAnthropic => "deepseek",
735 ProviderKind::SiliconflowCN => "siliconflow",
736 _ => provider.as_str(),
737 }
738 }
739
740 /// Exact partition key for one provider-owned catalog.
741 ///
742 /// Publishers of built-in catalogs already emit their canonical provider id.
743 /// Custom table identities are ownership boundaries and therefore remain
744 /// case-sensitive even when their spelling resembles a built-in provider or a
745 /// reviewed setup-template alias: `[providers.openai]` may intentionally shadow
746 /// the built-in, and `CustomA` / `customa` may be different hosts.
747 pub(crate) fn catalog_partition_key(provider: &str) -> String {
748 provider.trim().to_string()
749 }
750
751 /// Resolve the catalog partition for a concrete route.
752 ///
753 /// `ProviderKind::Custom` is only the wire family. Named compatible providers
754 /// such as Baseten own independent catalogs and must keep their exact config
755 /// identity instead of collapsing into a shared `custom` bucket.
756 fn catalog_provider_id_for_identity<'a>(
757 provider: ProviderKind,
758 provider_identity: Option<&'a str>,
759 ) -> Cow<'a, str> {
760 if provider == ProviderKind::Custom
761 && let Some(identity) = provider_identity.map(str::trim).filter(|id| !id.is_empty())
762 {
763 return Cow::Owned(catalog_partition_key(identity));
764 }
765 Cow::Borrowed(catalog_provider_id(provider))
766 }
767
768 fn offering_key(offering: &ProviderModelOffering) -> (String, String) {
769 (
770 offering.provider.as_str().trim().to_ascii_lowercase(),
771 offering.wire_model_id.as_str().to_string(),
772 )
773 }
774
775 fn row_matches_endpoint_fingerprint(row: &CatalogOffering, fingerprint: &str) -> bool {
776 matches!(
777 &row.source,
778 CatalogSource::Live {
779 base_url_fingerprint,
780 ..
781 } if base_url_fingerprint == fingerprint
782 )
783 }
784
785 /// Build or reuse the runtime resolver for an exact provider identity.
786 ///
787 /// Only a fresh provider-owned partition whose source fingerprint matches the
788 /// selected endpoint can carry live limits, capabilities, and pricing into an
789 /// executable route. Stale, failed, unknown, or wrong-endpoint partitions stay
790 /// visible to the picker but are removed from this resolver and replaced by the
791 /// ordinary Models.dev/bundled fallback. Named compatible providers such as
792 /// Baseten are remapped from their exact catalog identity to the resolver's
793 /// `custom` transport scope only after this check.
794 pub(crate) fn runtime_catalog_resolver_for_identity(
795 provider: ProviderKind,
796 provider_identity: Option<&str>,
797 base_url: &str,
798 status: CatalogStatus,
799 ) -> RuntimeCatalogResolver {
800 let catalog_id = catalog_provider_id_for_identity(provider, provider_identity);
801 let catalog_key = catalog_partition_key(catalog_id.as_ref());
802 let fingerprint = base_url_fingerprint(base_url);
803 let status_is_fresh = matches!(status, CatalogStatus::Fresh);
804 let generation = LIVE_GENERATION.load(Ordering::SeqCst);
805 let cloud = codewhale_config::cloud_facts::overlay::snapshot();
806 let cloud_generation = cloud.generation;
807 let cache_key = format!(
808 "{}\u{1f}{}\u{1f}{}",
809 provider.as_str(),
810 catalog_key,
811 fingerprint
812 );
813
814 if let Ok(cache) = RUNTIME_RESOLVER_CACHE.read()
815 && let Some(cached) = cache.get(&cache_key)
816 && cached.generation == generation
817 && cached.cloud_generation == cloud_generation
818 && cached.status_is_fresh == status_is_fresh
819 {
820 return RuntimeCatalogResolver {
821 resolver: cached.resolver.clone(),
822 endpoint_catalog_authoritative: cached.endpoint_catalog_authoritative,
823 };
824 }
825
826 let partition_owner = live_partition_owner_for_route(provider, provider_identity);
827 let (endpoint_catalog_authoritative, selected_rows) = if let Ok(live) = LIVE_SNAPSHOT.read() {
828 let exact_partition = live.per_provider.get(&partition_owner);
829 let exact_matches = status_is_fresh
830 && exact_partition.is_some_and(|partition| {
831 !partition.offerings.is_empty()
832 && partition.offerings.iter().all(|row| {
833 catalog_partition_key(&row.provider) == catalog_key
834 && row_matches_endpoint_fingerprint(row, &fingerprint)
835 })
836 });
837 let rows = if exact_matches {
838 exact_partition
839 .map(|partition| partition.offerings.clone())
840 .unwrap_or_default()
841 } else if provider != ProviderKind::Custom {
842 live.models_dev
843 .as_ref()
844 .map(|snapshot| {
845 snapshot
846 .offerings
847 .iter()
848 .filter(|row| catalog_partition_key(&row.provider) == catalog_key)
849 .map(|row| CatalogOffering::clone(row))
850 .collect()
851 })
852 .unwrap_or_default()
853 } else {
854 Vec::new()
855 };
856 (exact_matches, rows)
857 } else {
858 (false, Vec::new())
859 };
860
861 // Nonselected providers retain the bundled/curated resolver baseline.
862 // Another endpoint's live roster must not alter this route's ownership
863 // checks (including strict-direct rejection of known foreign model ids).
864 let mut source_rows: BTreeMap<(String, String), CatalogOffering> = bundled_snapshot()
865 .offerings
866 .iter()
867 .map(|row| CatalogOffering::clone(row))
868 .map(|row| ((row.provider.clone(), row.wire_model_id.clone()), row))
869 .collect();
870 let cloud_applies = !endpoint_catalog_authoritative
871 && cloud_facts_apply_to_route(
872 provider,
873 provider_identity.unwrap_or(provider.as_str()),
874 base_url,
875 );
876 if !endpoint_catalog_authoritative {
877 for row in &selected_rows {
878 source_rows.insert(
879 (row.provider.clone(), row.wire_model_id.clone()),
880 row.clone(),
881 );
882 }
883 if cloud_applies {
884 apply_cloud_facts_for_provider(&mut source_rows, catalog_id.as_ref(), &cloud);
885 }
886 }
887 let mut route_offerings: BTreeMap<(String, String), ProviderModelOffering> = source_rows
888 .values()
889 .map(CatalogOffering::to_offering)
890 .map(|offering| (offering_key(&offering), offering))
891 .collect();
892 // Curated transport facts win ordinary Models.dev collisions, exactly as
893 // in RouteResolver::new(). A fresh exact roster replaces its whole scope.
894 for offering in bundled_offerings() {
895 route_offerings.insert(offering_key(&offering), offering);
896 }
897 // Keep curated transport identity, applying only fields explicitly signed
898 // at the lower cloud layer. Hidden rows must not be resurrected here.
899 if cloud_applies && let Some(facts) = &cloud.facts {
900 for patch in facts
901 .models
902 .iter()
903 .filter(|patch| patch.provider == catalog_id.as_ref())
904 {
905 let key = (patch.provider.clone(), patch.id.clone());
906 match patch.op {
907 codewhale_config::cloud_facts::types::ModelOp::Hide => {
908 route_offerings.remove(&key);
909 }
910 codewhale_config::cloud_facts::types::ModelOp::Upsert => {
911 if let Some(offering) = route_offerings.get_mut(&key) {
912 if let Some(context) = patch.context_window {
913 offering.limits.context_tokens = Some(context);
914 }
915 if let Some(output) = patch.max_output {
916 offering.limits.output_tokens = Some(output);
917 }
918 if let Some(reasoning) = patch.reasoning {
919 offering.capabilities.reasoning =
920 codewhale_config::route::CapabilityState::from_optional_bool(Some(
921 reasoning,
922 ));
923 }
924 if patch.pricing.is_some()
925 && let Some(row) = source_rows.get(&key)
926 {
927 offering.pricing = codewhale_config::pricing::route_pricing_sku(row);
928 }
929 }
930 }
931 codewhale_config::cloud_facts::types::ModelOp::Deprecate => {}
932 }
933 }
934 }
935 if endpoint_catalog_authoritative {
936 let transport_provider = if provider == ProviderKind::Custom {
937 ProviderKind::Custom.as_str()
938 } else {
939 catalog_id.as_ref()
940 };
941 route_offerings.retain(|_, offering| offering.provider.as_str() != transport_provider);
942 let route_facts = cloud_facts_apply_to_route(
943 provider,
944 provider_identity.unwrap_or(provider.as_str()),
945 base_url,
946 )
947 .then_some(cloud.facts.as_ref())
948 .flatten();
949 for mut row in selected_rows {
950 row.provider = transport_provider.to_string();
951 // Same completion the picker applies, from the same helper: the
952 // executor must not resolve with an unknown the signed layer has
953 // already stated, nor with anything the provider itself contradicts.
954 if let Some(facts) = route_facts {
955 codewhale_config::cloud_facts::catalog_patch::complete_provider_live_row(
956 &mut row, facts,
957 );
958 }
959 let offering = row.to_offering();
960 route_offerings.insert(offering_key(&offering), offering);
961 }
962 // The roster replaced its whole scope, but an id it explicitly attests
963 // is unlisted is not an id it denied. That signed row is executable
964 // beside the roster, carrying only the facts the payload stated —
965 // otherwise the picker would offer a model the executor cannot resolve
966 // with the same metadata.
967 for row in cloud_unlisted_offerings_for_route(
968 provider,
969 provider_identity.unwrap_or(provider.as_str()),
970 base_url,
971 )
972 .into_values()
973 {
974 let offering = row.to_offering();
975 route_offerings
976 .entry(offering_key(&offering))
977 .or_insert(offering);
978 }
979 }
980
981 // Ollama's tag list does not mark a provider default. In the absence of
982 // an explicit tag, elect a stable row only from this fresh exact endpoint.
983 // Other providers retain their reported or curated default semantics.
984 if provider == ProviderKind::Ollama
985 && endpoint_catalog_authoritative
986 && !route_offerings.values().any(|offering| {
987 offering.provider.as_str() == catalog_id.as_ref() && offering.default_for_provider
988 })
989 && let Some(offering) = route_offerings
990 .values_mut()
991 .find(|offering| offering.provider.as_str() == catalog_id.as_ref())
992 {
993 offering.default_for_provider = true;
994 }
995
996 let resolver = RouteResolver::from_offerings(route_offerings.into_values().collect());
997 if let Ok(mut cache) = RUNTIME_RESOLVER_CACHE.write() {
998 cache.retain(|_, entry| {
999 entry.generation == generation && entry.cloud_generation == cloud_generation
1000 });
1001 if cache.len() >= MAX_RUNTIME_RESOLVER_CACHE_ENTRIES && !cache.contains_key(&cache_key) {
1002 cache.pop_first();
1003 }
1004 cache.insert(
1005 cache_key,
1006 RuntimeResolverCacheEntry {
1007 generation,
1008 cloud_generation,
1009 status_is_fresh,
1010 endpoint_catalog_authoritative,
1011 resolver: resolver.clone(),
1012 },
1013 );
1014 }
1015 RuntimeCatalogResolver {
1016 resolver,
1017 endpoint_catalog_authoritative,
1018 }
1019 }
1020
1021 fn offerings_for_provider_identity<'a>(
1022 snapshot: &'a SharedSnapshot,
1023 provider_id: &str,
1024 ) -> Vec<&'a CatalogOffering> {
1025 let provider_key = catalog_partition_key(provider_id);
1026 snapshot
1027 .offerings
1028 .iter()
1029 .map(Arc::as_ref)
1030 .filter(|row| catalog_partition_key(&row.provider) == provider_key)
1031 .collect()
1032 }
1033
1034 fn exact_custom_offerings(provider_identity: &str) -> Vec<CatalogOffering> {
1035 let provider_identity = provider_identity.trim();
1036 if provider_identity.is_empty() {
1037 return Vec::new();
1038 }
1039 let owner = LivePartitionOwner::Custom(catalog_partition_key(provider_identity));
1040 LIVE_SNAPSHOT
1041 .read()
1042 .ok()
1043 .and_then(|live| live.per_provider.get(&owner).cloned())
1044 .map(|snapshot| snapshot.offerings)
1045 .unwrap_or_default()
1046 }
1047
1048 /// Wire model ids are opaque and case-sensitive (`org/Model-A` and
1049 /// `org/model-a` may be two models), so dedup compares them exactly; folding
1050 /// case here would hide a real row. Only lookup forgives a case slip, and only
1051 /// when it is unambiguous (see `find_wire_model`).
1052 fn push_unique_model(models: &mut Vec<String>, model: &str) {
1053 let model = model.trim();
1054 if model.is_empty() {
1055 return;
1056 }
1057 if !models.iter().any(|existing| existing == model) {
1058 models.push(model.to_string());
1059 }
1060 }
1061
1062 fn catalog_models_from_offerings<'a>(
1063 offerings: impl IntoIterator<Item = &'a CatalogOffering>,
1064 ) -> Vec<String> {
1065 let mut rows: Vec<_> = offerings.into_iter().collect();
1066 rows.sort_by(|left, right| {
1067 right
1068 .default_for_provider
1069 .cmp(&left.default_for_provider)
1070 .then_with(|| left.wire_model_id.cmp(&right.wire_model_id))
1071 });
1072 let mut models = Vec::new();
1073 for row in rows {
1074 push_unique_model(&mut models, &row.wire_model_id);
1075 }
1076 models
1077 }
1078
1079 /// Tags from the provider's own live `/v1/models` partition.
1080 ///
1081 /// Models.dev rows must not satisfy a LOCAL default (Ollama). This reads only
1082 /// the PerProvider snapshot so a cross-provider catalog cannot costume a
1083 /// machine that has not answered with its own tags.
1084 #[cfg(test)]
1085 #[must_use]
1086 pub fn live_per_provider_models(provider: ProviderKind) -> Vec<String> {
1087 let catalog_id = catalog_provider_id(provider).to_ascii_lowercase();
1088 let Ok(guard) = LIVE_SNAPSHOT.read() else {
1089 return Vec::new();
1090 };
1091 let owner = LivePartitionOwner::BuiltIn(catalog_id);
1092 let Some(snapshot) = guard.per_provider.get(&owner) else {
1093 return Vec::new();
1094 };
1095 catalog_models_from_offerings(&snapshot.offerings)
1096 }
1097
1098 /// Catalog-backed model ids for one provider (#4188).
1099 ///
1100 /// Precedence: live Models.dev rows (when published) override bundled offline
1101 /// rows on `(provider, wire_model_id)`; if the merged catalog still has no rows
1102 /// for the provider, fall back to
1103 /// [`crate::config::model_completion_names_for_provider`] so CodeWhale-only /
1104 /// local providers (and gateways not yet in the offline seed) keep defaults.
1105 #[must_use]
1106 pub fn all_catalog_models_for_provider(provider: ProviderKind) -> Vec<String> {
1107 all_catalog_models_for_provider_identity(provider, None)
1108 }
1109
1110 /// Catalog-backed model ids for one exact provider route.
1111 ///
1112 /// Built-in providers retain their canonical ids. Named custom routes use
1113 /// `provider_identity`, so one host's live `/v1/models` rows remain isolated
1114 /// from every other custom host. There are no compiled seed models: a custom
1115 /// route with no live, bundled, or configured rows offers nothing (#6289).
1116 #[must_use]
1117 pub fn all_catalog_models_for_provider_identity(
1118 provider: ProviderKind,
1119 provider_identity: Option<&str>,
1120 ) -> Vec<String> {
1121 // ChatGPT OAuth availability is account-scoped. A generic OpenAI or
1122 // Models.dev catalog is not evidence that a model can be routed through
1123 // a ChatGPT plan, so this provider owns a registration-scoped source.
1124 if provider == ProviderKind::OpenaiCodex {
1125 return codex_model_cache::model_roster().model_ids();
1126 }
1127
1128 let catalog_id = catalog_provider_id_for_identity(provider, provider_identity);
1129 let custom_offerings =
1130 (provider == ProviderKind::Custom).then(|| exact_custom_offerings(catalog_id.as_ref()));
1131 let merged = merged_snapshot();
1132 let mut models = match custom_offerings.as_ref() {
1133 Some(rows) => catalog_models_from_offerings(rows.iter()),
1134 None => catalog_models_from_offerings(offerings_for_provider_identity(
1135 &merged,
1136 catalog_id.as_ref(),
1137 )),
1138 };
1139 if models.is_empty() {
1140 for model in model_completion_names_for_provider(provider) {
1141 push_unique_model(&mut models, model);
1142 }
1143 }
1144 models
1145 }
1146
1147 /// Look up a merged-catalog offering for `(provider, wire_model_id)` (#4115).
1148 ///
1149 /// Returns the live-over-bundled row when present so picker metadata (context,
1150 /// pricing, tools, reasoning, freshness) can be projected without a second
1151 /// catalog walk. `None` for CodeWhale-only / legacy-fallback ids that have no
1152 /// Models.dev row.
1153 #[must_use]
1154 pub fn catalog_offering_for_model(
1155 provider: ProviderKind,
1156 wire_model_id: &str,
1157 ) -> Option<CatalogOffering> {
1158 catalog_offering_for_model_identity(provider, None, wire_model_id)
1159 }
1160
1161 /// Look up a merged-catalog offering for one exact provider route.
1162 #[must_use]
1163 pub fn catalog_offering_for_model_identity(
1164 provider: ProviderKind,
1165 provider_identity: Option<&str>,
1166 wire_model_id: &str,
1167 ) -> Option<CatalogOffering> {
1168 if provider == ProviderKind::OpenaiCodex {
1169 return None;
1170 }
1171 let catalog_id = catalog_provider_id_for_identity(provider, provider_identity);
1172 let needle = wire_model_id.trim();
1173 if needle.is_empty() {
1174 return None;
1175 }
1176 if provider == ProviderKind::Custom {
1177 let rows = exact_custom_offerings(catalog_id.as_ref());
1178 return find_wire_model(&rows, needle).cloned();
1179 }
1180 find_wire_model(
1181 offerings_for_provider_identity(&merged_snapshot(), catalog_id.as_ref()),
1182 needle,
1183 )
1184 .cloned()
1185 }
1186
1187 /// Metadata from the exact route, without borrowing another endpoint's live facts.
1188 pub(crate) fn catalog_offering_for_route(
1189 provider: ProviderKind,
1190 identity: &str,
1191 base_url: &str,
1192 model: &str,
1193 ) -> Option<CatalogOffering> {
1194 if let Ok(Some(entry)) =
1195 crate::provider_catalog_live::cached_entry_for_route(provider, identity, base_url)
1196 && entry.fetched_at > 0
1197 {
1198 if let Some(mut row) = entry
1199 .offerings
1200 .into_iter()
1201 .find(|row| row.wire_model_id == model)
1202 {
1203 // An id-only roster row states existence, not that its limits and
1204 // capabilities are unknown. Complete it from the signed layer for
1205 // this exact route; anything the provider did state stays.
1206 let cloud = codewhale_config::cloud_facts::overlay::snapshot();
1207 if cloud_facts_apply_to_route(provider, identity, base_url)
1208 && let Some(facts) = &cloud.facts
1209 {
1210 codewhale_config::cloud_facts::catalog_patch::complete_provider_live_row(
1211 &mut row, facts,
1212 );
1213 }
1214 return Some(row);
1215 }
1216 // The roster answered and does not list this id. Only an explicitly
1217 // attested unlisted row may still name it here: falling through to the
1218 // bundled/Models.dev merge would hand back facts for a model the roster
1219 // has retired.
1220 return cloud_unlisted_offerings_for_route(provider, identity, base_url)
1221 .into_values()
1222 .find(|row| row.wire_model_id == model);
1223 }
1224 if codewhale_config::provider_preserves_custom_base_url_model(provider, base_url) {
1225 return None;
1226 }
1227 let offering = catalog_offering_for_model_identity(provider, Some(identity), model)?;
1228 if matches!(offering.source, CatalogSource::CloudFacts { .. })
1229 && !cloud_facts_apply_to_route(provider, identity, base_url)
1230 {
1231 return bundled_catalog_offering_for_model(provider, model);
1232 }
1233 if matches!(offering.source, CatalogSource::Live { .. })
1234 && !row_matches_endpoint_fingerprint(&offering, &base_url_fingerprint(base_url))
1235 {
1236 return None;
1237 }
1238 Some(offering)
1239 }
1240
1241 pub(crate) fn configured_model_for_route<'a>(
1242 config: &'a Config,
1243 provider: ProviderKind,
1244 identity: &str,
1245 base_url: &str,
1246 model: &str,
1247 ) -> Option<&'a codewhale_config::catalog::configured::ConfiguredModel> {
1248 // Account-owned OAuth rosters retain their separate authority.
1249 if provider == ProviderKind::OpenaiCodex {
1250 return None;
1251 }
1252 let models = config.custom_models.as_deref()?;
1253 codewhale_config::catalog::configured::validate_configured_models(models).ok()?;
1254 models
1255 .iter()
1256 .find(|row| row.id == model && row.matches_route(identity, base_url))
1257 }
1258
1259 /// Config declarations take precedence only for the exact selected route.
1260 pub(crate) fn configured_catalog_offering_for_route(
1261 config: &Config,
1262 provider: ProviderKind,
1263 identity: &str,
1264 base_url: &str,
1265 model: &str,
1266 ) -> Option<CatalogOffering> {
1267 configured_model_for_route(config, provider, identity, base_url, model)
1268 .map(|row| row.to_catalog_offering())
1269 .or_else(|| catalog_offering_for_route(provider, identity, base_url, model))
1270 }
1271
1272 pub(crate) fn configured_catalog_models_for_route(
1273 config: &Config,
1274 provider: ProviderKind,
1275 identity: &str,
1276 base_url: &str,
1277 ) -> Vec<String> {
1278 if provider == ProviderKind::OpenaiCodex {
1279 return codex_model_cache::model_roster_for(config).model_ids();
1280 }
1281 let mut ids = catalog_models_for_route(provider, identity, base_url);
1282 if provider != ProviderKind::OpenaiCodex {
1283 let models = config.custom_models.as_deref().unwrap_or_default();
1284 if codewhale_config::catalog::configured::validate_configured_models(models).is_ok() {
1285 for model in models
1286 .iter()
1287 .filter(|row| row.matches_route(identity, base_url))
1288 {
1289 if !ids.contains(&model.id) {
1290 ids.push(model.id.clone());
1291 }
1292 }
1293 }
1294 }
1295 ids
1296 }
1297
1298 /// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
1299 /// ignoring any live rows merged over it.
1300 ///
1301 /// Pricing uses this as an honest fallback when a live row cannot be verified as
1302 /// authoritative for the endpoint being priced (stale fetch, or a fetch from a
1303 /// different base URL). The bundled snapshot is a published Models.dev seed with
1304 /// no endpoint scoping, so it is authoritative for the model without needing a
1305 /// freshness proof — degrading to it is strictly more truthful than billing
1306 /// against an unverified live rate (#4318).
1307 #[must_use]
1308 pub fn bundled_catalog_offering_for_model(
1309 provider: ProviderKind,
1310 wire_model_id: &str,
1311 ) -> Option<CatalogOffering> {
1312 if provider == ProviderKind::OpenaiCodex {
1313 return None;
1314 }
1315 let catalog_id = catalog_provider_id(provider);
1316 let needle = wire_model_id.trim();
1317 if needle.is_empty() {
1318 return None;
1319 }
1320 find_wire_model(
1321 bundled_snapshot().offerings_for_provider(catalog_id),
1322 needle,
1323 )
1324 .cloned()
1325 }
1326
1327 /// Count of merged-catalog models for one provider (catalog view / dashboard).
1328 #[must_use]
1329 pub fn catalog_model_count_for_provider(provider: ProviderKind) -> usize {
1330 all_catalog_models_for_provider(provider).len()
1331 }
1332
1333 /// Providers the user has set up — active provider, working credentials/OAuth,
1334 /// or an explicit `[providers.<name>]` entry (#3830).
1335 #[must_use]
1336 pub fn configured_providers(config: &Config) -> Vec<ProviderIdentity> {
1337 let Ok(active) = config.active_provider_identity() else {
1338 return Vec::new();
1339 };
1340 config
1341 .provider_identities()
1342 .into_iter()
1343 .filter(|identity| provider_is_configured_for_active(config, identity, &active))
1344 .collect()
1345 }
1346
1347 /// Catalog models for providers that qualify as configured for `active`.
1348 #[must_use]
1349 pub fn models_for_provider(config: &Config, identity: &ProviderIdentity) -> Vec<String> {
1350 let Ok(active) = config.active_provider_identity() else {
1351 return Vec::new();
1352 };
1353 if provider_is_configured_for_active(config, identity, &active) {
1354 configured_catalog_models_for_route(
1355 config,
1356 identity.provider,
1357 identity.key.as_str(),
1358 &config.base_url_for_route(identity),
1359 )
1360 } else {
1361 Vec::new()
1362 }
1363 }
1364
1365 pub(crate) fn valid_catalog_model_id(value: &str) -> bool {
1366 !value.is_empty()
1367 && value.len() <= 256
1368 && value.bytes().any(|byte| byte.is_ascii_alphanumeric())
1369 && value.bytes().all(|byte| {
1370 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-')
1371 })
1372 }
1373
1374 /// Endpoint-scoped roster for CLI, pickers and inventory. A cached provider
1375 /// listing is authoritative for the IDs it lists **and for its own omissions**;
1376 /// the only ID appended after it is one the signed payload explicitly attests
1377 /// as unlisted. Failed/stale rows remain usable offline; configured models are
1378 /// retained by the caller.
1379 #[must_use]
1380 pub(crate) fn catalog_models_for_route(
1381 provider: ProviderKind,
1382 identity: &str,
1383 base_url: &str,
1384 ) -> Vec<String> {
1385 if provider == ProviderKind::OpenaiCodex {
1386 return codex_model_cache::model_roster().model_ids();
1387 }
1388 if let Ok(Some(entry)) =
1389 crate::provider_catalog_live::cached_entry_for_route(provider, identity, base_url)
1390 && entry.fetched_at > 0
1391 {
1392 let mut models = Vec::with_capacity(entry.offerings.len());
1393 for row in &entry.offerings {
1394 push_unique_model(&mut models, &row.wire_model_id);
1395 }
1396 // Appended, never interleaved: the roster keeps its own order and its
1397 // own authority, and an explicitly attested unlisted id is offered
1398 // after it. This is the same list the picker, metadata lookups and the
1399 // route resolver read, so a user pin stays exactly what it was.
1400 for row in cloud_unlisted_offerings_for_route(provider, identity, base_url).values() {
1401 push_unique_model(&mut models, &row.wire_model_id);
1402 }
1403 return models;
1404 }
1405 if provider == ProviderKind::Custom {
1406 // No compiled seeds: without a cached listing the caller retains the
1407 // configured model and the live refresh fills the roster (#6289).
1408 return Vec::new();
1409 }
1410 if codewhale_config::provider_preserves_custom_base_url_model(provider, base_url) {
1411 return Vec::new();
1412 }
1413 // Do not borrow a live partition published for another endpoint.
1414 let catalog_id = catalog_provider_id(provider);
1415 let live = LIVE_SNAPSHOT.read().ok();
1416 let mut rows: BTreeMap<(String, String), CatalogOffering> = bundled_snapshot()
1417 .offerings_for_provider(catalog_id)
1418 .into_iter()
1419 .map(|row| {
1420 (
1421 (row.provider.clone(), row.wire_model_id.clone()),
1422 row.clone(),
1423 )
1424 })
1425 .collect();
1426 if let Some(models_dev) = live.as_ref().and_then(|live| live.models_dev.as_ref()) {
1427 for row in models_dev.offerings_for_provider(catalog_id) {
1428 rows.insert(
1429 (row.provider.clone(), row.wire_model_id.clone()),
1430 row.clone(),
1431 );
1432 }
1433 }
1434 let cloud = codewhale_config::cloud_facts::overlay::snapshot();
1435 if cloud_facts_apply_to_route(provider, identity, base_url) {
1436 apply_cloud_facts_for_provider(&mut rows, catalog_id, &cloud);
1437 }
1438 let mut models = catalog_models_from_offerings(rows.values());
1439 if models.is_empty() && cloud.facts.is_none() {
1440 models.extend(
1441 model_completion_names_for_provider(provider)
1442 .into_iter()
1443 .map(str::to_string),
1444 );
1445 }
1446 models
1447 }
1448
1449 #[derive(serde::Serialize)]
1450 pub(crate) struct CatalogUpdateReceipt {
1451 provider: String,
1452 source: &'static str,
1453 outcome: &'static str,
1454 status: CatalogStatus,
1455 fetched_at: Option<u64>,
1456 observed_at: Option<u64>,
1457 base_url_fingerprint: Option<String>,
1458 model_count: usize,
1459 error: Option<&'static str>,
1460 }
1461
1462 fn cached_receipt(config: &Config, identity: &ProviderIdentity) -> CatalogUpdateReceipt {
1463 let base_url = config.base_url_for_route(identity);
1464 let fingerprint = base_url_fingerprint(&base_url);
1465 let entry = crate::provider_catalog_live::cached_entry_for_route(
1466 identity.provider,
1467 identity.key.as_str(),
1468 &base_url,
1469 );
1470 let cached = entry.as_ref().ok().and_then(Option::as_ref);
1471 CatalogUpdateReceipt {
1472 provider: identity.key.to_string(),
1473 source: "provider_models",
1474 outcome: "cached",
1475 status: crate::provider_catalog_live::status_for_route(
1476 identity.provider,
1477 identity.key.as_str(),
1478 &base_url,
1479 ),
1480 fetched_at: cached
1481 .map(|entry| entry.fetched_at)
1482 .filter(|timestamp| *timestamp > 0),
1483 observed_at: None,
1484 base_url_fingerprint: Some(fingerprint),
1485 model_count: cached.map_or(0, |entry| entry.offerings.len()),
1486 error: entry.is_err().then_some("cache_read_failed"),
1487 }
1488 }
1489
1490 fn catalog_identities(
1491 config: &Config,
1492 selected: Option<&str>,
1493 update: bool,
1494 ) -> anyhow::Result<Vec<ProviderIdentity>> {
1495 if let Some(selected) = selected {
1496 return Ok(vec![
1497 config
1498 .resolve_provider_pin_identity(selected)
1499 .map_err(anyhow::Error::msg)?,
1500 ]);
1501 }
1502 let active = config
1503 .active_provider_identity()
1504 .map_err(anyhow::Error::msg)?;
1505 if !update {
1506 return Ok(vec![active]);
1507 }
1508 let mut identities = configured_providers(config);
1509 if !identities.contains(&active) {
1510 identities.push(active);
1511 }
1512 identities.sort_by(|a, b| a.key.as_str().cmp(b.key.as_str()));
1513 identities.dedup_by(|a, b| a == b);
1514 Ok(identities)
1515 }
1516
1517 fn codex_receipt(config: &Config, identity: &ProviderIdentity) -> CatalogUpdateReceipt {
1518 let roster = codex_model_cache::model_roster_for(config);
1519 codex_roster_receipt(identity, &roster)
1520 }
1521
1522 fn codex_roster_receipt(
1523 identity: &ProviderIdentity,
1524 roster: &codex_model_cache::CodexModelRoster,
1525 ) -> CatalogUpdateReceipt {
1526 let fresh = roster.freshness == codex_model_cache::CodexModelCacheFreshness::Fresh;
1527 CatalogUpdateReceipt {
1528 provider: identity.key.to_string(),
1529 source: roster.source,
1530 outcome: "cached",
1531 status: if fresh {
1532 CatalogStatus::Fresh
1533 } else {
1534 CatalogStatus::Unknown
1535 },
1536 fetched_at: roster
1537 .fetched_at
1538 .and_then(|timestamp| u64::try_from(timestamp.timestamp()).ok()),
1539 observed_at: roster
1540 .observed_at
1541 .and_then(|timestamp| u64::try_from(timestamp.timestamp()).ok()),
1542 base_url_fingerprint: None,
1543 model_count: roster.models.len(),
1544 error: if !fresh {
1545 Some("chatgpt_catalog_unavailable")
1546 } else if !roster.observation_persisted {
1547 Some("chatgpt_catalog_not_persisted")
1548 } else {
1549 None
1550 },
1551 }
1552 }
1553
1554 /// Whether a provider-scoped live listing can never exist for this route.
1555 /// OAuth-authenticated routes (other than Codex, which owns its own roster)
1556 /// have no catalog endpoint Codewhale may call, so listings fall back to the
1557 /// next catalog layer — live Models.dev, then the bundled snapshot — instead
1558 /// of treating the missing live listing as "no models".
1559 pub(crate) fn live_catalog_unavailable(config: &Config, identity: &ProviderIdentity) -> bool {
1560 config.verify_provider_identity(identity).is_ok()
1561 && identity.provider != ProviderKind::OpenaiCodex
1562 && config
1563 .auth_mode_for_provider(identity)
1564 .is_some_and(|mode| mode.eq_ignore_ascii_case("oauth"))
1565 }
1566
1567 pub(crate) async fn update_provider_catalog(
1568 config: &Config,
1569 identity: &ProviderIdentity,
1570 ) -> CatalogUpdateReceipt {
1571 if config.verify_provider_identity(identity).is_err() {
1572 let mut receipt = cached_receipt(config, identity);
1573 receipt.outcome = "skipped";
1574 receipt.error = Some("provider_identity_changed");
1575 return receipt;
1576 }
1577 if identity.provider == ProviderKind::OpenaiCodex {
1578 let mut route_config = config.clone();
1579 if route_config.scope_to_provider_identity(identity).is_err() {
1580 let mut receipt = cached_receipt(config, identity);
1581 receipt.outcome = "skipped";
1582 receipt.error = Some("provider_identity_changed");
1583 return receipt;
1584 }
1585 return match codex_model_cache::update_from_chatgpt(&route_config).await {
1586 Ok(roster) => {
1587 let mut receipt = codex_roster_receipt(identity, &roster);
1588 receipt.outcome = "updated";
1589 receipt
1590 }
1591 Err(error) => {
1592 let mut receipt = codex_receipt(&route_config, identity);
1593 receipt.outcome = "failed";
1594 receipt.error = Some(error);
1595 receipt
1596 }
1597 };
1598 }
1599 let mut route_config = config.clone();
1600 if route_config.scope_to_provider_identity(identity).is_err() {
1601 let mut receipt = cached_receipt(config, identity);
1602 receipt.outcome = "skipped";
1603 receipt.error = Some("provider_identity_changed");
1604 return receipt;
1605 }
1606 let base_url = route_config.active_route_base_url();
1607 let fingerprint = base_url_fingerprint(&base_url);
1608 let mut receipt = cached_receipt(&route_config, identity);
1609 if identity.provider == ProviderKind::Antigravity {
1610 receipt.outcome = "skipped";
1611 receipt.error = Some("provider_retired");
1612 return receipt;
1613 }
1614 if crate::config::explicit_cli_api_key_override().is_some()
1615 && config.active_provider_identity().ok().as_ref() != Some(identity)
1616 {
1617 receipt.outcome = "skipped";
1618 receipt.error = Some("cli_key_is_scoped_to_active_provider");
1619 return receipt;
1620 }
1621 if live_catalog_unavailable(&route_config, identity) {
1622 receipt.outcome = "skipped";
1623 receipt.error = Some("oauth_catalog_unavailable");
1624 return receipt;
1625 }
1626 // Ordinary model listing never constructs a client. Explicit refresh uses
1627 // the existing read-only resolver: no secret migration or OAuth refresh.
1628 let account_owner = route_config.account_model_access.read().clone();
1629 let prepared = route_config.with_read_only_api_key_for_diagnostic();
1630 let credential = prepared
1631 .as_ref()
1632 .ok()
1633 .and_then(|config| config.active_route_api_key_read_only().ok());
1634 let client =
1635 prepared.and_then(|config| crate::client::CodewhaleClient::for_catalog_refresh(&config));
1636 let client = match client {
1637 Ok(client) => client,
1638 Err(_) => {
1639 receipt.outcome = "skipped";
1640 receipt.error = Some("credentials_or_route_unavailable");
1641 return receipt;
1642 }
1643 };
1644 if receipt.error.is_some() {
1645 receipt.outcome = "failed";
1646 return receipt;
1647 }
1648 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
1649 identity.provider,
1650 identity.key.as_str(),
1651 &base_url,
1652 );
1653 let result = tokio::time::timeout(
1654 std::time::Duration::from_secs(20),
1655 client.fetch_catalog_delta(),
1656 )
1657 .await
1658 .unwrap_or(Err(codewhale_config::catalog::CatalogRefreshError::Network));
1659 // Resolve from the original route, not the materialized client clone: the
1660 // shared session or secure credential may have changed during the request.
1661 if route_config.active_route_api_key_read_only().ok() != credential {
1662 receipt.outcome = "skipped";
1663 receipt.error = Some("refresh_credentials_changed");
1664 return receipt;
1665 }
1666 // Serialize publication with explicit overlay install/remove. Resolve
1667 // above before taking this guard: the resolver itself reads the overlay.
1668 let access = route_config.account_model_access.read();
1669 let owner = |access: &crate::config::AccountModelAccess| {
1670 (
1671 access.session_id.clone(),
1672 access.profile.clone(),
1673 access.credential.expose_secret().to_string(),
1674 )
1675 };
1676 if access.as_ref().map(owner) != account_owner.as_ref().map(owner) {
1677 receipt.outcome = "skipped";
1678 receipt.error = Some("refresh_credentials_changed");
1679 return receipt;
1680 }
1681 match result {
1682 Ok(mut delta) => {
1683 if delta.base_url_fingerprint != fingerprint {
1684 receipt.outcome = "failed";
1685 receipt.error = Some("catalog_endpoint_mismatch");
1686 return receipt;
1687 }
1688 delta.provider = identity.key.to_string();
1689 match crate::provider_catalog_live::record_success_if_current(&ticket, delta) {
1690 None => {
1691 receipt.outcome = "skipped";
1692 receipt.error = Some("refresh_superseded");
1693 return receipt;
1694 }
1695 Some(CatalogStatus::Fresh) => receipt.outcome = "updated",
1696 Some(_) => {
1697 receipt.outcome = "failed";
1698 receipt.error = Some("cache_write_failed");
1699 return receipt;
1700 }
1701 }
1702 }
1703 Err(reason) => {
1704 crate::provider_catalog_live::record_failure_if_current(
1705 &ticket,
1706 identity.key.as_str(),
1707 &fingerprint,
1708 reason,
1709 );
1710 receipt.outcome = "failed";
1711 }
1712 }
1713 drop(access);
1714 let outcome = receipt.outcome;
1715 receipt = cached_receipt(&route_config, identity);
1716 receipt.outcome = outcome;
1717 receipt
1718 }
1719
1720 pub(crate) async fn run_models(
1721 config: &Config,
1722 update: bool,
1723 selected: Option<&str>,
1724 json: bool,
1725 ) -> anyhow::Result<()> {
1726 use codewhale_localization::{MessageId, resolve_locale, tr};
1727 let locale = resolve_locale(
1728 &crate::settings::Settings::load_persisted()
1729 .unwrap_or_default()
1730 .locale,
1731 );
1732 let identities = catalog_identities(config, selected, update)?;
1733 crate::models_dev_live::maybe_load_persisted_cache();
1734 if update {
1735 let mut receipts = Vec::new();
1736 if selected.is_none() {
1737 let result = crate::models_dev_live::refresh(true).await;
1738 let status = crate::models_dev_live::status();
1739 receipts.push(CatalogUpdateReceipt {
1740 provider: "models.dev".to_string(),
1741 source: "models.dev",
1742 outcome: if result.is_ok() { "updated" } else { "failed" },
1743 status: if result.is_ok() {
1744 CatalogStatus::Fresh
1745 } else {
1746 CatalogStatus::Unknown
1747 },
1748 fetched_at: status.fetched_at,
1749 observed_at: None,
1750 base_url_fingerprint: None,
1751 model_count: status.offering_count,
1752 error: result.err().map(|error| match error {
1753 crate::models_dev_live::ModelsDevRefreshError::Disabled => "fetch_disabled",
1754 crate::models_dev_live::ModelsDevRefreshError::Network(_) => "network",
1755 crate::models_dev_live::ModelsDevRefreshError::HttpStatus(_) => "http_status",
1756 crate::models_dev_live::ModelsDevRefreshError::InvalidResponse(_) => {
1757 "invalid_response"
1758 }
1759 crate::models_dev_live::ModelsDevRefreshError::EmptyCatalog => "empty_catalog",
1760 crate::models_dev_live::ModelsDevRefreshError::Io(_) => "cache_io",
1761 }),
1762 });
1763 }
1764 use futures_util::StreamExt;
1765 receipts.extend(
1766 futures_util::stream::iter(&identities)
1767 .map(|identity| update_provider_catalog(config, identity))
1768 .buffered(4)
1769 .collect::<Vec<_>>()
1770 .await,
1771 );
1772 let updated = receipts
1773 .iter()
1774 .filter(|receipt| receipt.outcome == "updated")
1775 .count();
1776 let loaded = receipts
1777 .iter()
1778 .filter(|receipt| receipt.outcome == "loaded")
1779 .count();
1780 let failed = receipts
1781 .iter()
1782 .filter(|receipt| receipt.outcome == "failed")
1783 .count();
1784 let skipped = receipts
1785 .iter()
1786 .filter(|receipt| receipt.outcome == "skipped")
1787 .count();
1788 if json {
1789 println!(
1790 "{}",
1791 serde_json::to_string_pretty(&serde_json::json!({
1792 "updated": updated, "loaded": loaded, "failed": failed, "skipped": skipped,
1793 "catalogs": receipts,
1794 }))?
1795 );
1796 } else {
1797 println!(
1798 "{}",
1799 tr(locale, MessageId::ModelsUpdateSummary)
1800 .replace("{updated}", &updated.to_string())
1801 .replace("{loaded}", &loaded.to_string())
1802 .replace("{failed}", &failed.to_string())
1803 .replace("{skipped}", &skipped.to_string())
1804 );
1805 for receipt in &receipts {
1806 println!(
1807 "{}\t{}\tmodels={}\tfetched_at={}\tobserved_at={}\tsource={}\tstatus={}{}",
1808 receipt.provider,
1809 receipt.outcome,
1810 receipt.model_count,
1811 receipt
1812 .fetched_at
1813 .map_or_else(|| "unknown".to_string(), |timestamp| timestamp.to_string()),
1814 receipt
1815 .observed_at
1816 .map_or_else(|| "unknown".to_string(), |timestamp| timestamp.to_string()),
1817 receipt.source,
1818 serde_json::to_string(&receipt.status)?,
1819 receipt
1820 .error
1821 .map_or_else(String::new, |error| format!("\terror={error}"))
1822 );
1823 }
1824 }
1825 if failed > 0 {
1826 anyhow::bail!("{}", tr(locale, MessageId::ModelsUpdatePartial));
1827 }
1828 return Ok(());
1829 }
1830 let identity = &identities[0];
1831 let mut route_config = config.clone();
1832 route_config
1833 .scope_to_provider_identity(identity)
1834 .map_err(anyhow::Error::msg)?;
1835 let mut models = configured_catalog_models_for_route(
1836 &route_config,
1837 identity.provider,
1838 identity.key.as_str(),
1839 &route_config.active_route_base_url(),
1840 );
1841 let default_model = route_config.default_model();
1842 if identity.provider != ProviderKind::OpenaiCodex
1843 && !default_model.is_empty()
1844 && !default_model.eq_ignore_ascii_case("auto")
1845 {
1846 push_unique_model(&mut models, &default_model);
1847 }
1848 if identity.provider != ProviderKind::OpenaiCodex {
1849 models.sort();
1850 models.dedup();
1851 }
1852 if json {
1853 // Preserve the existing array + AvailableModel field shape.
1854 let chatgpt_roster = (identity.provider == ProviderKind::OpenaiCodex)
1855 .then(|| codex_model_cache::model_roster_for(&route_config));
1856 let rows: Vec<_> = models
1857 .iter()
1858 .map(|id| crate::client::AvailableModel {
1859 id: id.clone(),
1860 display_name: chatgpt_roster
1861 .as_ref()
1862 .and_then(|roster| roster.metadata_for(id))
1863 .and_then(|model| model.display_name.clone()),
1864 owned_by: None,
1865 created: None,
1866 })
1867 .collect();
1868 println!("{}", serde_json::to_string_pretty(&rows)?);
1869 } else {
1870 println!(
1871 "{}",
1872 tr(locale, MessageId::ModelsListHeader)
1873 .replace("{provider}", identity.key.as_str())
1874 .replace("{model}", &default_model)
1875 );
1876 let receipt = if identity.provider == ProviderKind::OpenaiCodex {
1877 codex_receipt(&route_config, identity)
1878 } else {
1879 cached_receipt(&route_config, identity)
1880 };
1881 println!(
1882 "source={}\tstatus={}\tfetched_at={}\tobserved_at={}",
1883 receipt.source,
1884 serde_json::to_string(&receipt.status)?,
1885 receipt
1886 .fetched_at
1887 .map_or_else(|| "unknown".to_string(), |timestamp| timestamp.to_string()),
1888 receipt
1889 .observed_at
1890 .map_or_else(|| "unknown".to_string(), |timestamp| timestamp.to_string())
1891 );
1892 if receipt.model_count == 0 {
1893 println!("{}", tr(locale, MessageId::ModelsSourceFallback));
1894 }
1895 for model in models {
1896 println!("{} {model}", if model == default_model { "*" } else { " " });
1897 }
1898 println!("{}", tr(locale, MessageId::ModelsListHint));
1899 }
1900 Ok(())
1901 }
1902
1903 #[cfg(test)]
1904 mod tests {
1905 use super::*;
1906 use crate::config::{DEFAULT_TOGETHER_FLASH_MODEL, DEFAULT_TOGETHER_MODEL};
1907 use codewhale_config::catalog::CatalogSource;
1908
1909 #[test]
1910 fn wire_model_lookup_is_exact_first_and_never_guesses_between_case_variants() {
1911 let row = |id: &str| CatalogOffering {
1912 provider: "p".to_string(),
1913 wire_model_id: id.to_string(),
1914 ..Default::default()
1915 };
1916 let rows = vec![row("org/Model-A"), row("org/model-a"), row("org/Model-B")];
1917 let found =
1918 |needle: &str| find_wire_model(&rows, needle).map(|row| row.wire_model_id.clone());
1919 assert_eq!(found("org/model-a").as_deref(), Some("org/model-a"));
1920 assert_eq!(found("org/Model-A").as_deref(), Some("org/Model-A"));
1921 assert_eq!(found("ORG/MODEL-A"), None, "two distinct ids fold together");
1922 assert_eq!(found("org/model-b").as_deref(), Some("org/Model-B"));
1923
1924 // Listing keeps both case variants: each is a distinct, pickable id.
1925 let listed = catalog_models_from_offerings(rows.iter());
1926 assert!(
1927 listed.contains(&"org/Model-A".to_string())
1928 && listed.contains(&"org/model-a".to_string()),
1929 "case variants of a wire id are distinct models: {listed:?}"
1930 );
1931 }
1932
1933 fn catalog_test_config(first_url: &str, second_url: &str) -> Config {
1934 use crate::config::{ProviderConfig, ProvidersConfig};
1935 Config {
1936 provider: Some("catalog-first".to_string()),
1937 providers: Some(ProvidersConfig {
1938 custom: [
1939 ("catalog-first", first_url, "first-route-test-key"),
1940 ("catalog-second", second_url, "second-route-test-key"),
1941 ]
1942 .into_iter()
1943 .map(|(name, base_url, key)| {
1944 (
1945 name.to_string(),
1946 ProviderConfig {
1947 kind: Some("openai-compatible".to_string()),
1948 base_url: Some(base_url.to_string()),
1949 api_key: Some(key.to_string()),
1950 model: Some("saved-model".to_string()),
1951 ..Default::default()
1952 },
1953 )
1954 })
1955 .collect(),
1956 ..Default::default()
1957 }),
1958 ..Default::default()
1959 }
1960 }
1961
1962 async fn catalog_mock(
1963 server: &wiremock::MockServer,
1964 key: &str,
1965 status: u16,
1966 body: serde_json::Value,
1967 ) {
1968 use wiremock::matchers::{header, method, path};
1969 wiremock::Mock::given(method("GET"))
1970 .and(path("/v1/models"))
1971 .and(header("Authorization", format!("Bearer {key}")))
1972 .respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body))
1973 .expect(1)
1974 .mount(server)
1975 .await;
1976 }
1977
1978 #[tokio::test]
1979 async fn models_update_refuses_credentials_changed_during_request() {
1980 let _env = crate::test_support::lock_test_env();
1981 let home = tempfile::tempdir().unwrap();
1982 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1983 let _cli = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
1984 let _key =
1985 crate::test_support::EnvVarGuard::set("CWC_CATALOG_TEST_KEY", "first-route-test-key");
1986 let upstream = wiremock::MockServer::start().await;
1987 let mut config = catalog_test_config(&upstream.uri(), &upstream.uri());
1988 let entry = config
1989 .providers
1990 .as_mut()
1991 .unwrap()
1992 .custom
1993 .get_mut("catalog-first")
1994 .unwrap();
1995 entry.api_key = None;
1996 entry.api_key_env = Some("CWC_CATALOG_TEST_KEY".into());
1997 let identity = config.resolve_provider_identity("catalog-first").unwrap();
1998 crate::provider_catalog_live::reset_cache_for_test();
1999 wiremock::Mock::given(wiremock::matchers::method("GET"))
2000 .respond_with(
2001 wiremock::ResponseTemplate::new(200)
2002 .set_delay(std::time::Duration::from_millis(100))
2003 .set_body_json(
2004 serde_json::json!({"data":[{"id":"old-account-private-model"}]}),
2005 ),
2006 )
2007 .expect(1)
2008 .mount(&upstream)
2009 .await;
2010 let change = async {
2011 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2012 while upstream.received_requests().await.unwrap().is_empty() {
2013 tokio::task::yield_now().await;
2014 }
2015 })
2016 .await
2017 .unwrap();
2018 // This guard stays alive until after the delayed refresh completes.
2019 crate::test_support::EnvVarGuard::set("CWC_CATALOG_TEST_KEY", "other-account-test-key")
2020 };
2021 let (receipt, _changed) = tokio::join!(update_provider_catalog(&config, &identity), change);
2022 assert_eq!(receipt.outcome, "skipped");
2023 assert_eq!(receipt.error, Some("refresh_credentials_changed"));
2024 assert!(
2025 crate::provider_catalog_live::cached_entry_for_route(
2026 ProviderKind::Custom,
2027 identity.key.as_str(),
2028 &upstream.uri()
2029 )
2030 .unwrap()
2031 .is_none()
2032 );
2033 }
2034
2035 #[tokio::test]
2036 async fn models_update_persists_exact_routes_and_keeps_prior_rows_after_failure() {
2037 let _env = crate::test_support::lock_test_env();
2038 let home = tempfile::tempdir().unwrap();
2039 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2040 crate::provider_catalog_live::reset_cache_for_test();
2041 let _cli_key = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
2042 let first = wiremock::MockServer::start().await;
2043 let second = wiremock::MockServer::start().await;
2044 let config = catalog_test_config(&first.uri(), &second.uri());
2045 let first_id = config.resolve_provider_identity("catalog-first").unwrap();
2046 let second_id = config.resolve_provider_identity("catalog-second").unwrap();
2047 catalog_mock(
2048 &first,
2049 "first-route-test-key",
2050 200,
2051 serde_json::json!({"data":[{"id":"new-first-model"}]}),
2052 )
2053 .await;
2054 catalog_mock(
2055 &second,
2056 "second-route-test-key",
2057 200,
2058 serde_json::json!({"data":[{"id":"new-second-model"}]}),
2059 )
2060 .await;
2061 assert_eq!(
2062 update_provider_catalog(&config, &first_id).await.outcome,
2063 "updated"
2064 );
2065 assert_eq!(
2066 update_provider_catalog(&config, &second_id).await.outcome,
2067 "updated"
2068 );
2069 assert_eq!(config.provider.as_deref(), Some("catalog-first"));
2070 assert_eq!(config.default_model(), "saved-model");
2071 // Simulate restart: remove only this memo, not any persistent state.
2072 crate::provider_catalog_live::reset_cache_for_test();
2073 assert_eq!(
2074 catalog_models_for_route(ProviderKind::Custom, "catalog-first", &first.uri()),
2075 ["new-first-model"]
2076 );
2077 assert_eq!(
2078 catalog_models_for_route(ProviderKind::Custom, "catalog-second", &second.uri()),
2079 ["new-second-model"]
2080 );
2081 assert!(
2082 catalog_models_for_route(ProviderKind::Custom, "catalog-first", &second.uri())
2083 .is_empty()
2084 );
2085 let prior = cached_receipt(&config, &first_id).fetched_at;
2086 first.reset().await;
2087 catalog_mock(
2088 &first,
2089 "first-route-test-key",
2090 401,
2091 serde_json::json!({"error":"first-route-test-key"}),
2092 )
2093 .await;
2094 let receipt = update_provider_catalog(&config, &first_id).await;
2095 assert_eq!(receipt.outcome, "failed");
2096 assert_eq!(receipt.fetched_at, prior);
2097 assert!(matches!(
2098 receipt.status,
2099 CatalogStatus::Failed {
2100 reason: codewhale_config::catalog::CatalogRefreshError::Unauthorized
2101 }
2102 ));
2103 assert_eq!(
2104 catalog_models_for_route(ProviderKind::Custom, "catalog-first", &first.uri()),
2105 ["new-first-model"]
2106 );
2107 let body =
2108 std::fs::read_to_string(crate::provider_catalog_live::cache_path().unwrap()).unwrap();
2109 assert!(!body.contains("first-route-test-key"));
2110 assert!(!body.contains(&first.uri()));
2111 assert!(
2112 !serde_json::to_string(&receipt)
2113 .unwrap()
2114 .contains("first-route-test-key")
2115 );
2116 }
2117
2118 #[tokio::test]
2119 async fn models_update_removes_withdrawn_ids_and_rejects_secret_or_control_ids() {
2120 let _env = crate::test_support::lock_test_env();
2121 let home = tempfile::tempdir().unwrap();
2122 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2123 crate::provider_catalog_live::reset_cache_for_test();
2124 let _cli_key = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
2125 let server = wiremock::MockServer::start().await;
2126 let config = catalog_test_config(&server.uri(), &server.uri());
2127 let identity = config.resolve_provider_identity("catalog-first").unwrap();
2128 for model in [
2129 "old-model",
2130 "new-model",
2131 "first-route-test-key",
2132 "bad\u{1b}[31m-model",
2133 ] {
2134 server.reset().await;
2135 catalog_mock(
2136 &server,
2137 "first-route-test-key",
2138 200,
2139 serde_json::json!({"data":[{"id": model}]}),
2140 )
2141 .await;
2142 let receipt = update_provider_catalog(&config, &identity).await;
2143 if model.starts_with("old-") || model.starts_with("new-") {
2144 assert_eq!(receipt.outcome, "updated");
2145 assert_eq!(
2146 catalog_models_for_route(
2147 ProviderKind::Custom,
2148 identity.key.as_str(),
2149 &server.uri()
2150 ),
2151 [model]
2152 );
2153 } else {
2154 assert_eq!(receipt.outcome, "failed");
2155 assert_eq!(
2156 catalog_models_for_route(
2157 ProviderKind::Custom,
2158 identity.key.as_str(),
2159 &server.uri()
2160 ),
2161 ["new-model"]
2162 );
2163 }
2164 }
2165 server.reset().await;
2166 catalog_mock(
2167 &server,
2168 "first-route-test-key",
2169 200,
2170 serde_json::json!({"data":[]}),
2171 )
2172 .await;
2173 let receipt = update_provider_catalog(&config, &identity).await;
2174 // The existing provider adapter treats an empty list as a failed
2175 // refresh. Preserve the last usable rows and disclose that failure.
2176 assert_eq!(receipt.outcome, "failed");
2177 assert_eq!(
2178 catalog_models_for_route(ProviderKind::Custom, identity.key.as_str(), &server.uri()),
2179 ["new-model"]
2180 );
2181 }
2182
2183 #[tokio::test]
2184 async fn chatgpt_catalog_refresh_rejects_unregistered_legacy_credentials() {
2185 let _env = crate::test_support::lock_test_env();
2186 let _token = crate::test_support::EnvVarGuard::set("CODEX_ACCESS_TOKEN", "legacy-token");
2187 let config = Config {
2188 provider: Some("openai-codex".to_string()),
2189 ..Default::default()
2190 };
2191 let identity = config.resolve_provider_identity("openai-codex").unwrap();
2192 let receipt = update_provider_catalog(&config, &identity).await;
2193 assert_eq!(receipt.outcome, "failed");
2194 assert_eq!(receipt.error, Some("chatgpt_plan_permission_required"));
2195 assert_eq!(receipt.model_count, 0);
2196 }
2197
2198 #[test]
2199 fn codex_live_roster_receipt_discloses_skipped_persistence() {
2200 let identity = Config::default()
2201 .resolve_provider_identity("openai-codex")
2202 .unwrap();
2203 let roster = codex_model_cache::CodexModelRoster {
2204 models: Vec::new(),
2205 freshness: codex_model_cache::CodexModelCacheFreshness::Fresh,
2206 fetched_at: None,
2207 observed_at: Some(chrono::Utc::now()),
2208 source: "chatgpt_plan_api",
2209 observation_persisted: false,
2210 };
2211 let receipt = codex_roster_receipt(&identity, &roster);
2212 assert_eq!(receipt.status, CatalogStatus::Fresh);
2213 assert_eq!(receipt.error, Some("chatgpt_catalog_not_persisted"));
2214 assert_eq!(receipt.fetched_at, None);
2215 assert!(receipt.observed_at.is_some());
2216 }
2217
2218 #[tokio::test]
2219 async fn models_listing_is_offline_and_update_never_forwards_another_routes_cli_key() {
2220 let _env = crate::test_support::lock_test_env();
2221 let home = tempfile::tempdir().unwrap();
2222 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2223 crate::provider_catalog_live::reset_cache_for_test();
2224 let _source =
2225 crate::test_support::EnvVarGuard::set(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
2226 let _key = crate::test_support::EnvVarGuard::set(
2227 codewhale_config::CLI_API_KEY_ENV,
2228 "active-route-cli-key",
2229 );
2230 let server = wiremock::MockServer::start().await;
2231 let config = catalog_test_config(&server.uri(), &server.uri());
2232 run_models(&config, false, Some("catalog-first"), true)
2233 .await
2234 .unwrap();
2235 let other = config.resolve_provider_identity("catalog-second").unwrap();
2236 let receipt = update_provider_catalog(&config, &other).await;
2237 assert_eq!(receipt.outcome, "skipped");
2238 assert_eq!(receipt.error, Some("cli_key_is_scoped_to_active_provider"));
2239 assert!(server.received_requests().await.unwrap().is_empty());
2240 assert!(!crate::provider_catalog_live::cache_path().unwrap().exists());
2241 }
2242
2243 #[tokio::test]
2244 async fn models_update_reports_io_failure_without_claiming_persistence() {
2245 let _env = crate::test_support::lock_test_env();
2246 let home = tempfile::tempdir().unwrap();
2247 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2248 crate::provider_catalog_live::reset_cache_for_test();
2249 let _cli_key = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
2250 let server = wiremock::MockServer::start().await;
2251 let config = catalog_test_config(&server.uri(), &server.uri());
2252 let identity = config.resolve_provider_identity("catalog-first").unwrap();
2253 let path = crate::provider_catalog_live::cache_path().unwrap();
2254 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2255 std::fs::write(&path, b"broken cache").unwrap();
2256 let receipt = update_provider_catalog(&config, &identity).await;
2257 assert_eq!(receipt.outcome, "failed");
2258 assert_eq!(receipt.error, Some("cache_read_failed"));
2259 assert_eq!(std::fs::read(&path).unwrap(), b"broken cache");
2260 assert!(server.received_requests().await.unwrap().is_empty());
2261 }
2262
2263 #[test]
2264 fn models_update_scope_includes_every_named_identity_once() {
2265 let _env = crate::test_support::lock_test_env();
2266 let config = catalog_test_config("http://localhost:1", "http://localhost:2");
2267 let identities = catalog_identities(&config, None, true).unwrap();
2268 for name in ["catalog-first", "catalog-second"] {
2269 assert_eq!(
2270 identities
2271 .iter()
2272 .filter(|identity| identity.key.as_str() == name)
2273 .count(),
2274 1
2275 );
2276 }
2277 assert_eq!(
2278 catalog_identities(&config, Some("catalog-second"), true)
2279 .unwrap()
2280 .len(),
2281 1
2282 );
2283 }
2284
2285 #[test]
2286 fn together_catalog_includes_flash_from_bundled_asset() {
2287 let _live = lock_live_snapshot();
2288 clear_live_snapshot();
2289 let models = all_catalog_models_for_provider(ProviderKind::Together);
2290 assert!(
2291 models.contains(&DEFAULT_TOGETHER_MODEL.to_string()),
2292 "missing Together pro: {models:?}"
2293 );
2294 assert!(
2295 models.contains(&DEFAULT_TOGETHER_FLASH_MODEL.to_string()),
2296 "missing Together flash: {models:?}"
2297 );
2298 }
2299
2300 #[test]
2301 fn configured_providers_matches_provider_predicate() {
2302 let _env_lock = crate::test_support::lock_test_env();
2303 let tmp = tempfile::tempdir().expect("tempdir");
2304 let _auth_file = crate::test_support::EnvVarGuard::set(
2305 "OPENAI_CODEX_AUTH_FILE",
2306 tmp.path().join("missing-auth.json"),
2307 );
2308 let _openai_token = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
2309 let _codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
2310 let config = Config::default();
2311 let active = config.active_provider_identity().unwrap();
2312 let expected: Vec<_> = config
2313 .provider_identities()
2314 .into_iter()
2315 .filter(|identity| {
2316 crate::config::provider_is_configured_for_active(&config, identity, &active)
2317 })
2318 .collect();
2319 assert_eq!(configured_providers(&config), expected);
2320 }
2321
2322 #[test]
2323 fn models_for_provider_filters_unconfigured_gateways() {
2324 let _env_lock = crate::test_support::lock_test_env();
2325 let _together = crate::test_support::EnvVarGuard::remove("TOGETHER_API_KEY");
2326 let config = Config::default();
2327 assert!(
2328 models_for_provider(
2329 &config,
2330 &config.test_identity_for_kind(ProviderKind::Together)
2331 )
2332 .is_empty()
2333 );
2334 assert!(
2335 !models_for_provider(
2336 &config,
2337 &config.test_identity_for_kind(ProviderKind::Deepseek)
2338 )
2339 .is_empty()
2340 );
2341 }
2342
2343 /// #4116 CRITICAL (no-narrowing guarantee for the migrated consumer): the
2344 /// catalog-backed facade must return a NON-EMPTY enumeration for every
2345 /// provider that has a non-empty legacy `model_completion_names_for_provider`
2346 /// table. `all_catalog_models_for_provider` falls back to that legacy table
2347 /// whenever the merged catalog has no rows for the provider, so this holds by
2348 /// construction — and it proves that the raw-legacy tail removed from the
2349 /// subagent `operator_model_for_subagent` consumer (which only ran when the
2350 /// facade was empty) was unreachable whenever legacy was non-empty. The
2351 /// migrated consumer is therefore behavior-preserving: it always has a
2352 /// catalog-sourced model to pick and never narrows to fewer choices than the
2353 /// legacy path offered.
2354 ///
2355 /// Note: the facade is intentionally *catalog-authoritative* (live >
2356 /// bundled > legacy fallback, #4188), so for some providers whose catalog
2357 /// supersedes stale entries in the legacy placeholder table (e.g.
2358 /// OpenRouter/MiniMax revisions), the facade is not a strict superset of
2359 /// every legacy id. That divergence does not affect subagent model
2360 /// *acceptance*, which is gated by `validate_route` /
2361 /// `requested_model_for_provider`, not by this list.
2362 #[test]
2363 fn catalog_facade_covers_every_provider_with_a_legacy_table() {
2364 let _env = crate::test_support::lock_test_env();
2365 let codex_home = tempfile::tempdir().expect("temporary CODEX_HOME");
2366 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
2367 let _live = lock_live_snapshot();
2368 clear_live_snapshot();
2369 for &provider in ProviderKind::all() {
2370 let legacy_len = model_completion_names_for_provider(provider).len();
2371 if legacy_len == 0 || provider == ProviderKind::OpenaiCodex {
2372 continue;
2373 }
2374 assert!(
2375 !all_catalog_models_for_provider(provider).is_empty(),
2376 "catalog facade returned no models for {provider:?} despite a \
2377 non-empty legacy table ({legacy_len} entries): the operator-route \
2378 consumer would have nothing to enumerate"
2379 );
2380 }
2381 }
2382
2383 /// #4188: CodeWhale-only / local providers keep defaults via the legacy
2384 /// fallback when Models.dev (live or bundled) has no rows for them.
2385 #[test]
2386 fn codewhale_only_providers_keep_legacy_defaults() {
2387 let _env = crate::test_support::lock_test_env();
2388 let codex_home = tempfile::tempdir().expect("temporary CODEX_HOME");
2389 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
2390 let _live = lock_live_snapshot();
2391 clear_live_snapshot();
2392 // A public model table cannot attest account plan permission.
2393 assert!(all_catalog_models_for_provider(ProviderKind::OpenaiCodex).is_empty());
2394
2395 // Ollama intentionally has an empty legacy table (user-supplied ids);
2396 // the lake must still return empty rather than inventing rows.
2397 assert!(all_catalog_models_for_provider(ProviderKind::Ollama).is_empty());
2398 assert!(model_completion_names_for_provider(ProviderKind::Ollama).is_empty());
2399 assert!(live_per_provider_models(ProviderKind::Ollama).is_empty());
2400 }
2401
2402 #[test]
2403 fn ollama_live_default_uses_per_provider_tags_not_models_dev() {
2404 let _live = lock_live_snapshot();
2405 clear_live_snapshot();
2406
2407 set_live_snapshot(
2408 CatalogSnapshot {
2409 offerings: vec![CatalogOffering {
2410 provider: "ollama".to_string(),
2411 wire_model_id: "deepseek-v4-flash".to_string(),
2412 endpoint_key: "chat".to_string(),
2413 default_for_provider: true,
2414 ..Default::default()
2415 }],
2416 },
2417 LiveSource::ModelsDev,
2418 );
2419 assert!(
2420 live_per_provider_models(ProviderKind::Ollama).is_empty(),
2421 "Models.dev must not satisfy a local Ollama default"
2422 );
2423
2424 merge_live_offerings(vec![CatalogOffering {
2425 provider: "ollama".to_string(),
2426 wire_model_id: "qwen2.5:0.5b".to_string(),
2427 endpoint_key: "chat".to_string(),
2428 default_for_provider: true,
2429 ..Default::default()
2430 }]);
2431 assert_eq!(
2432 live_per_provider_models(ProviderKind::Ollama),
2433 vec!["qwen2.5:0.5b".to_string()]
2434 );
2435 clear_live_snapshot();
2436 }
2437
2438 /// #4116 / #4188 (AC): a provider with no bundled/live catalog coverage must
2439 /// fall back to the legacy table verbatim, so CodeWhale-only routes stay
2440 /// usable. We assert this for every currently-unbundled provider that still
2441 /// carries a non-empty legacy list, and require at least one such provider
2442 /// to exist so the fallback path is actually exercised.
2443 #[test]
2444 fn unbundled_provider_falls_back_to_legacy_table() {
2445 let _live = lock_live_snapshot();
2446 clear_live_snapshot();
2447 let merged = merged_snapshot();
2448 let mut exercised = 0usize;
2449 for &provider in ProviderKind::all() {
2450 // OpenAI Codex deliberately owns an account-scoped cache source;
2451 // its fallback behavior is covered separately above.
2452 if provider == ProviderKind::OpenaiCodex {
2453 continue;
2454 }
2455 let catalog_id = catalog_provider_id(provider);
2456 let has_catalog_rows = !merged.offerings_for_provider(catalog_id).is_empty();
2457 let legacy = model_completion_names_for_provider(provider);
2458 if has_catalog_rows || legacy.is_empty() {
2459 continue;
2460 }
2461 // Unbundled + non-empty legacy: the facade must echo the legacy list.
2462 let facade = all_catalog_models_for_provider(provider);
2463 let expected: Vec<String> = legacy.iter().map(|m| m.to_string()).collect();
2464 assert_eq!(
2465 facade, expected,
2466 "unbundled provider {provider:?} did not fall back to the legacy table"
2467 );
2468 exercised += 1;
2469 }
2470 assert!(
2471 exercised > 0,
2472 "expected at least one unbundled provider to exercise the legacy fallback path"
2473 );
2474 }
2475
2476 /// #4188: live Models.dev rows win over bundled on identity, and clearing
2477 /// live restores the offline bundled snapshot (offline startup still works).
2478 #[test]
2479 fn live_snapshot_merges_over_bundled() {
2480 let _live = lock_live_snapshot();
2481 clear_live_snapshot();
2482 // With no live snapshot, we get bundled models.
2483 let bundled = all_catalog_models_for_provider(ProviderKind::Deepseek);
2484 assert!(!bundled.is_empty());
2485
2486 // Set a live snapshot that adds a synthetic model.
2487 let live = CatalogSnapshot {
2488 offerings: vec![CatalogOffering {
2489 provider: "deepseek".to_string(),
2490 wire_model_id: "deepseek-v4-synthetic".to_string(),
2491 endpoint_key: "chat".to_string(),
2492 ..Default::default()
2493 }],
2494 };
2495 set_live_snapshot(live, LiveSource::ModelsDev);
2496 let merged = all_catalog_models_for_provider(ProviderKind::Deepseek);
2497 assert!(merged.contains(&"deepseek-v4-synthetic".to_string()));
2498 // The bundled model is still present.
2499 assert!(merged.iter().any(|m| bundled.contains(m)));
2500
2501 clear_live_snapshot();
2502 let after_clear = all_catalog_models_for_provider(ProviderKind::Deepseek);
2503 assert_eq!(after_clear, bundled);
2504 }
2505
2506 #[test]
2507 fn provider_owned_roster_replaces_bundled_and_models_dev_rows() {
2508 let _live = lock_live_snapshot();
2509 clear_live_snapshot();
2510 let bundled = all_catalog_models_for_provider(ProviderKind::Openrouter);
2511 assert!(
2512 !bundled.is_empty(),
2513 "OpenRouter must have an offline fallback roster"
2514 );
2515
2516 set_live_snapshot(
2517 CatalogSnapshot {
2518 offerings: vec![CatalogOffering {
2519 provider: "openrouter".to_string(),
2520 wire_model_id: "models-dev-only-openrouter-model".to_string(),
2521 endpoint_key: "chat".to_string(),
2522 ..Default::default()
2523 }],
2524 },
2525 LiveSource::ModelsDev,
2526 );
2527 set_live_snapshot(
2528 CatalogSnapshot {
2529 offerings: vec![CatalogOffering {
2530 provider: "openrouter".to_string(),
2531 wire_model_id: "provider-owned-openrouter-model".to_string(),
2532 endpoint_key: "chat".to_string(),
2533 ..Default::default()
2534 }],
2535 },
2536 LiveSource::PerProvider,
2537 );
2538
2539 assert_eq!(
2540 all_catalog_models_for_provider(ProviderKind::Openrouter),
2541 vec!["provider-owned-openrouter-model".to_string()],
2542 "a successful provider-owned refresh must remove stale bundled and Models.dev ids"
2543 );
2544
2545 replace_provider_live_snapshot("openrouter", CatalogSnapshot::default());
2546 let restored_cross_provider = all_catalog_models_for_provider(ProviderKind::Openrouter);
2547 assert!(
2548 restored_cross_provider.contains(&"models-dev-only-openrouter-model".to_string()),
2549 "clearing the exact partition must restore the cross-provider fallback"
2550 );
2551 assert!(
2552 restored_cross_provider
2553 .iter()
2554 .any(|model| bundled.contains(model)),
2555 "clearing the exact partition must restore bundled fallbacks"
2556 );
2557
2558 clear_live_snapshot();
2559 assert_eq!(
2560 all_catalog_models_for_provider(ProviderKind::Openrouter),
2561 bundled
2562 );
2563 }
2564
2565 #[test]
2566 fn named_custom_catalogs_keep_exact_identity_without_compiled_seeds() {
2567 let _live = lock_live_snapshot();
2568 clear_live_snapshot();
2569
2570 // No live rows, no bundled rows, no configured rows: an ordinary
2571 // custom route offers nothing rather than a compiled default (#6289).
2572 for identity in ["baseten", "another-custom-host"] {
2573 assert!(
2574 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some(identity))
2575 .is_empty(),
2576 "{identity} must not invent models offline"
2577 );
2578 }
2579
2580 set_live_snapshot(
2581 CatalogSnapshot {
2582 offerings: vec![CatalogOffering {
2583 provider: "baseten".to_string(),
2584 wire_model_id: "synthetic-live-baseten-model".to_string(),
2585 endpoint_key: "chat".to_string(),
2586 source: CatalogSource::Live {
2587 base_url_fingerprint: "baseten-fp".to_string(),
2588 fetched_at: 42,
2589 },
2590 ..Default::default()
2591 }],
2592 },
2593 LiveSource::PerProvider,
2594 );
2595
2596 assert_eq!(
2597 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("baseten")),
2598 vec!["synthetic-live-baseten-model".to_string()]
2599 );
2600 let case_distinct =
2601 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("BASETEN"));
2602 assert!(
2603 case_distinct.is_empty(),
2604 "a case variant shares neither seeds nor another exact table's live roster"
2605 );
2606 assert!(
2607 catalog_offering_for_model_identity(
2608 ProviderKind::Custom,
2609 Some("baseten"),
2610 "synthetic-live-baseten-model",
2611 )
2612 .is_some()
2613 );
2614 assert!(
2615 catalog_offering_for_model(ProviderKind::Custom, "synthetic-live-baseten-model",)
2616 .is_none(),
2617 "the generic custom bucket must not see Baseten rows"
2618 );
2619
2620 clear_live_snapshot();
2621 }
2622
2623 #[test]
2624 fn case_colliding_and_builtin_named_custom_catalogs_stay_isolated() {
2625 let _live = lock_live_snapshot();
2626 clear_live_snapshot();
2627
2628 for (provider, model) in [("CustomA", "upper-model"), ("customa", "lower-model")] {
2629 replace_provider_live_snapshot(
2630 provider,
2631 CatalogSnapshot {
2632 offerings: vec![CatalogOffering {
2633 provider: provider.to_string(),
2634 wire_model_id: model.to_string(),
2635 endpoint_key: "chat".to_string(),
2636 ..Default::default()
2637 }],
2638 },
2639 );
2640 }
2641
2642 assert_eq!(
2643 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("CustomA")),
2644 vec!["upper-model".to_string()]
2645 );
2646 assert_eq!(
2647 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("customa")),
2648 vec!["lower-model".to_string()]
2649 );
2650 let built_in_openai = all_catalog_models_for_provider(ProviderKind::Openai);
2651 assert!(!built_in_openai.is_empty());
2652 assert!(
2653 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("openai"))
2654 .is_empty(),
2655 "a custom table named openai must not borrow the first-class OpenAI template"
2656 );
2657 for model in &built_in_openai {
2658 assert!(
2659 catalog_offering_for_model_identity(ProviderKind::Custom, Some("openai"), model)
2660 .is_none(),
2661 "an exact custom table named openai must not inherit built-in model {model}"
2662 );
2663 }
2664
2665 let custom_model = "custom-openai-only-model";
2666 replace_provider_live_snapshot_for_identity(
2667 ProviderKind::Custom,
2668 "openai",
2669 CatalogSnapshot {
2670 offerings: vec![CatalogOffering {
2671 provider: "openai".to_string(),
2672 wire_model_id: custom_model.to_string(),
2673 endpoint_key: "chat".to_string(),
2674 ..Default::default()
2675 }],
2676 },
2677 );
2678 assert_eq!(
2679 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("openai")),
2680 vec![custom_model.to_string()],
2681 "the exact custom table must retrieve its own built-in-looking roster"
2682 );
2683 assert_eq!(
2684 all_catalog_models_for_provider(ProviderKind::Openai),
2685 built_in_openai,
2686 "publishing custom openai must not replace or suppress built-in OpenAI"
2687 );
2688 assert!(
2689 catalog_offering_for_model(ProviderKind::Openai, custom_model).is_none(),
2690 "the built-in OpenAI route must not see the custom table's row"
2691 );
2692
2693 let built_in_live_model = "built-in-openai-only-model";
2694 replace_provider_live_snapshot_for_identity(
2695 ProviderKind::Openai,
2696 "openai",
2697 CatalogSnapshot {
2698 offerings: vec![CatalogOffering {
2699 provider: "openai".to_string(),
2700 wire_model_id: built_in_live_model.to_string(),
2701 endpoint_key: "chat".to_string(),
2702 ..Default::default()
2703 }],
2704 },
2705 );
2706 assert_eq!(
2707 all_catalog_models_for_provider(ProviderKind::Openai),
2708 vec![built_in_live_model.to_string()]
2709 );
2710 assert_eq!(
2711 all_catalog_models_for_provider_identity(ProviderKind::Custom, Some("openai")),
2712 vec![custom_model.to_string()],
2713 "publishing built-in OpenAI must not replace the custom table's roster"
2714 );
2715
2716 clear_live_snapshot();
2717 }
2718
2719 #[test]
2720 fn live_catalog_origin_prefers_per_provider_over_models_dev() {
2721 let _live = lock_live_snapshot();
2722 clear_live_snapshot();
2723 let wire = "accounts/fireworks/models/deepseek-v4-flash-0731";
2724 assert_eq!(live_catalog_origin(ProviderKind::Fireworks, wire), None);
2725
2726 set_live_snapshot(
2727 CatalogSnapshot {
2728 offerings: vec![CatalogOffering {
2729 provider: "fireworks".to_string(),
2730 wire_model_id: wire.to_string(),
2731 endpoint_key: "chat".to_string(),
2732 ..Default::default()
2733 }],
2734 },
2735 LiveSource::ModelsDev,
2736 );
2737 assert_eq!(
2738 live_catalog_origin(ProviderKind::Fireworks, wire),
2739 Some(LiveSource::ModelsDev)
2740 );
2741
2742 set_live_snapshot(
2743 CatalogSnapshot {
2744 offerings: vec![CatalogOffering {
2745 provider: "fireworks".to_string(),
2746 wire_model_id: wire.to_string(),
2747 endpoint_key: "chat".to_string(),
2748 ..Default::default()
2749 }],
2750 },
2751 LiveSource::PerProvider,
2752 );
2753 assert_eq!(
2754 live_catalog_origin(ProviderKind::Fireworks, wire),
2755 Some(LiveSource::PerProvider)
2756 );
2757 clear_live_snapshot();
2758 }
2759
2760 /// Footprint: the merge holds `Arc`s into the bundled and Models.dev
2761 /// layers, so only rows it rewrites exist twice in memory.
2762 #[test]
2763 fn merged_snapshot_shares_rows_with_its_layers_instead_of_copying_them() {
2764 let _live = lock_live_snapshot();
2765 clear_live_snapshot();
2766
2767 let live_id = "deepseek-shared-row-probe";
2768 set_live_snapshot(
2769 CatalogSnapshot {
2770 offerings: vec![CatalogOffering {
2771 provider: "deepseek".to_string(),
2772 wire_model_id: live_id.to_string(),
2773 endpoint_key: "chat".to_string(),
2774 ..Default::default()
2775 }],
2776 },
2777 LiveSource::ModelsDev,
2778 );
2779 let merged = merged_snapshot();
2780 let live_row = {
2781 let live = LIVE_SNAPSHOT.read().expect("live snapshot");
2782 let models_dev = live.models_dev.as_ref().expect("models.dev partition");
2783 Arc::clone(&models_dev.offerings[0])
2784 };
2785 let merged_live_row = merged
2786 .offerings
2787 .iter()
2788 .find(|row| row.wire_model_id == live_id)
2789 .expect("live row merged");
2790 assert!(
2791 Arc::ptr_eq(merged_live_row, &live_row),
2792 "the merge must share the Models.dev row, not hold a second copy"
2793 );
2794
2795 let bundled = bundled_snapshot();
2796 let shared_bundled = merged
2797 .offerings
2798 .iter()
2799 .filter(|row| bundled.offerings.iter().any(|b| Arc::ptr_eq(b, row)))
2800 .count();
2801 let untouched_bundled = bundled
2802 .offerings
2803 .iter()
2804 .filter(|row| {
2805 ProviderKind::parse(&row.provider) != Some(ProviderKind::OpencodeGo)
2806 && !(row.provider == "deepseek" && row.wire_model_id == live_id)
2807 })
2808 .count();
2809 assert_eq!(
2810 shared_bundled, untouched_bundled,
2811 "every bundled row the merge does not rewrite must be shared"
2812 );
2813
2814 clear_live_snapshot();
2815 }
2816
2817 /// Memoization: repeated `merged_snapshot()` calls return the cached merge
2818 /// (same `Arc` allocation), and publishing or clearing a live snapshot
2819 /// invalidates the cache so new content becomes visible.
2820 #[test]
2821 fn merged_snapshot_cache_invalidates_on_live_snapshot_change() {
2822 let _live = lock_live_snapshot();
2823 clear_live_snapshot();
2824
2825 let bundled_only = merged_snapshot();
2826 assert!(
2827 Arc::ptr_eq(&bundled_only, &merged_snapshot()),
2828 "repeated merged_snapshot() calls must return the cached Arc"
2829 );
2830 let probe = "deepseek-cache-probe-model";
2831 assert!(
2832 !bundled_only
2833 .offerings
2834 .iter()
2835 .any(|row| row.wire_model_id == probe),
2836 "probe model must not pre-exist in the bundled snapshot"
2837 );
2838
2839 set_live_snapshot(
2840 CatalogSnapshot {
2841 offerings: vec![CatalogOffering {
2842 provider: "deepseek".to_string(),
2843 wire_model_id: probe.to_string(),
2844 endpoint_key: "chat".to_string(),
2845 ..Default::default()
2846 }],
2847 },
2848 LiveSource::ModelsDev,
2849 );
2850 let with_live = merged_snapshot();
2851 assert!(
2852 !Arc::ptr_eq(&bundled_only, &with_live),
2853 "set_live_snapshot must invalidate the memoized merge"
2854 );
2855 assert!(
2856 with_live
2857 .offerings
2858 .iter()
2859 .any(|row| row.wire_model_id == probe),
2860 "new live content must be visible after set_live_snapshot"
2861 );
2862
2863 clear_live_snapshot();
2864 let after_clear = merged_snapshot();
2865 assert!(
2866 !after_clear
2867 .offerings
2868 .iter()
2869 .any(|row| row.wire_model_id == probe),
2870 "clear_live_snapshot must invalidate the memoized merge"
2871 );
2872 assert_eq!(
2873 after_clear.offerings, bundled_only.offerings,
2874 "clearing live must restore the bundled-only merge content"
2875 );
2876 }
2877
2878 #[test]
2879 fn opencode_go_lake_corrects_stale_protocols_in_saved_and_live_rows() {
2880 let _live = lock_live_snapshot();
2881 clear_live_snapshot();
2882
2883 let mut offerings: Vec<_> = crate::config::opencode_go_models()
2884 .iter()
2885 .map(|model| CatalogOffering {
2886 provider: "opencode_go".to_string(),
2887 wire_model_id: if *model == crate::config::DEFAULT_OPENCODE_GO_MODEL {
2888 format!("opencode-go/{model}")
2889 } else {
2890 (*model).to_string()
2891 },
2892 endpoint_key: "chat".to_string(),
2893 ..Default::default()
2894 })
2895 .collect();
2896 offerings.extend(["minimax-m3", "qwen3.7-max"].map(|model| CatalogOffering {
2897 provider: "opencode-go".to_string(),
2898 wire_model_id: model.to_string(),
2899 endpoint_key: "messages".to_string(),
2900 ..Default::default()
2901 }));
2902 set_live_snapshot(CatalogSnapshot { offerings }, LiveSource::ModelsDev);
2903
2904 let models: std::collections::BTreeSet<_> =
2905 all_catalog_models_for_provider(ProviderKind::OpencodeGo)
2906 .into_iter()
2907 .collect();
2908 let expected: std::collections::BTreeSet<_> = crate::config::opencode_go_models()
2909 .iter()
2910 .map(|model| (*model).to_string())
2911 .collect();
2912 assert_eq!(models, expected);
2913 for (model, endpoint) in [("minimax-m3", "messages"), ("grok-4.6", "responses")] {
2914 let row = catalog_offering_for_model(ProviderKind::OpencodeGo, model)
2915 .expect("documented model survives refresh");
2916 assert_eq!(row.endpoint_key, endpoint);
2917 }
2918 assert!(
2919 catalog_offering_for_model(
2920 ProviderKind::OpencodeGo,
2921 crate::config::DEFAULT_OPENCODE_GO_MODEL,
2922 )
2923 .is_some()
2924 );
2925
2926 clear_live_snapshot();
2927 }
2928
2929 /// #4188: live > bundled > legacy fallback precedence, including live
2930 /// override of a bundled wire id and no duplicate rows after alias
2931 /// normalization (`moonshotai` → `moonshot`).
2932 #[test]
2933 fn live_over_bundled_over_legacy_precedence_and_alias_dedupe() {
2934 let _live = lock_live_snapshot();
2935 clear_live_snapshot();
2936
2937 let bundled_moonshot = all_catalog_models_for_provider(ProviderKind::Moonshot);
2938 assert!(
2939 !bundled_moonshot.is_empty(),
2940 "offline bundled Moonshot seed required: {bundled_moonshot:?}"
2941 );
2942
2943 // Live rows use the Models.dev alias id; lake merge must normalize onto
2944 // CodeWhale `moonshot` and not leave a parallel `moonshotai` bucket.
2945 let live = CatalogSnapshot {
2946 offerings: vec![
2947 CatalogOffering {
2948 provider: "moonshot".to_string(),
2949 wire_model_id: "kimi-k2.5-live".to_string(),
2950 endpoint_key: "chat".to_string(),
2951 default_for_provider: true,
2952 ..Default::default()
2953 },
2954 // Same identity as a typical bundled Moonshot default — live wins.
2955 CatalogOffering {
2956 provider: "moonshot".to_string(),
2957 wire_model_id: bundled_moonshot[0].clone(),
2958 endpoint_key: "chat".to_string(),
2959 family: Some("live-override".to_string()),
2960 ..Default::default()
2961 },
2962 ],
2963 };
2964 set_live_snapshot(live, LiveSource::ModelsDev);
2965
2966 let merged = merged_snapshot();
2967 let moonshot_rows = merged.offerings_for_provider("moonshot");
2968 assert!(
2969 moonshot_rows
2970 .iter()
2971 .any(|r| r.wire_model_id == "kimi-k2.5-live"),
2972 "live-only Moonshot row missing: {moonshot_rows:?}"
2973 );
2974 let overridden = moonshot_rows
2975 .iter()
2976 .find(|r| r.wire_model_id == bundled_moonshot[0])
2977 .expect("bundled Moonshot id should still exist after live merge");
2978 assert_eq!(
2979 overridden.family.as_deref(),
2980 Some("live-override"),
2981 "live row must replace bundled facts on the same wire id"
2982 );
2983 assert!(
2984 merged.offerings_for_provider("moonshotai").is_empty(),
2985 "alias-normalized providers must not leave a duplicate moonshotai bucket"
2986 );
2987
2988 let models = all_catalog_models_for_provider(ProviderKind::Moonshot);
2989 let mut seen = std::collections::BTreeSet::new();
2990 for model in &models {
2991 assert!(
2992 seen.insert(model.to_ascii_lowercase()),
2993 "duplicate Moonshot model row after alias merge: {model}"
2994 );
2995 }
2996 assert!(models.contains(&"kimi-k2.5-live".to_string()));
2997
2998 // Legacy fallback is skipped when catalog rows exist (even if legacy
2999 // lists additional ids) — catalog is authoritative once non-empty.
3000 assert!(
3001 !model_completion_names_for_provider(ProviderKind::Moonshot).is_empty(),
3002 "legacy Moonshot table should still exist as fallback documentation"
3003 );
3004
3005 clear_live_snapshot();
3006 assert_eq!(
3007 all_catalog_models_for_provider(ProviderKind::Moonshot),
3008 bundled_moonshot,
3009 "clearing live must restore offline bundled Moonshot rows"
3010 );
3011 }
3012
3013 /// #4188: when live Models.dev emits both an alias id and the CodeWhale id
3014 /// for the same provider, compiling through `live_offerings_from_models_dev`
3015 /// then merging into the lake must not produce duplicate model rows.
3016 #[test]
3017 fn alias_normalized_live_rows_do_not_duplicate_in_lake() {
3018 let _live = lock_live_snapshot();
3019 clear_live_snapshot();
3020 let body = r#"{
3021 "models": {},
3022 "providers": {
3023 "moonshotai": {
3024 "id": "moonshotai",
3025 "models": {
3026 "kimi-k2.5": {
3027 "id": "kimi-k2.5",
3028 "modalities": { "input": ["text"], "output": ["text"] }
3029 }
3030 }
3031 },
3032 "moonshot": {
3033 "id": "moonshot",
3034 "models": {
3035 "kimi-k2.5": {
3036 "id": "kimi-k2.5",
3037 "modalities": { "input": ["text"], "output": ["text"] },
3038 "limit": { "context": 262144, "output": 8192 }
3039 },
3040 "kimi-k2.7-code": {
3041 "id": "kimi-k2.7-code",
3042 "modalities": { "input": ["text"], "output": ["text"] }
3043 }
3044 }
3045 }
3046 }
3047 }"#;
3048 let catalog =
3049 codewhale_config::models_dev::ModelsDevCatalog::parse_json(body).expect("parse");
3050 let live_rows =
3051 codewhale_config::catalog::live_offerings_from_models_dev(&catalog, 1_700_000_000);
3052 assert!(
3053 live_rows.iter().all(|r| r.provider == "moonshot"),
3054 "both moonshotai and moonshot must normalize onto moonshot: {:?}",
3055 live_rows
3056 .iter()
3057 .map(|r| r.provider.as_str())
3058 .collect::<Vec<_>>()
3059 );
3060 set_live_snapshot(
3061 CatalogSnapshot {
3062 offerings: live_rows,
3063 },
3064 LiveSource::ModelsDev,
3065 );
3066
3067 let models = all_catalog_models_for_provider(ProviderKind::Moonshot);
3068 let kimi_count = models.iter().filter(|m| m.as_str() == "kimi-k2.5").count();
3069 assert_eq!(
3070 kimi_count, 1,
3071 "alias-normalized providers must not duplicate kimi-k2.5: {models:?}"
3072 );
3073 assert!(
3074 merged_snapshot()
3075 .offerings_for_provider("moonshotai")
3076 .is_empty()
3077 );
3078 clear_live_snapshot();
3079 }
3080
3081 // ── Source-scoped partition tests (#4188 race fix) ──────────────────────
3082
3083 #[test]
3084 fn provider_live_snapshots_are_scoped_per_provider() {
3085 let _live = lock_live_snapshot();
3086 clear_live_snapshot();
3087
3088 set_live_snapshot(
3089 CatalogSnapshot {
3090 offerings: vec![CatalogOffering {
3091 provider: "telecomjs".to_string(),
3092 wire_model_id: "deepseek-v4-pro".to_string(),
3093 endpoint_key: "chat".to_string(),
3094 ..Default::default()
3095 }],
3096 },
3097 LiveSource::PerProvider,
3098 );
3099 let telecom_only = merged_snapshot();
3100 assert_eq!(telecom_only.offerings_for_provider("telecomjs").len(), 1);
3101
3102 set_live_snapshot(
3103 CatalogSnapshot {
3104 offerings: vec![CatalogOffering {
3105 provider: "another-gateway".to_string(),
3106 wire_model_id: "another-model".to_string(),
3107 endpoint_key: "chat".to_string(),
3108 ..Default::default()
3109 }],
3110 },
3111 LiveSource::PerProvider,
3112 );
3113
3114 let merged = merged_snapshot();
3115 assert_eq!(merged.offerings_for_provider("telecomjs").len(), 1);
3116 assert_eq!(merged.offerings_for_provider("another-gateway").len(), 1);
3117 assert!(
3118 !Arc::ptr_eq(&telecom_only, &merged),
3119 "publishing a second provider must invalidate the cached merge"
3120 );
3121
3122 clear_live_snapshot();
3123 }
3124
3125 /// Models.dev→TelecomJS completion order: Models.dev sets its snapshot first,
3126 /// then TelecomJS merges per-provider rows. Both sets must be present in the
3127 /// final merged view.
3128 #[test]
3129 fn models_dev_first_then_telecomjs_both_preserved() {
3130 let _live = lock_live_snapshot();
3131 clear_live_snapshot();
3132
3133 // 1) Models.dev publishes its cross-provider snapshot.
3134 let models_dev_rows = vec![
3135 CatalogOffering {
3136 provider: "deepseek".to_string(),
3137 wire_model_id: "deepseek-chat".to_string(),
3138 endpoint_key: "chat".to_string(),
3139 family: Some("deepseek".to_string()),
3140 source: CatalogSource::Live {
3141 base_url_fingerprint: "modelsdev-fp".to_string(),
3142 fetched_at: 1000,
3143 },
3144 ..Default::default()
3145 },
3146 CatalogOffering {
3147 provider: "zai".to_string(),
3148 wire_model_id: "glm-4".to_string(),
3149 endpoint_key: "chat".to_string(),
3150 family: Some("glm".to_string()),
3151 source: CatalogSource::Live {
3152 base_url_fingerprint: "modelsdev-fp".to_string(),
3153 fetched_at: 1000,
3154 },
3155 ..Default::default()
3156 },
3157 ];
3158 set_live_snapshot(
3159 CatalogSnapshot {
3160 offerings: models_dev_rows,
3161 },
3162 LiveSource::ModelsDev,
3163 );
3164 let before_provider_refresh = merged_snapshot();
3165 assert!(
3166 before_provider_refresh
3167 .offerings_for_provider("telecomjs")
3168 .is_empty()
3169 );
3170
3171 // 2) TelecomJS merges its per-provider rows (after Models.dev completes).
3172 let telecomjs_rows = vec![
3173 CatalogOffering {
3174 provider: "telecomjs".to_string(),
3175 wire_model_id: "deepseek-chat".to_string(),
3176 endpoint_key: "chat".to_string(),
3177 family: Some("deepseek".to_string()),
3178 source: CatalogSource::Live {
3179 base_url_fingerprint: "telecomjs-fp".to_string(),
3180 fetched_at: 2000,
3181 },
3182 ..Default::default()
3183 },
3184 CatalogOffering {
3185 provider: "telecomjs".to_string(),
3186 wire_model_id: "glm-4".to_string(),
3187 endpoint_key: "chat".to_string(),
3188 family: Some("glm".to_string()),
3189 source: CatalogSource::Live {
3190 base_url_fingerprint: "telecomjs-fp".to_string(),
3191 fetched_at: 2000,
3192 },
3193 ..Default::default()
3194 },
3195 ];
3196 merge_live_offerings(telecomjs_rows);
3197 assert_eq!(
3198 merged_snapshot().offerings_for_provider("telecomjs").len(),
3199 2,
3200 "provider refresh should invalidate the cached Models.dev-only view"
3201 );
3202
3203 // 3) Both sources' rows are present in the merged snapshot.
3204 let merged = merged_snapshot();
3205 let deepseek_rows = merged.offerings_for_provider("deepseek");
3206 assert!(
3207 deepseek_rows
3208 .iter()
3209 .any(|r| r.wire_model_id == "deepseek-chat"),
3210 "Models.dev deepseek row missing: {deepseek_rows:?}"
3211 );
3212 let zai_rows = merged.offerings_for_provider("zai");
3213 assert!(
3214 zai_rows.iter().any(|r| r.wire_model_id == "glm-4"),
3215 "Models.dev zai row missing: {zai_rows:?}"
3216 );
3217 let telecomjs_rows_merged = merged.offerings_for_provider("telecomjs");
3218 assert_eq!(
3219 telecomjs_rows_merged.len(),
3220 2,
3221 "TelecomJS rows missing: {telecomjs_rows_merged:?}"
3222 );
3223 assert!(
3224 telecomjs_rows_merged
3225 .iter()
3226 .any(|r| r.wire_model_id == "deepseek-chat"),
3227 "TelecomJS deepseek-chat row missing"
3228 );
3229 assert!(
3230 telecomjs_rows_merged
3231 .iter()
3232 .any(|r| r.wire_model_id == "glm-4"),
3233 "TelecomJS glm-4 row missing"
3234 );
3235
3236 clear_live_snapshot();
3237 }
3238
3239 /// TelecomJS→Models.dev completion order: TelecomJS merges first, then
3240 /// Models.dev replaces the cross-provider snapshot. TelecomJS rows must
3241 /// survive the Models.dev refresh (they live in a separate partition).
3242 #[test]
3243 fn telecomjs_first_then_models_dev_both_preserved() {
3244 let _live = lock_live_snapshot();
3245 clear_live_snapshot();
3246
3247 // 1) TelecomJS merges its per-provider rows first.
3248 let telecomjs_rows = vec![
3249 CatalogOffering {
3250 provider: "telecomjs".to_string(),
3251 wire_model_id: "deepseek-chat".to_string(),
3252 endpoint_key: "chat".to_string(),
3253 family: Some("deepseek".to_string()),
3254 source: CatalogSource::Live {
3255 base_url_fingerprint: "telecomjs-fp".to_string(),
3256 fetched_at: 2000,
3257 },
3258 ..Default::default()
3259 },
3260 CatalogOffering {
3261 provider: "telecomjs".to_string(),
3262 wire_model_id: "glm-4".to_string(),
3263 endpoint_key: "chat".to_string(),
3264 family: Some("glm".to_string()),
3265 source: CatalogSource::Live {
3266 base_url_fingerprint: "telecomjs-fp".to_string(),
3267 fetched_at: 2000,
3268 },
3269 ..Default::default()
3270 },
3271 ];
3272 merge_live_offerings(telecomjs_rows);
3273 assert_eq!(
3274 merged_snapshot().offerings_for_provider("telecomjs").len(),
3275 2,
3276 "provider rows should be visible before Models.dev completes"
3277 );
3278
3279 // 2) Models.dev refreshes and replaces its cross-provider snapshot.
3280 // Before the source-scoped fix, this would have wiped TelecomJS rows.
3281 let models_dev_rows = vec![CatalogOffering {
3282 provider: "deepseek".to_string(),
3283 wire_model_id: "deepseek-chat".to_string(),
3284 endpoint_key: "chat".to_string(),
3285 family: Some("deepseek".to_string()),
3286 source: CatalogSource::Live {
3287 base_url_fingerprint: "modelsdev-fp".to_string(),
3288 fetched_at: 3000,
3289 },
3290 ..Default::default()
3291 }];
3292 set_live_snapshot(
3293 CatalogSnapshot {
3294 offerings: models_dev_rows,
3295 },
3296 LiveSource::ModelsDev,
3297 );
3298
3299 // 3) Both sources' rows are present — TelecomJS rows were NOT erased.
3300 let merged = merged_snapshot();
3301 let telecomjs_rows_merged = merged.offerings_for_provider("telecomjs");
3302 assert_eq!(
3303 telecomjs_rows_merged.len(),
3304 2,
3305 "TelecomJS rows were erased by Models.dev refresh: {telecomjs_rows_merged:?}"
3306 );
3307 assert!(
3308 telecomjs_rows_merged
3309 .iter()
3310 .any(|r| r.wire_model_id == "deepseek-chat"),
3311 "TelecomJS deepseek-chat row erased"
3312 );
3313 assert!(
3314 telecomjs_rows_merged
3315 .iter()
3316 .any(|r| r.wire_model_id == "glm-4"),
3317 "TelecomJS glm-4 row erased"
3318 );
3319 let deepseek_rows = merged.offerings_for_provider("deepseek");
3320 assert!(
3321 deepseek_rows
3322 .iter()
3323 .any(|r| r.wire_model_id == "deepseek-chat"),
3324 "Models.dev deepseek row missing: {deepseek_rows:?}"
3325 );
3326
3327 clear_live_snapshot();
3328 }
3329
3330 /// Catalog refresh never deletes previously published rows: a Models.dev
3331 /// refresh that adds new rows must preserve existing per-provider rows,
3332 /// and a per-provider merge must preserve existing Models.dev rows.
3333 #[test]
3334 fn catalog_refresh_never_deletes_previously_published_rows() {
3335 let _live = lock_live_snapshot();
3336 clear_live_snapshot();
3337
3338 // 1) Initial state: Models.dev publishes rows for deepseek + zai.
3339 let initial_models_dev = vec![
3340 CatalogOffering {
3341 provider: "deepseek".to_string(),
3342 wire_model_id: "deepseek-chat".to_string(),
3343 endpoint_key: "chat".to_string(),
3344 source: CatalogSource::Live {
3345 base_url_fingerprint: "modelsdev-fp".to_string(),
3346 fetched_at: 1000,
3347 },
3348 ..Default::default()
3349 },
3350 CatalogOffering {
3351 provider: "zai".to_string(),
3352 wire_model_id: "glm-4".to_string(),
3353 endpoint_key: "chat".to_string(),
3354 source: CatalogSource::Live {
3355 base_url_fingerprint: "modelsdev-fp".to_string(),
3356 fetched_at: 1000,
3357 },
3358 ..Default::default()
3359 },
3360 ];
3361 set_live_snapshot(
3362 CatalogSnapshot {
3363 offerings: initial_models_dev,
3364 },
3365 LiveSource::ModelsDev,
3366 );
3367
3368 // 2) TelecomJS merges its rows.
3369 let telecomjs_rows = vec![CatalogOffering {
3370 provider: "telecomjs".to_string(),
3371 wire_model_id: "deepseek-chat".to_string(),
3372 endpoint_key: "chat".to_string(),
3373 source: CatalogSource::Live {
3374 base_url_fingerprint: "telecomjs-fp".to_string(),
3375 fetched_at: 2000,
3376 },
3377 ..Default::default()
3378 }];
3379 merge_live_offerings(telecomjs_rows);
3380
3381 // Record what we have before the second refresh.
3382 let before_refresh = merged_snapshot();
3383 let before_providers: std::collections::BTreeSet<_> = before_refresh
3384 .offerings
3385 .iter()
3386 .map(|r| (r.provider.clone(), r.wire_model_id.clone()))
3387 .collect();
3388 assert!(
3389 before_providers.contains(&("deepseek".to_string(), "deepseek-chat".to_string())),
3390 "deepseek row should exist before refresh"
3391 );
3392 assert!(
3393 before_providers.contains(&("telecomjs".to_string(), "deepseek-chat".to_string())),
3394 "telecomjs row should exist before refresh"
3395 );
3396
3397 // 3) Models.dev refreshes again with an updated snapshot (adds a new row).
3398 let updated_models_dev = vec![
3399 CatalogOffering {
3400 provider: "deepseek".to_string(),
3401 wire_model_id: "deepseek-chat".to_string(),
3402 endpoint_key: "chat".to_string(),
3403 source: CatalogSource::Live {
3404 base_url_fingerprint: "modelsdev-fp".to_string(),
3405 fetched_at: 3000,
3406 },
3407 ..Default::default()
3408 },
3409 CatalogOffering {
3410 provider: "zai".to_string(),
3411 wire_model_id: "glm-4".to_string(),
3412 endpoint_key: "chat".to_string(),
3413 source: CatalogSource::Live {
3414 base_url_fingerprint: "modelsdev-fp".to_string(),
3415 fetched_at: 3000,
3416 },
3417 ..Default::default()
3418 },
3419 // New row added by the refresh.
3420 CatalogOffering {
3421 provider: "moonshot".to_string(),
3422 wire_model_id: "kimi-k2.5".to_string(),
3423 endpoint_key: "chat".to_string(),
3424 source: CatalogSource::Live {
3425 base_url_fingerprint: "modelsdev-fp".to_string(),
3426 fetched_at: 3000,
3427 },
3428 ..Default::default()
3429 },
3430 ];
3431 set_live_snapshot(
3432 CatalogSnapshot {
3433 offerings: updated_models_dev,
3434 },
3435 LiveSource::ModelsDev,
3436 );
3437
3438 // 4) The TelecomJS row is STILL present — it was not deleted.
3439 let after_refresh = merged_snapshot();
3440 let after_telecomjs: Vec<_> = after_refresh
3441 .offerings_for_provider("telecomjs")
3442 .iter()
3443 .map(|r| r.wire_model_id.clone())
3444 .collect();
3445 assert!(
3446 after_telecomjs.iter().any(|id| id == "deepseek-chat"),
3447 "TelecomJS row was deleted by Models.dev refresh! Remaining: {after_telecomjs:?}"
3448 );
3449
3450 // 5) New Models.dev row is also present.
3451 let after_moonshot: Vec<_> = after_refresh
3452 .offerings_for_provider("moonshot")
3453 .iter()
3454 .map(|r| r.wire_model_id.clone())
3455 .collect();
3456 assert!(
3457 after_moonshot.iter().any(|id| id == "kimi-k2.5"),
3458 "New Models.dev moonshot row missing: {after_moonshot:?}"
3459 );
3460
3461 // 6) Also verify: a per-provider merge does not delete Models.dev rows.
3462 let extra_telecomjs = vec![CatalogOffering {
3463 provider: "telecomjs".to_string(),
3464 wire_model_id: "glm-4".to_string(),
3465 endpoint_key: "chat".to_string(),
3466 source: CatalogSource::Live {
3467 base_url_fingerprint: "telecomjs-fp".to_string(),
3468 fetched_at: 4000,
3469 },
3470 ..Default::default()
3471 }];
3472 merge_live_offerings(extra_telecomjs);
3473
3474 let final_merged = merged_snapshot();
3475 let final_deepseek: Vec<_> = final_merged
3476 .offerings_for_provider("deepseek")
3477 .iter()
3478 .map(|r| r.wire_model_id.clone())
3479 .collect();
3480 assert!(
3481 final_deepseek.iter().any(|id| id == "deepseek-chat"),
3482 "Models.dev deepseek row was deleted by per-provider merge! Remaining: {final_deepseek:?}"
3483 );
3484 let final_moonshot: Vec<_> = final_merged
3485 .offerings_for_provider("moonshot")
3486 .iter()
3487 .map(|r| r.wire_model_id.clone())
3488 .collect();
3489 assert!(
3490 final_moonshot.iter().any(|id| id == "kimi-k2.5"),
3491 "Models.dev moonshot row was deleted by per-provider merge! Remaining: {final_moonshot:?}"
3492 );
3493
3494 clear_live_snapshot();
3495 }
3496
3497 #[test]
3498 fn cloud_generation_updates_exact_catalog_defaults_and_disable_restores_baseline() {
3499 use codewhale_config::cloud_facts::{
3500 CloudFactsStatus, ModelFact, ProviderDefaultFact, ScopedFacts, overlay,
3501 };
3502 let _live = lock_live_snapshot();
3503 let home = tempfile::tempdir().unwrap();
3504 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
3505 let _enabled = crate::test_support::EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS");
3506 struct Reset;
3507 impl Drop for Reset {
3508 fn drop(&mut self) {
3509 overlay::clear();
3510 clear_live_snapshot();
3511 crate::provider_catalog_live::reset_cache_for_test();
3512 }
3513 }
3514 let _reset = Reset;
3515 overlay::clear();
3516 clear_live_snapshot();
3517 crate::provider_catalog_live::reset_cache_for_test();
3518 let provider = ProviderKind::Openai;
3519 let base = provider.provider().default_base_url();
3520 let model = "cloud-catalog-fixture";
3521 let config = Config {
3522 provider: Some("openai".into()),
3523 ..Default::default()
3524 };
3525 let baseline = crate::route_runtime::resolve_runtime_route(&config, provider, None)
3526 .unwrap()
3527 .model;
3528 assert!(
3529 !catalog_models_for_route(provider, "openai", base)
3530 .iter()
3531 .any(|id| id == model)
3532 );
3533 let ticket = overlay::configure(true, "catalog-generation-test").unwrap();
3534 let mut facts = ScopedFacts {
3535 channel: "catalog-generation-test".into(),
3536 facts_version: 1,
3537 key_id: "cwf-test-only".into(),
3538 models: vec![ModelFact {
3539 provider: "openai".into(),
3540 id: model.into(),
3541 context_window: Some(31_337),
3542 ..Default::default()
3543 }],
3544 provider_defaults: BTreeMap::from([(
3545 "openai".into(),
3546 ProviderDefaultFact {
3547 default_model: Some(model.into()),
3548 ..Default::default()
3549 },
3550 )]),
3551 ..Default::default()
3552 };
3553 assert!(overlay::publish(
3554 &ticket,
3555 Some(facts.clone()),
3556 CloudFactsStatus::default()
3557 ));
3558 assert!(
3559 catalog_models_for_route(provider, "openai", base)
3560 .iter()
3561 .any(|id| id == model)
3562 );
3563 assert!(
3564 catalog_models_for_route(provider, "openai", "https://catalog-proxy.invalid/v1")
3565 .is_empty()
3566 );
3567 let route = crate::route_runtime::resolve_runtime_route(&config, provider, None).unwrap();
3568 assert_eq!(route.model, model);
3569 assert_eq!(route.candidate.limits().context_tokens, Some(31_337));
3570 assert_eq!(
3571 crate::route_runtime::resolve_runtime_route(&config, provider, Some(&baseline))
3572 .unwrap()
3573 .model,
3574 baseline
3575 );
3576 facts.facts_version = 2;
3577 facts.models[0].context_window = Some(62_674);
3578 assert!(overlay::publish(
3579 &ticket,
3580 Some(facts),
3581 CloudFactsStatus::default()
3582 ));
3583 assert_eq!(
3584 crate::route_runtime::resolve_runtime_route(&config, provider, None)
3585 .unwrap()
3586 .candidate
3587 .limits()
3588 .context_tokens,
3589 Some(62_674)
3590 );
3591 overlay::clear();
3592 assert_eq!(
3593 crate::route_runtime::resolve_runtime_route(&config, provider, None)
3594 .unwrap()
3595 .model,
3596 baseline
3597 );
3598 assert!(
3599 !catalog_models_for_route(provider, "openai", base)
3600 .iter()
3601 .any(|id| id == model)
3602 );
3603 }
3604
3605 /// Test scaffolding shared by the signed-catalog cases: an isolated home,
3606 /// cloud facts enabled, and every process-wide layer reset on the way out.
3607 ///
3608 /// Field order is the drop order and is load-bearing: the env guards must
3609 /// restore their variables while this thread still holds the test env
3610 /// barrier that [`lock_live_snapshot`] took, so `_live` is declared last.
3611 struct CloudFactsTestEnv {
3612 _enabled: crate::test_support::EnvVarGuard,
3613 _home: crate::test_support::EnvVarGuard,
3614 _home_dir: tempfile::TempDir,
3615 _live: LiveSnapshotLock,
3616 }
3617
3618 impl Drop for CloudFactsTestEnv {
3619 fn drop(&mut self) {
3620 codewhale_config::cloud_facts::overlay::clear();
3621 clear_live_snapshot();
3622 crate::provider_catalog_live::reset_cache_for_test();
3623 }
3624 }
3625
3626 fn cloud_facts_test_env() -> CloudFactsTestEnv {
3627 let live = lock_live_snapshot();
3628 let home_dir = tempfile::tempdir().unwrap();
3629 let home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home_dir.path());
3630 let enabled = crate::test_support::EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS");
3631 codewhale_config::cloud_facts::overlay::clear();
3632 clear_live_snapshot();
3633 crate::provider_catalog_live::reset_cache_for_test();
3634 CloudFactsTestEnv {
3635 _enabled: enabled,
3636 _home: home,
3637 _home_dir: home_dir,
3638 _live: live,
3639 }
3640 }
3641
3642 fn publish_test_facts(
3643 channel: &str,
3644 version: u64,
3645 valid_until: Option<u64>,
3646 models: Vec<codewhale_config::cloud_facts::ModelFact>,
3647 ) {
3648 use codewhale_config::cloud_facts::{CloudFactsStatus, ScopedFacts, overlay};
3649 let ticket = overlay::configure(true, channel).unwrap();
3650 assert!(overlay::publish(
3651 &ticket,
3652 Some(ScopedFacts {
3653 channel: channel.into(),
3654 facts_version: version,
3655 key_id: "cwf-test-only".into(),
3656 valid_until,
3657 models,
3658 ..Default::default()
3659 }),
3660 CloudFactsStatus::default()
3661 ));
3662 }
3663
3664 fn upsert_fact(
3665 provider: &str,
3666 id: &str,
3667 context_window: u64,
3668 ) -> codewhale_config::cloud_facts::ModelFact {
3669 codewhale_config::cloud_facts::ModelFact {
3670 provider: provider.into(),
3671 id: id.into(),
3672 context_window: Some(context_window),
3673 ..Default::default()
3674 }
3675 }
3676
3677 /// An id-only unlisted assertion: the signer says this exact id exists on
3678 /// the provider's official endpoint and states nothing else about it.
3679 fn attested_fact(provider: &str, id: &str) -> codewhale_config::cloud_facts::ModelFact {
3680 codewhale_config::cloud_facts::ModelFact {
3681 provider: provider.into(),
3682 id: id.into(),
3683 allow_unlisted: true,
3684 ..Default::default()
3685 }
3686 }
3687
3688 /// `scoped_view` only keeps an assertion in a payload that expires; mirror
3689 /// that here so these tests publish what the client can actually receive.
3690 fn bounded() -> Option<u64> {
3691 Some(codewhale_config::catalog::now_unix() + 3_600)
3692 }
3693
3694 fn record_roster(base: &str, offerings: Vec<CatalogOffering>) {
3695 use codewhale_config::catalog::ProviderCatalogDelta;
3696 crate::provider_catalog_live::record_success(ProviderCatalogDelta {
3697 provider: "deepseek".to_string(),
3698 base_url_fingerprint: base_url_fingerprint(base),
3699 fetched_at: codewhale_config::catalog::now_unix(),
3700 offerings,
3701 });
3702 }
3703
3704 fn roster_row(base: &str, id: &str) -> CatalogOffering {
3705 CatalogOffering {
3706 provider: "deepseek".to_string(),
3707 wire_model_id: id.to_string(),
3708 endpoint_key: "chat".to_string(),
3709 source: CatalogSource::Live {
3710 base_url_fingerprint: base_url_fingerprint(base),
3711 fetched_at: codewhale_config::catalog::now_unix(),
3712 },
3713 ..Default::default()
3714 }
3715 }
3716
3717 /// A provider roster owns the ids it lists **and its own omissions**. This
3718 /// client keeps no roster history, so nothing it holds locally — bundled or
3719 /// otherwise — is evidence about what the provider once served: only an
3720 /// explicit signed assertion may name an id the roster omits, and it does so
3721 /// for a bundled id and an unknown id alike.
3722 #[test]
3723 fn roster_omission_stands_unless_the_payload_explicitly_attests_the_id() {
3724 let _env = cloud_facts_test_env();
3725
3726 let provider = ProviderKind::Deepseek;
3727 let base = provider.provider().default_base_url();
3728 // One id the bundled catalog knows, one it has never heard of. Neither
3729 // fact changes what the roster is authoritative about.
3730 let bundled = "deepseek-v4-flash";
3731 let unknown = "deepseek-v4-nano-preview";
3732 let listed = "deepseek-v4-pro";
3733 assert!(bundled_catalog_offering_for_model(provider, bundled).is_some());
3734 assert!(bundled_catalog_offering_for_model(provider, unknown).is_none());
3735 record_roster(base, vec![roster_row(base, listed)]);
3736
3737 // No assertion: the roster's omission stands for both ids.
3738 publish_test_facts(
3739 "roster-dominance-test",
3740 1,
3741 bounded(),
3742 vec![
3743 upsert_fact("deepseek", bundled, 999_999),
3744 upsert_fact("deepseek", unknown, 131_072),
3745 ],
3746 );
3747 let models = catalog_models_for_route(provider, "deepseek", base);
3748 assert!(models.iter().any(|id| id == listed), "{models:?}");
3749 for id in [bundled, unknown] {
3750 assert!(
3751 !models.iter().any(|row| row == id),
3752 "an unattested patch must not survive the roster's omission: {models:?}"
3753 );
3754 assert!(
3755 catalog_offering_for_route(provider, "deepseek", base, id).is_none(),
3756 "{id} must not answer with signed facts either"
3757 );
3758 assert!(
3759 !all_catalog_models_for_provider(provider)
3760 .iter()
3761 .any(|row| row == id),
3762 "the merged view must not read it back out either"
3763 );
3764 }
3765
3766 // Same ids, now explicitly attested. The bundled one carries no stated
3767 // limits, so it must not inherit the bundled row's.
3768 publish_test_facts(
3769 "roster-dominance-test",
3770 2,
3771 bounded(),
3772 vec![
3773 attested_fact("deepseek", bundled),
3774 codewhale_config::cloud_facts::ModelFact {
3775 allow_unlisted: true,
3776 ..upsert_fact("deepseek", unknown, 131_072)
3777 },
3778 ],
3779 );
3780 let models = catalog_models_for_route(provider, "deepseek", base);
3781 let merged = all_catalog_models_for_provider(provider);
3782 for id in [listed, bundled, unknown] {
3783 assert!(models.iter().any(|row| row == id), "{models:?}");
3784 assert!(merged.iter().any(|row| row == id), "{merged:?}");
3785 }
3786 let attested = catalog_offering_for_route(provider, "deepseek", base, bundled)
3787 .expect("an attested id resolves its own facts");
3788 assert_eq!(
3789 attested.limit, None,
3790 "an id-only assertion must not borrow limits from the bundled layer"
3791 );
3792 assert_eq!(attested.cost, None);
3793 assert_eq!(attested.tool_call, None);
3794 assert_eq!(attested.modalities, None);
3795 assert_eq!(attested.attachment, None);
3796 let offering = catalog_offering_for_route(provider, "deepseek", base, unknown)
3797 .expect("an attested id resolves its own facts");
3798 assert_eq!(offering.wire_model_id, unknown, "the exact id, verbatim");
3799 assert_eq!(
3800 offering.limit.and_then(|limit| limit.context),
3801 Some(131_072)
3802 );
3803
3804 // The executor reads the same list the picker does.
3805 let config = Config {
3806 provider: Some("deepseek".into()),
3807 ..Default::default()
3808 };
3809 assert_eq!(
3810 crate::route_runtime::resolve_runtime_route(&config, provider, Some(unknown))
3811 .unwrap()
3812 .candidate
3813 .limits()
3814 .context_tokens,
3815 Some(131_072)
3816 );
3817
3818 // Signed rows never reach a proxied endpoint, attested or not.
3819 assert!(
3820 !catalog_models_for_route(provider, "deepseek", "https://deepseek-proxy.invalid/v1")
3821 .iter()
3822 .any(|id| id == unknown)
3823 );
3824 }
3825
3826 /// Every retraction path is the signer's, and none needs a provider
3827 /// request: `hide` removes a bundled row, an elapsed validity bound drops
3828 /// the whole overlay on read (including the hide it carried), and dropping
3829 /// an upsert withdraws the row it created.
3830 #[test]
3831 fn signed_hide_expiry_and_dropped_upsert_retract_rows_without_a_provider_request() {
3832 use codewhale_config::catalog::now_unix;
3833 use codewhale_config::cloud_facts::{ModelFact, ModelOp};
3834 let _env = cloud_facts_test_env();
3835
3836 let provider = ProviderKind::Deepseek;
3837 let base = provider.provider().default_base_url();
3838 let hidden = "deepseek-v4-flash";
3839 let preview = "deepseek-v4-nano-preview";
3840 let before = catalog_models_for_route(provider, "deepseek", base);
3841 assert!(before.iter().any(|id| id == hidden), "{before:?}");
3842
3843 publish_test_facts(
3844 "retraction-test",
3845 1,
3846 None,
3847 vec![
3848 ModelFact {
3849 provider: "deepseek".into(),
3850 id: hidden.into(),
3851 op: ModelOp::Hide,
3852 ..Default::default()
3853 },
3854 upsert_fact("deepseek", preview, 131_072),
3855 ],
3856 );
3857 let hidden_view = catalog_models_for_route(provider, "deepseek", base);
3858 assert!(
3859 !hidden_view.iter().any(|id| id == hidden),
3860 "hide must remove the bundled row: {hidden_view:?}"
3861 );
3862 assert!(
3863 hidden_view.iter().any(|id| id == preview),
3864 "{hidden_view:?}"
3865 );
3866
3867 // Expiry is evaluated on read: no refresh, no provider request, and no
3868 // setting change is needed for the payload to stop being authority.
3869 publish_test_facts(
3870 "retraction-test",
3871 2,
3872 Some(now_unix().saturating_sub(1)),
3873 vec![
3874 ModelFact {
3875 provider: "deepseek".into(),
3876 id: hidden.into(),
3877 op: ModelOp::Hide,
3878 ..Default::default()
3879 },
3880 upsert_fact("deepseek", preview, 131_072),
3881 ],
3882 );
3883 let expired = catalog_models_for_route(provider, "deepseek", base);
3884 assert!(
3885 expired.iter().any(|id| id == hidden),
3886 "an expired payload cannot keep hiding a bundled row: {expired:?}"
3887 );
3888 assert!(
3889 !expired.iter().any(|id| id == preview),
3890 "an expired payload cannot keep offering its own row: {expired:?}"
3891 );
3892
3893 // The third retraction: publish the same channel without the upsert.
3894 // An attested row is offered past a roster, so this is the path that
3895 // withdraws one without waiting for `not_after`.
3896 record_roster(base, vec![roster_row(base, "deepseek-v4-pro")]);
3897 publish_test_facts(
3898 "retraction-test",
3899 3,
3900 bounded(),
3901 vec![attested_fact("deepseek", preview)],
3902 );
3903 assert!(
3904 catalog_models_for_route(provider, "deepseek", base)
3905 .iter()
3906 .any(|id| id == preview)
3907 );
3908 publish_test_facts("retraction-test", 4, bounded(), Vec::new());
3909 let withdrawn = catalog_models_for_route(provider, "deepseek", base);
3910 assert!(
3911 !withdrawn.iter().any(|id| id == preview),
3912 "dropping the upsert must withdraw the row: {withdrawn:?}"
3913 );
3914 assert!(
3915 withdrawn.iter().any(|id| id == "deepseek-v4-pro"),
3916 "the roster is untouched by the withdrawal: {withdrawn:?}"
3917 );
3918 }
3919
3920 /// A signed row names one canonical identity on one official endpoint.
3921 /// Catalog partitions deliberately collapse regional and dual-wire aliases
3922 /// onto a vendor primary, and that collapse must not become a channel for
3923 /// facts to reach an endpoint the signer did not name.
3924 #[test]
3925 fn signed_rows_do_not_cross_regional_wire_or_proxied_routes() {
3926 let _env = cloud_facts_test_env();
3927
3928 let preview = "deepseek-v4-nano-preview";
3929 let siliconflow_preview = "sf-preview-not-in-any-catalog";
3930 publish_test_facts(
3931 "route-scope-test",
3932 1,
3933 bounded(),
3934 vec![
3935 // Attested: the assertion must not widen the endpoint or
3936 // identity boundary either.
3937 codewhale_config::cloud_facts::ModelFact {
3938 allow_unlisted: true,
3939 pricing: Some(codewhale_config::cloud_facts::PricingFact {
3940 input_per_m: Some(0.25),
3941 output_per_m: Some(1.0),
3942 ..Default::default()
3943 }),
3944 ..upsert_fact("deepseek", preview, 131_072)
3945 },
3946 upsert_fact("siliconflow", siliconflow_preview, 65_536),
3947 ],
3948 );
3949 let offers = |provider: ProviderKind, identity: &str, model: &str| {
3950 catalog_models_for_route(provider, identity, provider.provider().default_base_url())
3951 .iter()
3952 .any(|id| id == model)
3953 };
3954
3955 assert!(
3956 offers(ProviderKind::Deepseek, "deepseek", preview),
3957 "the exact signed route must offer the row"
3958 );
3959 // Same host, but a TUI-only legacy alias with no canonical identity.
3960 assert!(
3961 !offers(ProviderKind::Deepseek, "deepseek-cn", preview),
3962 "the legacy CN alias inherits nothing from the primary identity"
3963 );
3964 // Reads the `deepseek` partition, but is a separate endpoint contract.
3965 assert!(
3966 !offers(
3967 ProviderKind::DeepseekAnthropic,
3968 "deepseek-anthropic",
3969 preview
3970 ),
3971 "the Anthropic-wire endpoint is not the identity the signer named"
3972 );
3973 // A regional sibling that shares a catalog partition, not an identity.
3974 assert!(
3975 offers(
3976 ProviderKind::Siliconflow,
3977 "siliconflow",
3978 siliconflow_preview
3979 ),
3980 "the exact signed SiliconFlow route must offer the row"
3981 );
3982 assert!(
3983 !offers(
3984 ProviderKind::SiliconflowCN,
3985 "siliconflow-CN",
3986 siliconflow_preview
3987 ),
3988 "the China endpoint is a different identity, even where the host allowlist overlaps"
3989 );
3990 // A proxy or redirect on the right identity is still the wrong endpoint.
3991 assert!(
3992 !catalog_models_for_route(
3993 ProviderKind::Deepseek,
3994 "deepseek",
3995 "https://deepseek-proxy.invalid/v1"
3996 )
3997 .iter()
3998 .any(|id| id == preview),
3999 "a custom base URL never inherits signed rows"
4000 );
4001
4002 // The price travels with the row and no further. A signed rate that
4003 // renders somewhere it cannot be billed is the failure this layer must
4004 // not have, so the offered row and the dispatch quote answer together.
4005 let quote = |provider: ProviderKind, identity: &str, base: &str| {
4006 crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
4007 provider,
4008 identity,
4009 preview,
4010 base,
4011 codewhale_config::catalog::now_unix(),
4012 )
4013 };
4014 assert!(
4015 quote(
4016 ProviderKind::Deepseek,
4017 "deepseek",
4018 ProviderKind::Deepseek.provider().default_base_url()
4019 )
4020 .is_some(),
4021 "a signed price on the exact signed route is billable — this is what \
4022 makes a rate change data rather than a release"
4023 );
4024 for (provider, identity) in [
4025 (ProviderKind::Deepseek, "deepseek-cn"),
4026 (ProviderKind::DeepseekAnthropic, "deepseek-anthropic"),
4027 ] {
4028 assert!(
4029 quote(provider, identity, provider.provider().default_base_url()).is_none(),
4030 "{identity} must not mint a quote from another endpoint's facts"
4031 );
4032 }
4033 assert!(
4034 quote(
4035 ProviderKind::Deepseek,
4036 "deepseek",
4037 "https://deepseek-proxy.invalid/v1"
4038 )
4039 .is_none(),
4040 "and neither may a proxied base URL"
4041 );
4042 assert!(
4043 quote(
4044 ProviderKind::Deepseek,
4045 "deepseek-custom-table",
4046 ProviderKind::Deepseek.provider().default_base_url()
4047 )
4048 .is_none(),
4049 "a differently-named provider table is a separate billing relationship"
4050 );
4051 }
4052
4053 /// A roster that answers with ids alone has not said its models have no
4054 /// limits. Signed facts complete that silence for the picker, the metadata
4055 /// lookup and the executor alike — and lose every field the provider did
4056 /// state. Price stays the provider's business: nothing renders a cloud rate
4057 /// on a provider row that the dispatch quote would refuse to bill.
4058 #[test]
4059 fn provider_id_only_rows_take_signed_limits_while_provider_facts_and_prices_win() {
4060 use codewhale_config::cloud_facts::{ModelFact, PricingFact};
4061 use codewhale_config::models_dev::ModelsDevLimit;
4062 let _env = cloud_facts_test_env();
4063
4064 let provider = ProviderKind::Deepseek;
4065 let base = provider.provider().default_base_url();
4066 let id_only = "deepseek-roster-bare";
4067 let detailed = "deepseek-roster-detailed";
4068 record_roster(
4069 base,
4070 vec![
4071 roster_row(base, id_only),
4072 CatalogOffering {
4073 limit: Some(ModelsDevLimit {
4074 context: Some(12_345),
4075 ..Default::default()
4076 }),
4077 reasoning: Some(false),
4078 ..roster_row(base, detailed)
4079 },
4080 ],
4081 );
4082 let signed = |id: &str| ModelFact {
4083 max_output: Some(8_192),
4084 reasoning: Some(true),
4085 pricing: Some(PricingFact {
4086 input_per_m: Some(1.0),
4087 output_per_m: Some(2.0),
4088 ..Default::default()
4089 }),
4090 ..upsert_fact("deepseek", id, 131_072)
4091 };
4092 publish_test_facts(
4093 "roster-completion-test",
4094 1,
4095 bounded(),
4096 vec![signed(id_only), signed(detailed)],
4097 );
4098
4099 let bare = catalog_offering_for_route(provider, "deepseek", base, id_only)
4100 .expect("the roster row is still there");
4101 let limit = bare.limit.clone().expect("signed limits complete it");
4102 assert_eq!(limit.context, Some(131_072));
4103 assert_eq!(limit.output, Some(8_192));
4104 assert_eq!(bare.reasoning, Some(true));
4105 assert!(
4106 matches!(bare.source, CatalogSource::Live { .. }),
4107 "the row is still the provider's: {:?}",
4108 bare.source
4109 );
4110 assert_eq!(
4111 bare.cost, None,
4112 "a signed price must not appear on a provider-live row"
4113 );
4114 assert!(
4115 !matches!(bare.pricing_source(), CatalogSource::CloudFacts { .. }),
4116 "price provenance must not claim a cloud rate here"
4117 );
4118 assert!(
4119 crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
4120 provider,
4121 "deepseek",
4122 id_only,
4123 base,
4124 codewhale_config::catalog::now_unix(),
4125 )
4126 .is_none(),
4127 "and nothing bills against one either"
4128 );
4129
4130 let stated = catalog_offering_for_route(provider, "deepseek", base, detailed)
4131 .expect("the roster row is still there");
4132 let limit = stated.limit.clone().expect("provider limits are kept");
4133 assert_eq!(
4134 limit.context,
4135 Some(12_345),
4136 "the provider's own context must win"
4137 );
4138 assert_eq!(limit.output, Some(8_192), "only the silence is filled");
4139 assert_eq!(stated.reasoning, Some(false), "and its own capability wins");
4140
4141 // The same completion reaches the merged picker view and the executor.
4142 assert_eq!(
4143 catalog_offering_for_model(provider, id_only)
4144 .and_then(|row| row.limit)
4145 .and_then(|limit| limit.context),
4146 Some(131_072)
4147 );
4148 let config = Config {
4149 provider: Some("deepseek".into()),
4150 ..Default::default()
4151 };
4152 let limits = |model: &str| {
4153 crate::route_runtime::resolve_runtime_route(&config, provider, Some(model))
4154 .unwrap()
4155 .candidate
4156 .limits()
4157 .context_tokens
4158 };
4159 assert_eq!(limits(id_only), Some(131_072));
4160 assert_eq!(limits(detailed), Some(12_345));
4161 }
4162
4163 /// The population this layer exists to serve. Most models a user sees are
4164 /// described by a Models.dev refresh rather than by their provider, and a
4165 /// stale window or a changed rate on one of those is exactly what a signed
4166 /// correction must fix — arriving as data, not as a reinstall. The rows are
4167 /// published through the real producer, so this is also the runtime proof
4168 /// that external enrichment sits below the signed layer.
4169 #[test]
4170 fn signed_facts_correct_models_dev_enrichment_on_the_exact_route() {
4171 use codewhale_config::cloud_facts::{ModelFact, PricingFact};
4172 let _env = cloud_facts_test_env();
4173
4174 let provider = ProviderKind::Deepseek;
4175 let base = provider.provider().default_base_url();
4176 let model = "deepseek-enriched-only";
4177 let body = format!(
4178 r#"{{
4179 "models": {{}},
4180 "providers": {{
4181 "deepseek": {{
4182 "id": "deepseek",
4183 "models": {{
4184 "{model}": {{
4185 "id": "{model}",
4186 "modalities": {{ "input": ["text"], "output": ["text"] }},
4187 "limit": {{ "context": 65536, "output": 4096 }},
4188 "cost": {{ "input": 2.0, "output": 8.0 }}
4189 }}
4190 }}
4191 }}
4192 }}
4193 }}"#
4194 );
4195 let catalog =
4196 codewhale_config::models_dev::ModelsDevCatalog::parse_json(&body).expect("parse");
4197 set_live_snapshot(
4198 CatalogSnapshot {
4199 offerings: codewhale_config::catalog::live_offerings_from_models_dev(
4200 &catalog,
4201 codewhale_config::catalog::now_unix(),
4202 ),
4203 },
4204 LiveSource::ModelsDev,
4205 );
4206
4207 // A refreshed row describes a model, not an endpoint, so the route
4208 // resolves it without an endpoint fingerprint to match against.
4209 let enriched = catalog_offering_for_route(provider, "deepseek", base, model)
4210 .expect("the enriched row answers on the provider's own endpoint");
4211 assert_eq!(
4212 enriched.limit.as_ref().and_then(|limit| limit.context),
4213 Some(65_536)
4214 );
4215
4216 publish_test_facts(
4217 "models-dev-correction-test",
4218 1,
4219 bounded(),
4220 vec![ModelFact {
4221 pricing: Some(PricingFact {
4222 input_per_m: Some(0.5),
4223 output_per_m: Some(1.5),
4224 ..Default::default()
4225 }),
4226 ..upsert_fact("deepseek", model, 131_072)
4227 }],
4228 );
4229
4230 let corrected = catalog_offering_for_route(provider, "deepseek", base, model)
4231 .expect("the corrected row is still offered");
4232 let limit = corrected.limit.clone().expect("limits are kept");
4233 assert_eq!(
4234 limit.context,
4235 Some(131_072),
4236 "the stale window is corrected"
4237 );
4238 assert_eq!(
4239 limit.output,
4240 Some(4_096),
4241 "and only what the payload states is replaced"
4242 );
4243 assert_eq!(
4244 corrected.cost.as_ref().and_then(|cost| cost.input),
4245 Some(0.5),
4246 "the signed rate replaces the enriched one"
4247 );
4248 assert!(matches!(
4249 corrected.pricing_source(),
4250 CatalogSource::CloudFacts { .. }
4251 ));
4252
4253 // The executor reads the same correction the picker does.
4254 let config = Config {
4255 provider: Some("deepseek".into()),
4256 ..Default::default()
4257 };
4258 assert_eq!(
4259 crate::route_runtime::resolve_runtime_route(&config, provider, Some(model))
4260 .unwrap()
4261 .candidate
4262 .limits()
4263 .context_tokens,
4264 Some(131_072)
4265 );
4266
4267 // A corrected rate is only worth rendering where it is billable, and
4268 // only on the endpoint and identity the signer named.
4269 let quote = |identity: &str, endpoint: &str| {
4270 crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
4271 provider,
4272 identity,
4273 model,
4274 endpoint,
4275 codewhale_config::catalog::now_unix(),
4276 )
4277 };
4278 assert!(
4279 quote("deepseek", base).is_some(),
4280 "the corrected rate bills on the exact signed route"
4281 );
4282 assert!(
4283 quote("deepseek", "https://deepseek-proxy.invalid/v1").is_none(),
4284 "a proxied base URL never inherits it"
4285 );
4286 assert!(
4287 quote("deepseek-custom-table", base).is_none(),
4288 "a differently-named provider table is a separate billing relationship"
4289 );
4290
4291 // A fresh roster is still the authority for the ids it lists: it
4292 // suppresses the enrichment and the correction that rode on it.
4293 record_roster(base, vec![roster_row(base, "deepseek-v4-pro")]);
4294 assert!(
4295 catalog_offering_for_route(provider, "deepseek", base, model).is_none(),
4296 "an unattested correction cannot survive the roster's omission"
4297 );
4298 }
4299
4300 /// #6705: through the production runtime resolver, a curated Zen
4301 /// transport row wins over a Models.dev row for the same id, a
4302 /// catalog-only row routes on the wire its row declares, and a deprecated
4303 /// catalog row stays unroutable.
4304 #[test]
4305 fn opencode_zen_runtime_resolver_keeps_curated_wire_over_models_dev_row() {
4306 use codewhale_config::models_dev::ModelsDevCatalog;
4307 use codewhale_config::route::{LogicalModelRef, RequestProtocol, RouteError, RouteRequest};
4308
4309 let _live = lock_live_snapshot();
4310 clear_live_snapshot();
4311 let raw = r#"{
4312 "providers": {
4313 "opencode": {
4314 "id": "opencode",
4315 "npm": "@ai-sdk/openai-compatible",
4316 "models": {
4317 "gpt-5.6-sol": { "id": "gpt-5.6-sol", "provider": { "npm": "@ai-sdk/anthropic" } },
4318 "claude-opus-9": { "id": "claude-opus-9", "provider": { "npm": "@ai-sdk/anthropic" } },
4319 "claude-2-retired": {
4320 "id": "claude-2-retired",
4321 "status": "deprecated",
4322 "provider": { "npm": "@ai-sdk/anthropic" }
4323 }
4324 }
4325 }
4326 }
4327 }"#;
4328 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
4329 set_live_snapshot(
4330 CatalogSnapshot {
4331 offerings: codewhale_config::catalog::live_offerings_from_models_dev(&catalog, 1),
4332 },
4333 LiveSource::ModelsDev,
4334 );
4335
4336 let provider = ProviderKind::OpencodeZen;
4337 let resolver = runtime_catalog_resolver_for_identity(
4338 provider,
4339 None,
4340 provider.provider().default_base_url(),
4341 CatalogStatus::Unknown,
4342 )
4343 .resolver;
4344 let resolve = |model: &str| {
4345 resolver.resolve(&RouteRequest {
4346 explicit_provider: Some(provider),
4347 model_selector: Some(LogicalModelRef::from(model)),
4348 saved_provider_model: None,
4349 base_url_override: None,
4350 limit_overrides: Vec::new(),
4351 })
4352 };
4353
4354 let curated = resolve("gpt-5.6-sol").expect("curated Zen row resolves");
4355 assert_eq!(
4356 curated.protocol(),
4357 RequestProtocol::Responses,
4358 "the curated transport row must win over the catalog's conflicting row"
4359 );
4360 let catalog_only = resolve("claude-opus-9").expect("catalog-proven Zen row resolves");
4361 assert_eq!(catalog_only.protocol(), RequestProtocol::AnthropicMessages);
4362 match resolve("claude-2-retired") {
4363 Err(RouteError::UnsupportedModelProtocol { endpoint_key, .. }) => {
4364 assert_eq!(endpoint_key, "deprecated");
4365 }
4366 other => panic!("a deprecated Zen row must fail closed, got {other:?}"),
4367 }
4368
4369 clear_live_snapshot();
4370 }
4371 }
4372
4372 lines RUST