返回 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::collections::BTreeMap;
15 use std::sync::atomic::{AtomicU64, Ordering};
16 use std::sync::{Arc, RwLock};
17
18 use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot, bundled_catalog_offerings};
19
20 use crate::codex_model_cache;
21 use crate::config::{
22 ApiProvider, Config, model_completion_names_for_provider, opencode_go_chat_model_id,
23 provider_is_configured_for_active,
24 };
25
26 static BUNDLED_SNAPSHOT: std::sync::OnceLock<CatalogSnapshot> = std::sync::OnceLock::new();
27
28 /// Source tag for live-catalog rows. Models.dev is a cross-provider catalog
29 /// that serves as the primary live layer; per-provider refreshes (e.g.
30 /// TelecomJS `/v1/models`) are a secondary layer that must coexist alongside
31 /// Models.dev rows without being wiped by a Models.dev refresh.
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33 pub enum LiveSource {
34 /// The cross-provider Models.dev catalog refresh.
35 ModelsDev,
36 /// A per-provider `/v1/models` catalog refresh (e.g. TelecomJS TokenHub).
37 PerProvider,
38 }
39
40 /// Optional live catalog snapshot(s), source-scoped (#4188 race fix).
41 ///
42 /// Models.dev and every provider fetch maintain distinct partitions of live
43 /// rows. A Models.dev refresh replaces only Models.dev-sourced rows; a
44 /// per-provider merge adds/replaces only that provider's rows. This prevents a
45 /// later Models.dev `set_live_snapshot` from erasing TelecomJS rows and keeps
46 /// independent provider refreshes from erasing each other.
47 static LIVE_SNAPSHOT: RwLock<LiveSnapshotPartitions> = RwLock::new(LiveSnapshotPartitions {
48 models_dev: None,
49 per_provider: BTreeMap::new(),
50 });
51
52 /// Internal partition map: one Models.dev snapshot plus one snapshot per
53 /// provider-specific live fetch.
54 #[derive(Default)]
55 struct LiveSnapshotPartitions {
56 models_dev: Option<CatalogSnapshot>,
57 per_provider: BTreeMap<String, CatalogSnapshot>,
58 }
59
60 impl LiveSnapshotPartitions {
61 /// Collect all live rows from every partition into a single flat snapshot.
62 fn flattened(&self) -> Option<CatalogSnapshot> {
63 if self.models_dev.is_none() && self.per_provider.is_empty() {
64 return None;
65 }
66
67 // Merge by (provider, wire_model_id); provider-scoped rows win on
68 // collision because they came from that gateway's own live endpoint.
69 let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
70 if let Some(models_dev) = &self.models_dev {
71 for row in &models_dev.offerings {
72 merged.insert(
73 (row.provider.clone(), row.wire_model_id.clone()),
74 row.clone(),
75 );
76 }
77 }
78 for provider_snapshot in self.per_provider.values() {
79 for row in &provider_snapshot.offerings {
80 merged.insert(
81 (row.provider.clone(), row.wire_model_id.clone()),
82 row.clone(),
83 );
84 }
85 }
86 Some(CatalogSnapshot {
87 offerings: merged.into_values().collect(),
88 })
89 }
90 }
91
92 fn offerings_by_provider(
93 offerings: Vec<CatalogOffering>,
94 ) -> BTreeMap<String, Vec<CatalogOffering>> {
95 let mut grouped = BTreeMap::new();
96 for offering in offerings {
97 grouped
98 .entry(offering.provider.trim().to_ascii_lowercase())
99 .or_insert_with(Vec::new)
100 .push(offering);
101 }
102 grouped
103 }
104
105 /// Generation stamp for the live snapshot. Bumped (under the `LIVE_SNAPSHOT`
106 /// write lock) by [`set_live_snapshot`], [`merge_live_offerings`], and
107 /// [`clear_live_snapshot`] so the memoized merged snapshot below can detect
108 /// staleness without re-merging.
109 static LIVE_GENERATION: AtomicU64 = AtomicU64::new(0);
110
111 /// Memoized result of [`merged_snapshot`], tagged with the `LIVE_GENERATION`
112 /// it was computed from. Re-merging ~5,700 offerings per call made every
113 /// `/model` open pay a multi-second, UI-thread-blocking cost; the merge result
114 /// only changes when the live snapshot changes, so cache it.
115 static MERGED_CACHE: RwLock<Option<(u64, Arc<CatalogSnapshot>)>> = RwLock::new(None);
116
117 fn bundled_snapshot() -> &'static CatalogSnapshot {
118 BUNDLED_SNAPSHOT.get_or_init(|| CatalogSnapshot {
119 offerings: bundled_catalog_offerings(),
120 })
121 }
122
123 /// Remove catalog rows that cannot use the selected provider's wire protocol.
124 ///
125 /// OpenCode Go publishes one `/models` roster for both Chat Completions and
126 /// Anthropic Messages. The `OpencodeGo` route is Chat-only, so sanitize both
127 /// saved/live snapshots and the bundled fallback at the lake boundary. This is
128 /// deliberately downstream of every publisher so stale cached rows cannot
129 /// bypass the client-side live-fetch filter.
130 fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapshot {
131 snapshot.offerings = snapshot
132 .offerings
133 .into_iter()
134 .filter_map(|mut offering| {
135 if ApiProvider::parse(&offering.provider) == Some(ApiProvider::OpencodeGo) {
136 let canonical = opencode_go_chat_model_id(&offering.wire_model_id)?;
137 offering.provider = ApiProvider::OpencodeGo.as_str().to_string();
138 offering.wire_model_id = canonical.to_string();
139 }
140 Some(offering)
141 })
142 .collect();
143 snapshot
144 }
145
146 /// Set the live-catalog snapshot for a given source (#4188 race fix).
147 ///
148 /// Source-scoped: a Models.dev refresh replaces only Models.dev-sourced rows;
149 /// a per-provider refresh replaces only the layers for providers represented
150 /// in that snapshot. Other providers and sources are preserved. This
151 /// eliminates the race where a Models.dev `set_live_snapshot` would erase
152 /// TelecomJS rows merged earlier.
153 pub fn set_live_snapshot(snapshot: CatalogSnapshot, source: LiveSource) {
154 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
155 let snapshot = apply_provider_model_cutlines(snapshot);
156 let changed = match source {
157 LiveSource::ModelsDev => {
158 guard.models_dev = Some(snapshot);
159 true
160 }
161 LiveSource::PerProvider => {
162 let grouped = offerings_by_provider(snapshot.offerings);
163 let changed = !grouped.is_empty();
164 for (provider, offerings) in grouped {
165 guard
166 .per_provider
167 .insert(provider, CatalogSnapshot { offerings });
168 }
169 changed
170 }
171 };
172 // Invalidate the memoized merged snapshot while still holding the
173 // write lock so no reader can cache the old merge against the new
174 // generation.
175 if changed {
176 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
177 }
178 }
179 }
180
181 /// Clear all live snapshots (both Models.dev and per-provider partitions).
182 /// Used by tests and shutdown paths that need a full reset.
183 #[allow(dead_code)]
184 pub fn clear_live_snapshot() {
185 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
186 guard.models_dev = None;
187 guard.per_provider.clear();
188 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
189 }
190 }
191
192 /// Merge additional live offerings into provider-scoped live partitions (#4188).
193 ///
194 /// Unlike [`set_live_snapshot`] for `LiveSource::PerProvider` (which replaces
195 /// each represented provider's partition), this merges new rows by
196 /// `(provider, wire_model_id)` identity within that provider's partition,
197 /// preserving every other provider and the Models.dev partition. This is used
198 /// by provider catalog refreshes (e.g. TelecomJS `/v1/models`) that need to
199 /// coexist with the cross-provider Models.dev live layer.
200 pub fn merge_live_offerings(new_offerings: Vec<CatalogOffering>) {
201 if new_offerings.is_empty() {
202 return;
203 }
204 if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
205 for (provider, new_rows) in offerings_by_provider(new_offerings) {
206 let existing = guard.per_provider.remove(&provider).unwrap_or_default();
207 let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
208 for row in existing.offerings {
209 merged.insert((row.provider.clone(), row.wire_model_id.clone()), row);
210 }
211 for row in new_rows {
212 merged.insert((row.provider.clone(), row.wire_model_id.clone()), row);
213 }
214 guard.per_provider.insert(
215 provider,
216 CatalogSnapshot {
217 offerings: merged.into_values().collect(),
218 },
219 );
220 }
221 LIVE_GENERATION.fetch_add(1, Ordering::SeqCst);
222 }
223 }
224
225 /// The merged catalog snapshot: live rows override bundled rows on
226 /// `(provider, wire_model_id)` identity (#4188). When no live snapshot is
227 /// present, this is just the offline bundled snapshot. Per-provider live rows
228 /// override Models.dev live rows on collision (gateway-specific wins over
229 /// cross-provider).
230 ///
231 /// Memoized: the merge is recomputed only after a live-layer mutation bumps
232 /// `LIVE_GENERATION`; every other call returns the cached `Arc` (the picker
233 /// calls this per row, so it must be cheap).
234 fn merged_snapshot() -> Arc<CatalogSnapshot> {
235 let generation = LIVE_GENERATION.load(Ordering::SeqCst);
236 if let Ok(guard) = MERGED_CACHE.read()
237 && let Some((cached_generation, cached)) = guard.as_ref()
238 && *cached_generation == generation
239 {
240 return Arc::clone(cached);
241 }
242 let merged = Arc::new(compute_merged_snapshot());
243 if let Ok(mut guard) = MERGED_CACHE.write() {
244 // `generation` was sampled before the live snapshot was read, so a
245 // concurrent set/clear leaves this entry stale-tagged and the next
246 // reader recomputes; the merge itself is always internally consistent.
247 *guard = Some((generation, Arc::clone(&merged)));
248 }
249 merged
250 }
251
252 /// Uncached merge (see [`merged_snapshot`] for the caching seam).
253 fn compute_merged_snapshot() -> CatalogSnapshot {
254 let live = LIVE_SNAPSHOT
255 .read()
256 .ok()
257 .and_then(|guard| guard.flattened());
258 let merged = match live {
259 None => bundled_snapshot().clone(),
260 Some(live) => {
261 let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
262 for row in &bundled_snapshot().offerings {
263 merged.insert(
264 (row.provider.clone(), row.wire_model_id.clone()),
265 row.clone(),
266 );
267 }
268 for row in &live.offerings {
269 merged.insert(
270 (row.provider.clone(), row.wire_model_id.clone()),
271 row.clone(),
272 );
273 }
274 CatalogSnapshot {
275 offerings: merged.into_values().collect(),
276 }
277 }
278 };
279 apply_provider_model_cutlines(merged)
280 }
281
282 /// Maps an [`ApiProvider`] to its bundled-catalog provider id.
283 fn catalog_provider_id(provider: ApiProvider) -> &'static str {
284 match provider {
285 ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => "deepseek",
286 ApiProvider::SiliconflowCn => "siliconflow",
287 _ => provider.as_str(),
288 }
289 }
290
291 fn push_unique_model(models: &mut Vec<String>, model: &str) {
292 let model = model.trim();
293 if model.is_empty() {
294 return;
295 }
296 if !models
297 .iter()
298 .any(|existing| existing.eq_ignore_ascii_case(model))
299 {
300 models.push(model.to_string());
301 }
302 }
303
304 fn catalog_models_from_offerings<'a>(
305 offerings: impl IntoIterator<Item = &'a CatalogOffering>,
306 ) -> Vec<String> {
307 let mut rows: Vec<_> = offerings.into_iter().collect();
308 rows.sort_by(|left, right| {
309 right
310 .default_for_provider
311 .cmp(&left.default_for_provider)
312 .then_with(|| left.wire_model_id.cmp(&right.wire_model_id))
313 });
314 let mut models = Vec::new();
315 for row in rows {
316 push_unique_model(&mut models, &row.wire_model_id);
317 }
318 models
319 }
320
321 /// Catalog-backed model ids for one provider (#4188).
322 ///
323 /// Precedence: live Models.dev rows (when published) override bundled offline
324 /// rows on `(provider, wire_model_id)`; if the merged catalog still has no rows
325 /// for the provider, fall back to
326 /// [`crate::config::model_completion_names_for_provider`] so CodeWhale-only /
327 /// local providers (and gateways not yet in the offline seed) keep defaults.
328 #[must_use]
329 pub fn all_catalog_models_for_provider(provider: ApiProvider) -> Vec<String> {
330 // ChatGPT OAuth availability is account-scoped. A generic OpenAI or
331 // Models.dev catalog is not evidence that a model can be routed through
332 // the Codex backend, so this provider owns a separate secret-free source.
333 if provider == ApiProvider::OpenaiCodex {
334 return codex_model_cache::model_roster().model_ids();
335 }
336
337 let catalog_id = catalog_provider_id(provider);
338 let merged = merged_snapshot();
339 let mut models = catalog_models_from_offerings(merged.offerings_for_provider(catalog_id));
340 if models.is_empty() {
341 for model in model_completion_names_for_provider(provider) {
342 push_unique_model(&mut models, model);
343 }
344 }
345 models
346 }
347
348 /// Look up a merged-catalog offering for `(provider, wire_model_id)` (#4115).
349 ///
350 /// Returns the live-over-bundled row when present so picker metadata (context,
351 /// pricing, tools, reasoning, freshness) can be projected without a second
352 /// catalog walk. `None` for CodeWhale-only / legacy-fallback ids that have no
353 /// Models.dev row.
354 #[must_use]
355 pub fn catalog_offering_for_model(
356 provider: ApiProvider,
357 wire_model_id: &str,
358 ) -> Option<CatalogOffering> {
359 if provider == ApiProvider::OpenaiCodex {
360 return None;
361 }
362 let catalog_id = catalog_provider_id(provider);
363 let needle = wire_model_id.trim();
364 if needle.is_empty() {
365 return None;
366 }
367 merged_snapshot()
368 .offerings_for_provider(catalog_id)
369 .into_iter()
370 .find(|row| row.wire_model_id.eq_ignore_ascii_case(needle))
371 .cloned()
372 }
373
374 /// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
375 /// ignoring any live rows merged over it.
376 ///
377 /// Pricing uses this as an honest fallback when a live row cannot be verified as
378 /// authoritative for the endpoint being priced (stale fetch, or a fetch from a
379 /// different base URL). The bundled snapshot is a published Models.dev seed with
380 /// no endpoint scoping, so it is authoritative for the model without needing a
381 /// freshness proof — degrading to it is strictly more truthful than billing
382 /// against an unverified live rate (#4318).
383 #[must_use]
384 pub fn bundled_catalog_offering_for_model(
385 provider: ApiProvider,
386 wire_model_id: &str,
387 ) -> Option<CatalogOffering> {
388 if provider == ApiProvider::OpenaiCodex {
389 return None;
390 }
391 let catalog_id = catalog_provider_id(provider);
392 let needle = wire_model_id.trim();
393 if needle.is_empty() {
394 return None;
395 }
396 bundled_snapshot()
397 .offerings_for_provider(catalog_id)
398 .into_iter()
399 .find(|row| row.wire_model_id.eq_ignore_ascii_case(needle))
400 .cloned()
401 }
402
403 /// Count of merged-catalog models for one provider (catalog view / dashboard).
404 #[must_use]
405 pub fn catalog_model_count_for_provider(provider: ApiProvider) -> usize {
406 all_catalog_models_for_provider(provider).len()
407 }
408
409 /// Providers the user has set up — active provider, working credentials/OAuth,
410 /// or an explicit `[providers.<name>]` entry (#3830).
411 #[must_use]
412 pub fn configured_providers(config: &Config, active: ApiProvider) -> Vec<ApiProvider> {
413 ApiProvider::sorted_for_display()
414 .into_iter()
415 .filter(|provider| provider_is_configured_for_active(config, *provider, active))
416 .collect()
417 }
418
419 /// Catalog models for providers that qualify as configured for `active`.
420 #[must_use]
421 pub fn models_for_provider(
422 config: &Config,
423 active: ApiProvider,
424 provider: ApiProvider,
425 ) -> Vec<String> {
426 if provider_is_configured_for_active(config, provider, active) {
427 all_catalog_models_for_provider(provider)
428 } else {
429 Vec::new()
430 }
431 }
432
433 /// Every built-in provider that carries at least one merged-catalog row.
434 #[must_use]
435 #[allow(dead_code)]
436 pub fn all_catalog_providers() -> Vec<ApiProvider> {
437 let mut seen = Vec::new();
438 for offering in &merged_snapshot().offerings {
439 if let Some(provider) = ApiProvider::parse(&offering.provider)
440 && !seen.contains(&provider)
441 {
442 seen.push(provider);
443 }
444 }
445 seen
446 }
447
448 #[cfg(test)]
449 mod tests {
450 use super::*;
451 use crate::config::{DEFAULT_TOGETHER_FLASH_MODEL, DEFAULT_TOGETHER_MODEL};
452 use codewhale_config::catalog::CatalogSource;
453 use std::sync::{Mutex, MutexGuard, OnceLock};
454
455 /// Serialize tests that mutate the process-wide live snapshot.
456 fn lock_live_snapshot() -> MutexGuard<'static, ()> {
457 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
458 LOCK.get_or_init(|| Mutex::new(()))
459 .lock()
460 .unwrap_or_else(|poisoned| poisoned.into_inner())
461 }
462
463 #[test]
464 fn together_catalog_includes_flash_from_bundled_asset() {
465 let _live = lock_live_snapshot();
466 clear_live_snapshot();
467 let models = all_catalog_models_for_provider(ApiProvider::Together);
468 assert!(
469 models.contains(&DEFAULT_TOGETHER_MODEL.to_string()),
470 "missing Together pro: {models:?}"
471 );
472 assert!(
473 models.contains(&DEFAULT_TOGETHER_FLASH_MODEL.to_string()),
474 "missing Together flash: {models:?}"
475 );
476 }
477
478 #[test]
479 fn configured_providers_matches_provider_predicate() {
480 let _env_lock = crate::test_support::lock_test_env();
481 let tmp = tempfile::tempdir().expect("tempdir");
482 let _auth_file = crate::test_support::EnvVarGuard::set(
483 "OPENAI_CODEX_AUTH_FILE",
484 tmp.path().join("missing-auth.json"),
485 );
486 let _openai_token = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
487 let _codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
488 let config = Config::default();
489 let active = ApiProvider::Deepseek;
490 let expected: Vec<_> = ApiProvider::sorted_for_display()
491 .into_iter()
492 .filter(|provider| {
493 crate::config::provider_is_configured_for_active(&config, *provider, active)
494 })
495 .collect();
496 assert_eq!(configured_providers(&config, active), expected);
497 }
498
499 #[test]
500 fn models_for_provider_filters_unconfigured_gateways() {
501 let _env_lock = crate::test_support::lock_test_env();
502 let _together = crate::test_support::EnvVarGuard::remove("TOGETHER_API_KEY");
503 let config = Config::default();
504 assert!(
505 models_for_provider(&config, ApiProvider::Deepseek, ApiProvider::Together).is_empty()
506 );
507 assert!(
508 !models_for_provider(&config, ApiProvider::Deepseek, ApiProvider::Deepseek).is_empty()
509 );
510 }
511
512 /// #4116 CRITICAL (no-narrowing guarantee for the migrated consumer): the
513 /// catalog-backed facade must return a NON-EMPTY enumeration for every
514 /// provider that has a non-empty legacy `model_completion_names_for_provider`
515 /// table. `all_catalog_models_for_provider` falls back to that legacy table
516 /// whenever the merged catalog has no rows for the provider, so this holds by
517 /// construction — and it proves that the raw-legacy tail removed from the
518 /// subagent `operator_model_for_subagent` consumer (which only ran when the
519 /// facade was empty) was unreachable whenever legacy was non-empty. The
520 /// migrated consumer is therefore behavior-preserving: it always has a
521 /// catalog-sourced model to pick and never narrows to fewer choices than the
522 /// legacy path offered.
523 ///
524 /// Note: the facade is intentionally *catalog-authoritative* (live >
525 /// bundled > legacy fallback, #4188), so for some providers whose catalog
526 /// supersedes stale entries in the legacy placeholder table (e.g.
527 /// OpenRouter/MiniMax revisions), the facade is not a strict superset of
528 /// every legacy id. That divergence does not affect subagent model
529 /// *acceptance*, which is gated by `validate_route` /
530 /// `requested_model_for_provider`, not by this list.
531 #[test]
532 fn catalog_facade_covers_every_provider_with_a_legacy_table() {
533 let _env = crate::test_support::lock_test_env();
534 let codex_home = tempfile::tempdir().expect("temporary CODEX_HOME");
535 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
536 let _live = lock_live_snapshot();
537 clear_live_snapshot();
538 for &provider in ApiProvider::all() {
539 let legacy_len = model_completion_names_for_provider(provider).len();
540 if legacy_len == 0 {
541 continue;
542 }
543 assert!(
544 !all_catalog_models_for_provider(provider).is_empty(),
545 "catalog facade returned no models for {provider:?} despite a \
546 non-empty legacy table ({legacy_len} entries): the operator-route \
547 consumer would have nothing to enumerate"
548 );
549 }
550 }
551
552 /// #4188: CodeWhale-only / local providers keep defaults via the legacy
553 /// fallback when Models.dev (live or bundled) has no rows for them.
554 #[test]
555 fn codewhale_only_providers_keep_legacy_defaults() {
556 let _env = crate::test_support::lock_test_env();
557 let codex_home = tempfile::tempdir().expect("temporary CODEX_HOME");
558 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
559 let _live = lock_live_snapshot();
560 clear_live_snapshot();
561 let openai_codex = all_catalog_models_for_provider(ApiProvider::OpenaiCodex);
562 assert!(
563 !openai_codex.is_empty(),
564 "openai-codex must keep a default model offline: {openai_codex:?}"
565 );
566 assert_eq!(
567 openai_codex,
568 model_completion_names_for_provider(ApiProvider::OpenaiCodex)
569 .iter()
570 .map(|m| (*m).to_string())
571 .collect::<Vec<_>>(),
572 "openai-codex should come from the compatibility fallback table"
573 );
574
575 // Ollama intentionally has an empty legacy table (user-supplied ids);
576 // the lake must still return empty rather than inventing rows.
577 assert!(all_catalog_models_for_provider(ApiProvider::Ollama).is_empty());
578 assert!(model_completion_names_for_provider(ApiProvider::Ollama).is_empty());
579 }
580
581 /// #4116 / #4188 (AC): a provider with no bundled/live catalog coverage must
582 /// fall back to the legacy table verbatim, so CodeWhale-only routes stay
583 /// usable. We assert this for every currently-unbundled provider that still
584 /// carries a non-empty legacy list, and require at least one such provider
585 /// to exist so the fallback path is actually exercised.
586 #[test]
587 fn unbundled_provider_falls_back_to_legacy_table() {
588 let _live = lock_live_snapshot();
589 clear_live_snapshot();
590 let merged = merged_snapshot();
591 let mut exercised = 0usize;
592 for &provider in ApiProvider::all() {
593 // OpenAI Codex deliberately owns an account-scoped cache source;
594 // its fallback behavior is covered separately above.
595 if provider == ApiProvider::OpenaiCodex {
596 continue;
597 }
598 let catalog_id = catalog_provider_id(provider);
599 let has_catalog_rows = !merged.offerings_for_provider(catalog_id).is_empty();
600 let legacy = model_completion_names_for_provider(provider);
601 if has_catalog_rows || legacy.is_empty() {
602 continue;
603 }
604 // Unbundled + non-empty legacy: the facade must echo the legacy list.
605 let facade = all_catalog_models_for_provider(provider);
606 let expected: Vec<String> = legacy.iter().map(|m| m.to_string()).collect();
607 assert_eq!(
608 facade, expected,
609 "unbundled provider {provider:?} did not fall back to the legacy table"
610 );
611 exercised += 1;
612 }
613 assert!(
614 exercised > 0,
615 "expected at least one unbundled provider to exercise the legacy fallback path"
616 );
617 }
618
619 /// #4188: live Models.dev rows win over bundled on identity, and clearing
620 /// live restores the offline bundled snapshot (offline startup still works).
621 #[test]
622 fn live_snapshot_merges_over_bundled() {
623 let _live = lock_live_snapshot();
624 clear_live_snapshot();
625 // With no live snapshot, we get bundled models.
626 let bundled = all_catalog_models_for_provider(ApiProvider::Deepseek);
627 assert!(!bundled.is_empty());
628
629 // Set a live snapshot that adds a synthetic model.
630 let live = CatalogSnapshot {
631 offerings: vec![CatalogOffering {
632 provider: "deepseek".to_string(),
633 wire_model_id: "deepseek-v4-synthetic".to_string(),
634 endpoint_key: "chat".to_string(),
635 ..Default::default()
636 }],
637 };
638 set_live_snapshot(live, LiveSource::ModelsDev);
639 let merged = all_catalog_models_for_provider(ApiProvider::Deepseek);
640 assert!(merged.contains(&"deepseek-v4-synthetic".to_string()));
641 // The bundled model is still present.
642 assert!(merged.iter().any(|m| bundled.contains(m)));
643
644 clear_live_snapshot();
645 let after_clear = all_catalog_models_for_provider(ApiProvider::Deepseek);
646 assert_eq!(after_clear, bundled);
647 }
648
649 /// Memoization: repeated `merged_snapshot()` calls return the cached merge
650 /// (same `Arc` allocation), and publishing or clearing a live snapshot
651 /// invalidates the cache so new content becomes visible.
652 #[test]
653 fn merged_snapshot_cache_invalidates_on_live_snapshot_change() {
654 let _live = lock_live_snapshot();
655 clear_live_snapshot();
656
657 let bundled_only = merged_snapshot();
658 assert!(
659 Arc::ptr_eq(&bundled_only, &merged_snapshot()),
660 "repeated merged_snapshot() calls must return the cached Arc"
661 );
662 let probe = "deepseek-cache-probe-model";
663 assert!(
664 !bundled_only
665 .offerings
666 .iter()
667 .any(|row| row.wire_model_id == probe),
668 "probe model must not pre-exist in the bundled snapshot"
669 );
670
671 set_live_snapshot(
672 CatalogSnapshot {
673 offerings: vec![CatalogOffering {
674 provider: "deepseek".to_string(),
675 wire_model_id: probe.to_string(),
676 endpoint_key: "chat".to_string(),
677 ..Default::default()
678 }],
679 },
680 LiveSource::ModelsDev,
681 );
682 let with_live = merged_snapshot();
683 assert!(
684 !Arc::ptr_eq(&bundled_only, &with_live),
685 "set_live_snapshot must invalidate the memoized merge"
686 );
687 assert!(
688 with_live
689 .offerings
690 .iter()
691 .any(|row| row.wire_model_id == probe),
692 "new live content must be visible after set_live_snapshot"
693 );
694
695 clear_live_snapshot();
696 let after_clear = merged_snapshot();
697 assert!(
698 !after_clear
699 .offerings
700 .iter()
701 .any(|row| row.wire_model_id == probe),
702 "clear_live_snapshot must invalidate the memoized merge"
703 );
704 assert_eq!(
705 after_clear.offerings, bundled_only.offerings,
706 "clearing live must restore the bundled-only merge content"
707 );
708 }
709
710 #[test]
711 fn opencode_go_lake_drops_messages_only_saved_and_live_rows() {
712 let _live = lock_live_snapshot();
713 clear_live_snapshot();
714
715 let mut offerings: Vec<_> = crate::config::OPENCODE_GO_CHAT_MODELS
716 .iter()
717 .map(|model| CatalogOffering {
718 provider: "opencode_go".to_string(),
719 wire_model_id: if *model == crate::config::DEFAULT_OPENCODE_GO_MODEL {
720 format!("opencode-go/{model}")
721 } else {
722 (*model).to_string()
723 },
724 endpoint_key: "chat".to_string(),
725 ..Default::default()
726 })
727 .collect();
728 offerings.extend(["minimax-m3", "qwen3.7-max"].map(|model| CatalogOffering {
729 provider: "opencode-go".to_string(),
730 wire_model_id: model.to_string(),
731 endpoint_key: "messages".to_string(),
732 ..Default::default()
733 }));
734 set_live_snapshot(CatalogSnapshot { offerings }, LiveSource::ModelsDev);
735
736 let models: std::collections::BTreeSet<_> =
737 all_catalog_models_for_provider(ApiProvider::OpencodeGo)
738 .into_iter()
739 .collect();
740 let expected: std::collections::BTreeSet<_> = crate::config::OPENCODE_GO_CHAT_MODELS
741 .iter()
742 .map(|model| (*model).to_string())
743 .collect();
744 assert_eq!(models, expected);
745 for messages_only in ["minimax-m3", "qwen3.7-max"] {
746 assert!(
747 catalog_offering_for_model(ApiProvider::OpencodeGo, messages_only).is_none(),
748 "saved/live {messages_only} row must not bypass the Chat-only lake cutline"
749 );
750 }
751 assert!(
752 catalog_offering_for_model(
753 ApiProvider::OpencodeGo,
754 crate::config::DEFAULT_OPENCODE_GO_MODEL,
755 )
756 .is_some()
757 );
758
759 clear_live_snapshot();
760 }
761
762 /// #4188: live > bundled > legacy fallback precedence, including live
763 /// override of a bundled wire id and no duplicate rows after alias
764 /// normalization (`moonshotai` → `moonshot`).
765 #[test]
766 fn live_over_bundled_over_legacy_precedence_and_alias_dedupe() {
767 let _live = lock_live_snapshot();
768 clear_live_snapshot();
769
770 let bundled_moonshot = all_catalog_models_for_provider(ApiProvider::Moonshot);
771 assert!(
772 !bundled_moonshot.is_empty(),
773 "offline bundled Moonshot seed required: {bundled_moonshot:?}"
774 );
775
776 // Live rows use the Models.dev alias id; lake merge must normalize onto
777 // CodeWhale `moonshot` and not leave a parallel `moonshotai` bucket.
778 let live = CatalogSnapshot {
779 offerings: vec![
780 CatalogOffering {
781 provider: "moonshot".to_string(),
782 wire_model_id: "kimi-k2.5-live".to_string(),
783 endpoint_key: "chat".to_string(),
784 default_for_provider: true,
785 ..Default::default()
786 },
787 // Same identity as a typical bundled Moonshot default — live wins.
788 CatalogOffering {
789 provider: "moonshot".to_string(),
790 wire_model_id: bundled_moonshot[0].clone(),
791 endpoint_key: "chat".to_string(),
792 family: Some("live-override".to_string()),
793 ..Default::default()
794 },
795 ],
796 };
797 set_live_snapshot(live, LiveSource::ModelsDev);
798
799 let merged = merged_snapshot();
800 let moonshot_rows = merged.offerings_for_provider("moonshot");
801 assert!(
802 moonshot_rows
803 .iter()
804 .any(|r| r.wire_model_id == "kimi-k2.5-live"),
805 "live-only Moonshot row missing: {moonshot_rows:?}"
806 );
807 let overridden = moonshot_rows
808 .iter()
809 .find(|r| r.wire_model_id == bundled_moonshot[0])
810 .expect("bundled Moonshot id should still exist after live merge");
811 assert_eq!(
812 overridden.family.as_deref(),
813 Some("live-override"),
814 "live row must replace bundled facts on the same wire id"
815 );
816 assert!(
817 merged.offerings_for_provider("moonshotai").is_empty(),
818 "alias-normalized providers must not leave a duplicate moonshotai bucket"
819 );
820
821 let models = all_catalog_models_for_provider(ApiProvider::Moonshot);
822 let mut seen = std::collections::BTreeSet::new();
823 for model in &models {
824 assert!(
825 seen.insert(model.to_ascii_lowercase()),
826 "duplicate Moonshot model row after alias merge: {model}"
827 );
828 }
829 assert!(models.contains(&"kimi-k2.5-live".to_string()));
830
831 // Legacy fallback is skipped when catalog rows exist (even if legacy
832 // lists additional ids) — catalog is authoritative once non-empty.
833 assert!(
834 !model_completion_names_for_provider(ApiProvider::Moonshot).is_empty(),
835 "legacy Moonshot table should still exist as fallback documentation"
836 );
837
838 clear_live_snapshot();
839 assert_eq!(
840 all_catalog_models_for_provider(ApiProvider::Moonshot),
841 bundled_moonshot,
842 "clearing live must restore offline bundled Moonshot rows"
843 );
844 }
845
846 /// #4188: when live Models.dev emits both an alias id and the CodeWhale id
847 /// for the same provider, compiling through `live_offerings_from_models_dev`
848 /// then merging into the lake must not produce duplicate model rows.
849 #[test]
850 fn alias_normalized_live_rows_do_not_duplicate_in_lake() {
851 let _live = lock_live_snapshot();
852 clear_live_snapshot();
853 let body = r#"{
854 "models": {},
855 "providers": {
856 "moonshotai": {
857 "id": "moonshotai",
858 "models": {
859 "kimi-k2.5": {
860 "id": "kimi-k2.5",
861 "modalities": { "input": ["text"], "output": ["text"] }
862 }
863 }
864 },
865 "moonshot": {
866 "id": "moonshot",
867 "models": {
868 "kimi-k2.5": {
869 "id": "kimi-k2.5",
870 "modalities": { "input": ["text"], "output": ["text"] },
871 "limit": { "context": 262144, "output": 8192 }
872 },
873 "kimi-k2.7-code": {
874 "id": "kimi-k2.7-code",
875 "modalities": { "input": ["text"], "output": ["text"] }
876 }
877 }
878 }
879 }
880 }"#;
881 let catalog =
882 codewhale_config::models_dev::ModelsDevCatalog::parse_json(body).expect("parse");
883 let live_rows = codewhale_config::catalog::live_offerings_from_models_dev(
884 &catalog,
885 "alias-fp",
886 1_700_000_000,
887 );
888 assert!(
889 live_rows.iter().all(|r| r.provider == "moonshot"),
890 "both moonshotai and moonshot must normalize onto moonshot: {:?}",
891 live_rows
892 .iter()
893 .map(|r| r.provider.as_str())
894 .collect::<Vec<_>>()
895 );
896 set_live_snapshot(
897 CatalogSnapshot {
898 offerings: live_rows,
899 },
900 LiveSource::ModelsDev,
901 );
902
903 let models = all_catalog_models_for_provider(ApiProvider::Moonshot);
904 let kimi_count = models.iter().filter(|m| m.as_str() == "kimi-k2.5").count();
905 assert_eq!(
906 kimi_count, 1,
907 "alias-normalized providers must not duplicate kimi-k2.5: {models:?}"
908 );
909 assert!(
910 merged_snapshot()
911 .offerings_for_provider("moonshotai")
912 .is_empty()
913 );
914 clear_live_snapshot();
915 }
916
917 // ── Source-scoped partition tests (#4188 race fix) ──────────────────────
918
919 #[test]
920 fn provider_live_snapshots_are_scoped_per_provider() {
921 let _live = lock_live_snapshot();
922 clear_live_snapshot();
923
924 set_live_snapshot(
925 CatalogSnapshot {
926 offerings: vec![CatalogOffering {
927 provider: "telecomjs".to_string(),
928 wire_model_id: "deepseek-v4-pro".to_string(),
929 endpoint_key: "chat".to_string(),
930 ..Default::default()
931 }],
932 },
933 LiveSource::PerProvider,
934 );
935 let telecom_only = merged_snapshot();
936 assert_eq!(telecom_only.offerings_for_provider("telecomjs").len(), 1);
937
938 set_live_snapshot(
939 CatalogSnapshot {
940 offerings: vec![CatalogOffering {
941 provider: "another-gateway".to_string(),
942 wire_model_id: "another-model".to_string(),
943 endpoint_key: "chat".to_string(),
944 ..Default::default()
945 }],
946 },
947 LiveSource::PerProvider,
948 );
949
950 let merged = merged_snapshot();
951 assert_eq!(merged.offerings_for_provider("telecomjs").len(), 1);
952 assert_eq!(merged.offerings_for_provider("another-gateway").len(), 1);
953 assert!(
954 !Arc::ptr_eq(&telecom_only, &merged),
955 "publishing a second provider must invalidate the cached merge"
956 );
957
958 clear_live_snapshot();
959 }
960
961 /// Models.dev→TelecomJS completion order: Models.dev sets its snapshot first,
962 /// then TelecomJS merges per-provider rows. Both sets must be present in the
963 /// final merged view.
964 #[test]
965 fn models_dev_first_then_telecomjs_both_preserved() {
966 let _live = lock_live_snapshot();
967 clear_live_snapshot();
968
969 // 1) Models.dev publishes its cross-provider snapshot.
970 let models_dev_rows = vec![
971 CatalogOffering {
972 provider: "deepseek".to_string(),
973 wire_model_id: "deepseek-chat".to_string(),
974 endpoint_key: "chat".to_string(),
975 family: Some("deepseek".to_string()),
976 source: CatalogSource::Live {
977 base_url_fingerprint: "modelsdev-fp".to_string(),
978 fetched_at: 1000,
979 },
980 ..Default::default()
981 },
982 CatalogOffering {
983 provider: "zai".to_string(),
984 wire_model_id: "glm-4".to_string(),
985 endpoint_key: "chat".to_string(),
986 family: Some("glm".to_string()),
987 source: CatalogSource::Live {
988 base_url_fingerprint: "modelsdev-fp".to_string(),
989 fetched_at: 1000,
990 },
991 ..Default::default()
992 },
993 ];
994 set_live_snapshot(
995 CatalogSnapshot {
996 offerings: models_dev_rows,
997 },
998 LiveSource::ModelsDev,
999 );
1000 let before_provider_refresh = merged_snapshot();
1001 assert!(
1002 before_provider_refresh
1003 .offerings_for_provider("telecomjs")
1004 .is_empty()
1005 );
1006
1007 // 2) TelecomJS merges its per-provider rows (after Models.dev completes).
1008 let telecomjs_rows = vec![
1009 CatalogOffering {
1010 provider: "telecomjs".to_string(),
1011 wire_model_id: "deepseek-chat".to_string(),
1012 endpoint_key: "chat".to_string(),
1013 family: Some("deepseek".to_string()),
1014 source: CatalogSource::Live {
1015 base_url_fingerprint: "telecomjs-fp".to_string(),
1016 fetched_at: 2000,
1017 },
1018 ..Default::default()
1019 },
1020 CatalogOffering {
1021 provider: "telecomjs".to_string(),
1022 wire_model_id: "glm-4".to_string(),
1023 endpoint_key: "chat".to_string(),
1024 family: Some("glm".to_string()),
1025 source: CatalogSource::Live {
1026 base_url_fingerprint: "telecomjs-fp".to_string(),
1027 fetched_at: 2000,
1028 },
1029 ..Default::default()
1030 },
1031 ];
1032 merge_live_offerings(telecomjs_rows);
1033 assert_eq!(
1034 merged_snapshot().offerings_for_provider("telecomjs").len(),
1035 2,
1036 "provider refresh should invalidate the cached Models.dev-only view"
1037 );
1038
1039 // 3) Both sources' rows are present in the merged snapshot.
1040 let merged = merged_snapshot();
1041 let deepseek_rows = merged.offerings_for_provider("deepseek");
1042 assert!(
1043 deepseek_rows
1044 .iter()
1045 .any(|r| r.wire_model_id == "deepseek-chat"),
1046 "Models.dev deepseek row missing: {deepseek_rows:?}"
1047 );
1048 let zai_rows = merged.offerings_for_provider("zai");
1049 assert!(
1050 zai_rows.iter().any(|r| r.wire_model_id == "glm-4"),
1051 "Models.dev zai row missing: {zai_rows:?}"
1052 );
1053 let telecomjs_rows_merged = merged.offerings_for_provider("telecomjs");
1054 assert_eq!(
1055 telecomjs_rows_merged.len(),
1056 2,
1057 "TelecomJS rows missing: {telecomjs_rows_merged:?}"
1058 );
1059 assert!(
1060 telecomjs_rows_merged
1061 .iter()
1062 .any(|r| r.wire_model_id == "deepseek-chat"),
1063 "TelecomJS deepseek-chat row missing"
1064 );
1065 assert!(
1066 telecomjs_rows_merged
1067 .iter()
1068 .any(|r| r.wire_model_id == "glm-4"),
1069 "TelecomJS glm-4 row missing"
1070 );
1071
1072 clear_live_snapshot();
1073 }
1074
1075 /// TelecomJS→Models.dev completion order: TelecomJS merges first, then
1076 /// Models.dev replaces the cross-provider snapshot. TelecomJS rows must
1077 /// survive the Models.dev refresh (they live in a separate partition).
1078 #[test]
1079 fn telecomjs_first_then_models_dev_both_preserved() {
1080 let _live = lock_live_snapshot();
1081 clear_live_snapshot();
1082
1083 // 1) TelecomJS merges its per-provider rows first.
1084 let telecomjs_rows = vec![
1085 CatalogOffering {
1086 provider: "telecomjs".to_string(),
1087 wire_model_id: "deepseek-chat".to_string(),
1088 endpoint_key: "chat".to_string(),
1089 family: Some("deepseek".to_string()),
1090 source: CatalogSource::Live {
1091 base_url_fingerprint: "telecomjs-fp".to_string(),
1092 fetched_at: 2000,
1093 },
1094 ..Default::default()
1095 },
1096 CatalogOffering {
1097 provider: "telecomjs".to_string(),
1098 wire_model_id: "glm-4".to_string(),
1099 endpoint_key: "chat".to_string(),
1100 family: Some("glm".to_string()),
1101 source: CatalogSource::Live {
1102 base_url_fingerprint: "telecomjs-fp".to_string(),
1103 fetched_at: 2000,
1104 },
1105 ..Default::default()
1106 },
1107 ];
1108 merge_live_offerings(telecomjs_rows);
1109 assert_eq!(
1110 merged_snapshot().offerings_for_provider("telecomjs").len(),
1111 2,
1112 "provider rows should be visible before Models.dev completes"
1113 );
1114
1115 // 2) Models.dev refreshes and replaces its cross-provider snapshot.
1116 // Before the source-scoped fix, this would have wiped TelecomJS rows.
1117 let models_dev_rows = vec![CatalogOffering {
1118 provider: "deepseek".to_string(),
1119 wire_model_id: "deepseek-chat".to_string(),
1120 endpoint_key: "chat".to_string(),
1121 family: Some("deepseek".to_string()),
1122 source: CatalogSource::Live {
1123 base_url_fingerprint: "modelsdev-fp".to_string(),
1124 fetched_at: 3000,
1125 },
1126 ..Default::default()
1127 }];
1128 set_live_snapshot(
1129 CatalogSnapshot {
1130 offerings: models_dev_rows,
1131 },
1132 LiveSource::ModelsDev,
1133 );
1134
1135 // 3) Both sources' rows are present — TelecomJS rows were NOT erased.
1136 let merged = merged_snapshot();
1137 let telecomjs_rows_merged = merged.offerings_for_provider("telecomjs");
1138 assert_eq!(
1139 telecomjs_rows_merged.len(),
1140 2,
1141 "TelecomJS rows were erased by Models.dev refresh: {telecomjs_rows_merged:?}"
1142 );
1143 assert!(
1144 telecomjs_rows_merged
1145 .iter()
1146 .any(|r| r.wire_model_id == "deepseek-chat"),
1147 "TelecomJS deepseek-chat row erased"
1148 );
1149 assert!(
1150 telecomjs_rows_merged
1151 .iter()
1152 .any(|r| r.wire_model_id == "glm-4"),
1153 "TelecomJS glm-4 row erased"
1154 );
1155 let deepseek_rows = merged.offerings_for_provider("deepseek");
1156 assert!(
1157 deepseek_rows
1158 .iter()
1159 .any(|r| r.wire_model_id == "deepseek-chat"),
1160 "Models.dev deepseek row missing: {deepseek_rows:?}"
1161 );
1162
1163 clear_live_snapshot();
1164 }
1165
1166 /// Catalog refresh never deletes previously published rows: a Models.dev
1167 /// refresh that adds new rows must preserve existing per-provider rows,
1168 /// and a per-provider merge must preserve existing Models.dev rows.
1169 #[test]
1170 fn catalog_refresh_never_deletes_previously_published_rows() {
1171 let _live = lock_live_snapshot();
1172 clear_live_snapshot();
1173
1174 // 1) Initial state: Models.dev publishes rows for deepseek + zai.
1175 let initial_models_dev = vec![
1176 CatalogOffering {
1177 provider: "deepseek".to_string(),
1178 wire_model_id: "deepseek-chat".to_string(),
1179 endpoint_key: "chat".to_string(),
1180 source: CatalogSource::Live {
1181 base_url_fingerprint: "modelsdev-fp".to_string(),
1182 fetched_at: 1000,
1183 },
1184 ..Default::default()
1185 },
1186 CatalogOffering {
1187 provider: "zai".to_string(),
1188 wire_model_id: "glm-4".to_string(),
1189 endpoint_key: "chat".to_string(),
1190 source: CatalogSource::Live {
1191 base_url_fingerprint: "modelsdev-fp".to_string(),
1192 fetched_at: 1000,
1193 },
1194 ..Default::default()
1195 },
1196 ];
1197 set_live_snapshot(
1198 CatalogSnapshot {
1199 offerings: initial_models_dev,
1200 },
1201 LiveSource::ModelsDev,
1202 );
1203
1204 // 2) TelecomJS merges its rows.
1205 let telecomjs_rows = vec![CatalogOffering {
1206 provider: "telecomjs".to_string(),
1207 wire_model_id: "deepseek-chat".to_string(),
1208 endpoint_key: "chat".to_string(),
1209 source: CatalogSource::Live {
1210 base_url_fingerprint: "telecomjs-fp".to_string(),
1211 fetched_at: 2000,
1212 },
1213 ..Default::default()
1214 }];
1215 merge_live_offerings(telecomjs_rows);
1216
1217 // Record what we have before the second refresh.
1218 let before_refresh = merged_snapshot();
1219 let before_providers: std::collections::BTreeSet<_> = before_refresh
1220 .offerings
1221 .iter()
1222 .map(|r| (r.provider.clone(), r.wire_model_id.clone()))
1223 .collect();
1224 assert!(
1225 before_providers.contains(&("deepseek".to_string(), "deepseek-chat".to_string())),
1226 "deepseek row should exist before refresh"
1227 );
1228 assert!(
1229 before_providers.contains(&("telecomjs".to_string(), "deepseek-chat".to_string())),
1230 "telecomjs row should exist before refresh"
1231 );
1232
1233 // 3) Models.dev refreshes again with an updated snapshot (adds a new row).
1234 let updated_models_dev = vec![
1235 CatalogOffering {
1236 provider: "deepseek".to_string(),
1237 wire_model_id: "deepseek-chat".to_string(),
1238 endpoint_key: "chat".to_string(),
1239 source: CatalogSource::Live {
1240 base_url_fingerprint: "modelsdev-fp".to_string(),
1241 fetched_at: 3000,
1242 },
1243 ..Default::default()
1244 },
1245 CatalogOffering {
1246 provider: "zai".to_string(),
1247 wire_model_id: "glm-4".to_string(),
1248 endpoint_key: "chat".to_string(),
1249 source: CatalogSource::Live {
1250 base_url_fingerprint: "modelsdev-fp".to_string(),
1251 fetched_at: 3000,
1252 },
1253 ..Default::default()
1254 },
1255 // New row added by the refresh.
1256 CatalogOffering {
1257 provider: "moonshot".to_string(),
1258 wire_model_id: "kimi-k2.5".to_string(),
1259 endpoint_key: "chat".to_string(),
1260 source: CatalogSource::Live {
1261 base_url_fingerprint: "modelsdev-fp".to_string(),
1262 fetched_at: 3000,
1263 },
1264 ..Default::default()
1265 },
1266 ];
1267 set_live_snapshot(
1268 CatalogSnapshot {
1269 offerings: updated_models_dev,
1270 },
1271 LiveSource::ModelsDev,
1272 );
1273
1274 // 4) The TelecomJS row is STILL present — it was not deleted.
1275 let after_refresh = merged_snapshot();
1276 let after_telecomjs: Vec<_> = after_refresh
1277 .offerings_for_provider("telecomjs")
1278 .iter()
1279 .map(|r| r.wire_model_id.clone())
1280 .collect();
1281 assert!(
1282 after_telecomjs.iter().any(|id| id == "deepseek-chat"),
1283 "TelecomJS row was deleted by Models.dev refresh! Remaining: {after_telecomjs:?}"
1284 );
1285
1286 // 5) New Models.dev row is also present.
1287 let after_moonshot: Vec<_> = after_refresh
1288 .offerings_for_provider("moonshot")
1289 .iter()
1290 .map(|r| r.wire_model_id.clone())
1291 .collect();
1292 assert!(
1293 after_moonshot.iter().any(|id| id == "kimi-k2.5"),
1294 "New Models.dev moonshot row missing: {after_moonshot:?}"
1295 );
1296
1297 // 6) Also verify: a per-provider merge does not delete Models.dev rows.
1298 let extra_telecomjs = vec![CatalogOffering {
1299 provider: "telecomjs".to_string(),
1300 wire_model_id: "glm-4".to_string(),
1301 endpoint_key: "chat".to_string(),
1302 source: CatalogSource::Live {
1303 base_url_fingerprint: "telecomjs-fp".to_string(),
1304 fetched_at: 4000,
1305 },
1306 ..Default::default()
1307 }];
1308 merge_live_offerings(extra_telecomjs);
1309
1310 let final_merged = merged_snapshot();
1311 let final_deepseek: Vec<_> = final_merged
1312 .offerings_for_provider("deepseek")
1313 .iter()
1314 .map(|r| r.wire_model_id.clone())
1315 .collect();
1316 assert!(
1317 final_deepseek.iter().any(|id| id == "deepseek-chat"),
1318 "Models.dev deepseek row was deleted by per-provider merge! Remaining: {final_deepseek:?}"
1319 );
1320 let final_moonshot: Vec<_> = final_merged
1321 .offerings_for_provider("moonshot")
1322 .iter()
1323 .map(|r| r.wire_model_id.clone())
1324 .collect();
1325 assert!(
1326 final_moonshot.iter().any(|id| id == "kimi-k2.5"),
1327 "Models.dev moonshot row was deleted by per-provider merge! Remaining: {final_moonshot:?}"
1328 );
1329
1330 clear_live_snapshot();
1331 }
1332 }
1333
1333 lines RUST