| 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 |