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