返回 CodeWhale
tests.rs
根目录 / crates / config / src / catalog / tests.rs
1 //! Behavior tests for the Models.dev-backed catalog cache (#3385).
2 //!
3 //! Fixtures use synthetic ids for anti-hardcoding guards, plus the GLM-5.2 and
4 //! hosted-DeepSeek rows the issue explicitly asks to exercise. No full hosted
5 //! provider model list is copied here.
6
7 use super::*;
8
9 /// Zhipu canonical + Zhipu/Z.AI provider offerings, and a hosted DeepSeek row
10 /// served by an aggregator under a prefixed wire id with an explicit canonical
11 /// `base_model` join.
12 const FIXTURE: &str = r#"{
13 "models": {
14 "zhipuai/glm-5.2": {
15 "id": "zhipuai/glm-5.2",
16 "family": "glm",
17 "reasoning": true,
18 "modalities": { "input": ["text"], "output": ["text"] },
19 "limit": { "context": 1000000, "output": 131072 }
20 }
21 },
22 "providers": {
23 "zhipuai": {
24 "id": "zhipuai",
25 "models": {
26 "glm-5.2": {
27 "id": "glm-5.2",
28 "family": "glm",
29 "default": true,
30 "attachment": false,
31 "reasoning": true,
32 "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
33 "tool_call": true,
34 "structured_output": true,
35 "modalities": { "input": ["text"], "output": ["text"] },
36 "limit": { "context": 1000000, "output": 131072 },
37 "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
38 },
39 "glm-voice": {
40 "id": "glm-voice",
41 "modalities": { "input": ["text"], "output": ["audio"] }
42 }
43 }
44 },
45 "together": {
46 "id": "together",
47 "models": {
48 "deepseek-ai/DeepSeek-V4-Pro": {
49 "id": "deepseek-ai/DeepSeek-V4-Pro",
50 "base_model": "deepseek-v4-pro",
51 "family": "deepseek",
52 "reasoning": false,
53 "modalities": { "input": ["text"], "output": ["text"] },
54 "cost": { "input": 0.9, "output": 0.9 }
55 }
56 }
57 }
58 }
59 }"#;
60
61 fn fixture() -> ModelsDevCatalog {
62 ModelsDevCatalog::parse_json(FIXTURE).expect("fixture parses")
63 }
64
65 fn find<'a>(rows: &'a [CatalogOffering], provider: &str, wire: &str) -> &'a CatalogOffering {
66 rows.iter()
67 .find(|r| r.provider == provider && r.wire_model_id == wire)
68 .unwrap_or_else(|| panic!("offering {provider}/{wire} not found"))
69 }
70
71 #[test]
72 fn hydrates_models_dev_offerings_preserving_offering_facts() {
73 let rows = bundled_offerings_from_models_dev(&fixture());
74
75 // glm-voice (audio output) is excluded; two chat offerings remain.
76 assert_eq!(rows.len(), 2, "audio-only rows are not chat offerings");
77
78 let glm = find(&rows, "zhipuai", "glm-5.2");
79 assert!(glm.default_for_provider);
80 assert_eq!(glm.family.as_deref(), Some("glm"));
81 assert_eq!(glm.reasoning, Some(true));
82 assert_eq!(glm.attachment, Some(false));
83 assert_eq!(glm.tool_call, Some(true));
84 assert_eq!(glm.structured_output, Some(true));
85 // Provider-scoped reasoning options are preserved, not collapsed.
86 assert_eq!(glm.reasoning_options.len(), 1);
87 assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
88 assert_eq!(glm.cost.as_ref().and_then(|c| c.cache_read), Some(0.26));
89 // Provider row carried no base_model link → no inferred canonical model.
90 assert_eq!(glm.canonical_model, None);
91 assert_eq!(glm.source, CatalogSource::Bundled);
92 }
93
94 #[test]
95 fn hosted_offering_keeps_prefixed_wire_id_and_explicit_canonical_join() {
96 let rows = bundled_offerings_from_models_dev(&fixture());
97 let hosted = find(&rows, "together", "deepseek-ai/DeepSeek-V4-Pro");
98
99 // The prefixed wire id is preserved verbatim under the serving provider.
100 assert_eq!(hosted.wire_model_id, "deepseek-ai/DeepSeek-V4-Pro");
101 assert_eq!(hosted.provider, "together");
102 // Canonical link comes only from the explicit base_model.
103 assert_eq!(hosted.canonical_model.as_deref(), Some("deepseek-v4-pro"));
104 assert_eq!(hosted.reasoning, Some(false));
105 }
106
107 #[test]
108 fn model_only_rows_use_namespaced_keys_and_preserve_unpriced_facts() {
109 let catalog = ModelsDevCatalog::parse_json(
110 r#"{
111 "models": {
112 "moonshotai/synthetic/chat": {
113 "id": "",
114 "family": "synthetic",
115 "attachment": true,
116 "reasoning": true,
117 "tool_call": true,
118 "structured_output": false,
119 "limit": { "context": 123456, "output": 7890 },
120 "modalities": { "input": ["text", "image"], "output": ["text"] }
121 },
122 "new-vendor/solo": {},
123 "xiaomi/synthetic-chat": {},
124 "bare-model": {},
125 "/missing-provider": {},
126 "missing-model/": {},
127 "new-vendor/voice": {
128 "modalities": { "input": ["text"], "output": ["audio"] }
129 }
130 }
131 }"#,
132 )
133 .expect("fixture parses");
134
135 for live in [false, true] {
136 let (rows, provider, source) = if live {
137 (
138 live_offerings_from_models_dev(&catalog, 1_700),
139 "moonshot",
140 CatalogSource::ModelsDevLive { fetched_at: 1_700 },
141 )
142 } else {
143 (
144 bundled_offerings_from_models_dev(&catalog),
145 "moonshot",
146 CatalogSource::Bundled,
147 )
148 };
149 assert_eq!(
150 rows.len(),
151 3,
152 "unqualified and audio-only rows stay excluded"
153 );
154 let row = find(&rows, provider, "synthetic/chat");
155 assert_eq!(
156 row.canonical_model.as_deref(),
157 Some("moonshotai/synthetic/chat")
158 );
159 assert_eq!(row.endpoint_key, "chat");
160 assert_eq!(row.family.as_deref(), Some("synthetic"));
161 assert_eq!(
162 row.limit.as_ref().and_then(|limit| limit.context),
163 Some(123456)
164 );
165 assert_eq!(
166 row.limit.as_ref().and_then(|limit| limit.output),
167 Some(7890)
168 );
169 assert_eq!(row.attachment, Some(true));
170 assert_eq!(row.reasoning, Some(true));
171 assert_eq!(row.tool_call, Some(true));
172 assert_eq!(row.structured_output, Some(false));
173 assert_eq!(row.modalities.as_ref().unwrap().input, ["text", "image"]);
174 assert_eq!(row.source, source);
175 assert!(rows.iter().all(|row| {
176 row.cost.is_none()
177 // A MiMo row's price is withheld by a Codewhale correction.
178 && (row.cost_source.is_none()
179 || matches!(row.cost_source, Some(CatalogSource::CodewhaleBundled { .. })))
180 && !row.default_for_provider
181 && row.reasoning_options.is_empty()
182 }));
183 // Vendor namespaces normalize offline too (#6396 slice B).
184 find(&rows, "xiaomi-mimo", "synthetic-chat");
185 find(&rows, "new-vendor", "solo");
186 assert!(crate::ProviderKind::parse("new-vendor").is_none());
187 }
188 }
189
190 #[test]
191 fn model_only_rows_yield_to_provider_facts_and_non_chat_exclusions() {
192 let catalog = ModelsDevCatalog::parse_json(
193 r#"{
194 "models": {
195 "moonshotai/chat": { "reasoning": true },
196 "moonshotai/voice": {},
197 "moonshot/duplicate": {},
198 "moonshotai/duplicate": {}
199 },
200 "providers": {
201 "moonshot": {
202 "id": "moonshot",
203 "models": {
204 "chat": {
205 "id": "chat",
206 "base_model": "explicit/chat",
207 "reasoning": false,
208 "reasoning_options": [{ "type": "effort", "values": ["high"] }],
209 "default": true,
210 "cost": { "input": 2.0 }
211 },
212 "voice": {
213 "id": "voice",
214 "modalities": { "input": ["text"], "output": ["audio"] }
215 }
216 }
217 }
218 }
219 }"#,
220 )
221 .expect("fixture parses");
222 let rows = live_offerings_from_models_dev(&catalog, 1_700);
223 assert_eq!(
224 rows.len(),
225 2,
226 "aliases deduplicate; provider exclusions win"
227 );
228 let row = find(&rows, "moonshot", "chat");
229 assert_eq!(row.canonical_model.as_deref(), Some("explicit/chat"));
230 assert_eq!(row.reasoning, Some(false));
231 assert!(row.default_for_provider);
232 assert_eq!(row.cost.as_ref().and_then(|cost| cost.input), Some(2.0));
233 assert_eq!(row.reasoning_options.len(), 1);
234 find(&rows, "moonshot", "duplicate");
235 }
236
237 #[test]
238 fn provider_map_keys_preserve_precedence_when_model_ids_are_missing() {
239 let catalog = ModelsDevCatalog::parse_json(
240 r#"{
241 "models": {
242 "moonshotai/chat": { "reasoning": true },
243 "moonshotai/voice": {},
244 "moonshotai/explicit": { "reasoning": true }
245 },
246 "providers": {
247 "moonshotai": {
248 "models": {
249 " chat ": { "id": " ", "reasoning": false },
250 "voice": { "modalities": { "output": ["audio"] } },
251 "different-map-key": { "id": " explicit ", "reasoning": false },
252 " ": {}
253 }
254 }
255 }
256 }"#,
257 )
258 .expect("fixture parses");
259
260 for (rows, provider) in [
261 (live_offerings_from_models_dev(&catalog, 1_700), "moonshot"),
262 (bundled_offerings_from_models_dev(&catalog), "moonshotai"),
263 ] {
264 assert_eq!(rows.len(), 2, "provider identities and exclusions win");
265 for wire_model_id in ["chat", "explicit"] {
266 let row = find(&rows, provider, wire_model_id);
267 assert_eq!(row.reasoning, Some(false));
268 assert_eq!(row.canonical_model, None);
269 }
270 }
271 }
272
273 #[test]
274 fn to_offering_projects_routing_identity_and_limits() {
275 let rows = bundled_offerings_from_models_dev(&fixture());
276 let glm = find(&rows, "zhipuai", "glm-5.2").to_offering();
277
278 assert_eq!(glm.provider.as_str(), "zhipuai");
279 assert_eq!(glm.wire_model_id.as_str(), "glm-5.2");
280 assert_eq!(glm.canonical_model, None);
281 assert_eq!(glm.endpoint_key, "chat");
282 assert_eq!(glm.limits.context_tokens, Some(1_000_000));
283 assert_eq!(glm.limits.output_tokens, Some(131_072));
284 assert_eq!(
285 glm.capabilities.attachments,
286 crate::route::CapabilityState::Unsupported
287 );
288 assert_eq!(
289 glm.capabilities.reasoning,
290 crate::route::CapabilityState::Supported
291 );
292 assert_eq!(
293 glm.capabilities.native_tool_calls,
294 crate::route::CapabilityState::Supported
295 );
296 assert_eq!(
297 glm.capabilities.structured_output,
298 crate::route::CapabilityState::Supported
299 );
300 assert_eq!(
301 glm.capabilities.streaming,
302 crate::route::CapabilityState::Unknown
303 );
304 }
305
306 #[test]
307 fn compiler_merges_layers_with_override_precedence() {
308 // Bundled default for synthetic provider "acme".
309 let bundled = vec![CatalogOffering {
310 provider: "acme".into(),
311 wire_model_id: "synth-chat-1".into(),
312 endpoint_key: "chat".into(),
313 default_for_provider: true,
314 family: Some("synth".into()),
315 source: CatalogSource::Bundled,
316 ..Default::default()
317 }];
318 // Live refresh adds a new row AND restates the bundled one with a cost.
319 let live = vec![
320 CatalogOffering {
321 provider: "acme".into(),
322 wire_model_id: "synth-chat-1".into(),
323 endpoint_key: "chat".into(),
324 cost: Some(ModelsDevCost {
325 input: Some(2.0),
326 ..Default::default()
327 }),
328 source: CatalogSource::Live {
329 base_url_fingerprint: "fp".into(),
330 fetched_at: 100,
331 },
332 ..Default::default()
333 },
334 CatalogOffering {
335 provider: "acme".into(),
336 wire_model_id: "synth-chat-2".into(),
337 endpoint_key: "chat".into(),
338 source: CatalogSource::Live {
339 base_url_fingerprint: "fp".into(),
340 fetched_at: 100,
341 },
342 ..Default::default()
343 },
344 ];
345 // User override pins a custom canonical model on synth-chat-1.
346 let overrides = vec![CatalogOffering {
347 provider: "acme".into(),
348 wire_model_id: "synth-chat-1".into(),
349 canonical_model: Some("acme-canonical".into()),
350 endpoint_key: "chat".into(),
351 source: CatalogSource::UserOverride,
352 ..Default::default()
353 }];
354
355 let snapshot = CatalogCompiler::new()
356 .with_bundled(bundled)
357 .with_live(live)
358 .with_overrides(overrides)
359 .compile();
360
361 // Two distinct (provider, wire) identities survive de-duplication.
362 assert_eq!(snapshot.offerings.len(), 2);
363
364 let one = find(&snapshot.offerings, "acme", "synth-chat-1");
365 // Highest-precedence layer (override) wins the identity collision.
366 assert_eq!(one.source, CatalogSource::UserOverride);
367 assert_eq!(one.canonical_model.as_deref(), Some("acme-canonical"));
368
369 let two = find(&snapshot.offerings, "acme", "synth-chat-2");
370 assert!(matches!(two.source, CatalogSource::Live { .. }));
371 }
372
373 #[test]
374 fn compiler_layer_order_and_policy_deny_never_overridden() {
375 let row = |source: CatalogSource, model: &str, family: &str| CatalogOffering {
376 provider: "zai-coding-cn".into(),
377 wire_model_id: model.into(),
378 endpoint_key: "chat".into(),
379 family: Some(family.into()),
380 source,
381 ..Default::default()
382 };
383 let policy = crate::route::CatalogPolicy {
384 rules: vec![crate::route::PolicyRule {
385 effect: crate::route::PolicyEffect::Deny,
386 action: crate::route::PolicyAction::ModelUse,
387 resource: "*-cn/*".to_string(),
388 }],
389 };
390 let snapshot = CatalogCompiler::new()
391 .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")])
392 .with_models_dev_live(vec![row(
393 CatalogSource::ModelsDevLive { fetched_at: 1 },
394 "glm-5",
395 "models-dev",
396 )])
397 .with_provider_live(vec![row(
398 CatalogSource::Live {
399 base_url_fingerprint: "fp".into(),
400 fetched_at: 2,
401 },
402 "glm-5",
403 "provider",
404 )])
405 .with_config(vec![row(CatalogSource::ConfigOverride, "glm-5", "config")])
406 .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")])
407 .with_policy(policy)
408 .compile();
409
410 assert!(
411 snapshot.offerings.is_empty(),
412 "policy DENY after every layer must drop the row; layers cannot override it"
413 );
414
415 let allowed = CatalogCompiler::new()
416 .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")])
417 .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")])
418 .compile();
419 let kept = find(&allowed.offerings, "zai-coding-cn", "glm-5");
420 assert_eq!(kept.source, CatalogSource::UserOverride);
421 assert_eq!(kept.family.as_deref(), Some("user"));
422 }
423
424 #[test]
425 fn cache_scopes_by_provider_and_base_url_fingerprint() {
426 let fp_a = base_url_fingerprint("https://api.example.com/v1");
427 let fp_b = base_url_fingerprint("https://other.example.com/v1");
428 assert_ne!(fp_a, fp_b, "different hosts must not share a fingerprint");
429
430 let mut cache = ProviderCatalogCache::new();
431 let row = |id: &str| CatalogOffering {
432 provider: "acme".into(),
433 wire_model_id: id.into(),
434 endpoint_key: "chat".into(),
435 ..Default::default()
436 };
437
438 // Same provider, two different base URLs.
439 cache.record_success(
440 ProviderCatalogDelta {
441 provider: "acme".into(),
442 base_url_fingerprint: fp_a.clone(),
443 fetched_at: 1_000,
444 offerings: vec![row("from-a")],
445 },
446 3_600,
447 );
448 cache.record_success(
449 ProviderCatalogDelta {
450 provider: "acme".into(),
451 base_url_fingerprint: fp_b.clone(),
452 fetched_at: 1_000,
453 offerings: vec![row("from-b")],
454 },
455 3_600,
456 );
457 // Different provider, SAME base URL as fp_a.
458 cache.record_success(
459 ProviderCatalogDelta {
460 provider: "beta".into(),
461 base_url_fingerprint: fp_a.clone(),
462 fetched_at: 1_000,
463 offerings: vec![row("from-beta")],
464 },
465 3_600,
466 );
467
468 let a = cache.fresh_offerings("acme", &fp_a, 1_100);
469 assert_eq!(a.len(), 1);
470 assert_eq!(a[0].wire_model_id, "from-a");
471 // Same provider, different base URL must not leak rows across.
472 let b = cache.fresh_offerings("acme", &fp_b, 1_100);
473 assert_eq!(b[0].wire_model_id, "from-b");
474 // Different provider on the same base URL must not share rows either.
475 let beta = cache.fresh_offerings("beta", &fp_a, 1_100);
476 assert_eq!(beta[0].wire_model_id, "from-beta");
477 assert_eq!(cache.entries.len(), 3);
478 }
479
480 #[test]
481 fn fingerprint_folds_cosmetic_base_url_differences() {
482 let canonical = base_url_fingerprint("https://API.Example.com/v1");
483 assert_eq!(canonical.len(), 64, "endpoint fingerprints use SHA-256");
484 assert_eq!(
485 canonical,
486 base_url_fingerprint("https://api.example.com/v1/"),
487 "trailing slash + host case must not change the cache scope"
488 );
489 assert_eq!(
490 canonical,
491 base_url_fingerprint(" https://api.example.com:443/v1 "),
492 "default https port + surrounding whitespace must fold away"
493 );
494 // Path case is significant (providers can be case-sensitive on the path).
495 assert_ne!(
496 canonical,
497 base_url_fingerprint("https://api.example.com/V1")
498 );
499
500 // Port stripping is scheme-aware: :80 is http's default (folds away), but
501 // :443 on http is a non-default port and must stay distinct from bare http.
502 assert_eq!(
503 base_url_fingerprint("http://h.example.com:80/v1"),
504 base_url_fingerprint("http://h.example.com/v1"),
505 "http default port :80 must fold away"
506 );
507 assert_ne!(
508 base_url_fingerprint("http://h.example.com:443/v1"),
509 base_url_fingerprint("http://h.example.com/v1"),
510 ":443 is not http's default port and must not fold"
511 );
512 }
513
514 #[test]
515 fn fingerprint_never_hashes_secret_bearing_url_text() {
516 let expected = base_url_fingerprint("https://api.example.com/v1");
517 for url in [
518 "https://user:secret@api.example.com/v1",
519 "https://api.example.com/v1?api_key=secret",
520 "https://api.example.com/v1#secret",
521 ] {
522 assert_eq!(base_url_fingerprint(url), expected, "{url}");
523 }
524 }
525
526 #[test]
527 fn fingerprint_strips_userinfo_from_a_scheme_less_base_url() {
528 // A base_url typed without a scheme took the fall-through branch, which
529 // only split off `?`/`#` — so `user:pass@host` went into SHA-256 verbatim,
530 // against the documented "userinfo never enters the digest function".
531 let expected = base_url_fingerprint("api.example.com/v1");
532 for url in [
533 "user:secret@api.example.com/v1",
534 "user:other-secret@api.example.com/v1",
535 "token@api.example.com/v1",
536 ] {
537 assert_eq!(base_url_fingerprint(url), expected, "{url}");
538 }
539 }
540
541 #[test]
542 fn fingerprint_of_an_empty_base_url_is_the_redacted_constant() {
543 // The fall-through's `unwrap_or(REDACTED)` never fired — `split` always
544 // yields at least one (possibly empty) piece — so an empty base URL
545 // fingerprinted the empty string instead of the redacted sentinel.
546 let redacted = base_url_fingerprint("ftp://api.example.com");
547 for url in ["", " ", "?api_key=secret"] {
548 assert_eq!(base_url_fingerprint(url), redacted, "{url:?}");
549 }
550 // SHA-256("") is what empty/whitespace hashed to before the sentinel
551 // mapping. That digest is a persisted cache/receipt key, so flipping it
552 // back would be another undeclared persisted-key change.
553 const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
554 assert_ne!(redacted, EMPTY_SHA256);
555 }
556
557 #[test]
558 fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
559 let fp = base_url_fingerprint("https://api.example.com");
560 let mut cache = ProviderCatalogCache::new();
561 cache.record_success(
562 ProviderCatalogDelta {
563 provider: "acme".into(),
564 base_url_fingerprint: fp.clone(),
565 fetched_at: 1_000,
566 offerings: vec![CatalogOffering {
567 provider: "acme".into(),
568 wire_model_id: "synth-chat-1".into(),
569 endpoint_key: "chat".into(),
570 ..Default::default()
571 }],
572 },
573 100, // ttl
574 );
575
576 // Within TTL: fresh.
577 assert_eq!(cache.status("acme", &fp, 1_050), CatalogStatus::Fresh);
578 assert_eq!(cache.fresh_offerings("acme", &fp, 1_050).len(), 1);
579
580 // Past TTL: stale, and excluded from fresh offerings.
581 match cache.status("acme", &fp, 1_200) {
582 CatalogStatus::Stale { age_secs } => assert_eq!(age_secs, 200),
583 other => panic!("expected stale, got {other:?}"),
584 }
585 assert!(cache.fresh_offerings("acme", &fp, 1_200).is_empty());
586 // But the rows are still present in the cache for explicit fallback display.
587 assert_eq!(cache.get("acme", &fp).unwrap().offerings.len(), 1);
588 }
589
590 #[test]
591 fn ttl_zero_is_always_stale() {
592 let fp = base_url_fingerprint("https://api.example.com");
593 let mut cache = ProviderCatalogCache::new();
594 cache.record_success(
595 ProviderCatalogDelta {
596 provider: "acme".into(),
597 base_url_fingerprint: fp.clone(),
598 fetched_at: 1_000,
599 offerings: vec![],
600 },
601 0,
602 );
603 assert!(cache.get("acme", &fp).unwrap().is_stale(1_000));
604 }
605
606 #[test]
607 fn unknown_scope_reports_unknown_status() {
608 let cache = ProviderCatalogCache::new();
609 let fp = base_url_fingerprint("https://api.example.com");
610 assert_eq!(cache.status("acme", &fp, 1_000), CatalogStatus::Unknown);
611 assert!(cache.fresh_offerings("acme", &fp, 1_000).is_empty());
612 }
613
614 #[test]
615 fn refresh_failure_preserves_prior_rows_and_marks_failed() {
616 let fp = base_url_fingerprint("https://api.example.com");
617 let mut cache = ProviderCatalogCache::new();
618 cache.record_success(
619 ProviderCatalogDelta {
620 provider: "acme".into(),
621 base_url_fingerprint: fp.clone(),
622 fetched_at: 1_000,
623 offerings: vec![CatalogOffering {
624 provider: "acme".into(),
625 wire_model_id: "synth-chat-1".into(),
626 endpoint_key: "chat".into(),
627 ..Default::default()
628 }],
629 },
630 3_600,
631 );
632
633 for reason in [
634 CatalogRefreshError::Unauthorized,
635 CatalogRefreshError::Forbidden,
636 CatalogRefreshError::NotFound,
637 CatalogRefreshError::RateLimited,
638 CatalogRefreshError::InvalidResponse,
639 CatalogRefreshError::EmptyList,
640 CatalogRefreshError::Network,
641 ] {
642 cache.record_failure("acme", &fp, reason);
643 let entry = cache.get("acme", &fp).expect("entry survives failure");
644 // Prior successful rows remain available after a failed refresh.
645 assert_eq!(entry.offerings.len(), 1, "{reason:?} dropped prior rows");
646 assert_eq!(entry.status, CatalogStatus::Failed { reason });
647 // fetched_at is NOT bumped by a failure.
648 assert_eq!(entry.fetched_at, 1_000);
649 // ...but a Failed entry must NOT contribute to fresh offerings even
650 // while still within its TTL window (now=1_100, ttl=3_600). The rows
651 // are reachable only via get() for explicit fallback display.
652 assert!(
653 cache.fresh_offerings("acme", &fp, 1_100).is_empty(),
654 "{reason:?}: failed entry served fresh offerings within TTL"
655 );
656 assert!(cache.all_fresh_offerings(1_100).is_empty());
657 assert_eq!(
658 cache.status("acme", &fp, 1_100),
659 CatalogStatus::Failed { reason }
660 );
661 }
662 }
663
664 #[test]
665 fn failure_without_prior_creates_observable_empty_entry() {
666 let fp = base_url_fingerprint("https://api.example.com");
667 let mut cache = ProviderCatalogCache::new();
668 cache.record_failure("acme", &fp, CatalogRefreshError::Unauthorized);
669
670 let entry = cache.get("acme", &fp).expect("failure is observable");
671 assert!(entry.offerings.is_empty());
672 assert_eq!(
673 entry.status,
674 CatalogStatus::Failed {
675 reason: CatalogRefreshError::Unauthorized
676 }
677 );
678 }
679
680 #[test]
681 fn record_success_stamps_live_provenance_on_rows() {
682 let fp = base_url_fingerprint("https://api.example.com");
683 let mut cache = ProviderCatalogCache::new();
684 // Row arrives mislabeled as Bundled; ingest must normalize provenance.
685 cache.record_success(
686 ProviderCatalogDelta {
687 provider: "acme".into(),
688 base_url_fingerprint: fp.clone(),
689 fetched_at: 4_242,
690 offerings: vec![CatalogOffering {
691 provider: "acme".into(),
692 wire_model_id: "synth-chat-1".into(),
693 endpoint_key: "chat".into(),
694 source: CatalogSource::Bundled,
695 ..Default::default()
696 }],
697 },
698 3_600,
699 );
700 let entry = cache.get("acme", &fp).unwrap();
701 assert_eq!(
702 entry.offerings[0].source,
703 CatalogSource::Live {
704 base_url_fingerprint: fp,
705 fetched_at: 4_242,
706 }
707 );
708 }
709
710 #[test]
711 fn cache_serialization_round_trips_and_contains_no_secrets() {
712 let fp = base_url_fingerprint("https://api.example.com/v1");
713 let mut cache = ProviderCatalogCache::new();
714 cache.record_success(
715 ProviderCatalogDelta {
716 provider: "zhipuai".into(),
717 base_url_fingerprint: fp.clone(),
718 fetched_at: 1_700,
719 offerings: bundled_offerings_from_models_dev(&fixture()),
720 },
721 3_600,
722 );
723
724 let json = serde_json::to_string_pretty(&cache).expect("cache serializes");
725 let round: ProviderCatalogCache = serde_json::from_str(&json).expect("cache round-trips");
726 assert_eq!(round, cache);
727
728 // The persisted shape carries model facts but has no field that could hold
729 // a credential. Guard against a future field reintroducing one.
730 let lower = json.to_lowercase();
731 for needle in [
732 "api_key",
733 "apikey",
734 "api-key",
735 "authorization",
736 "secret",
737 "password",
738 "bearer",
739 "access_token",
740 ] {
741 assert!(
742 !lower.contains(needle),
743 "cache JSON unexpectedly contains `{needle}`"
744 );
745 }
746 // Sanity: it did serialize meaningful provider/model facts.
747 assert!(json.contains("glm-5.2"));
748 assert!(json.contains("base_url_fingerprint"));
749 }
750
751 #[test]
752 fn all_fresh_offerings_spans_providers_and_skips_stale() {
753 let fp = base_url_fingerprint("https://api.example.com");
754 let mut cache = ProviderCatalogCache::new();
755 cache.record_success(
756 ProviderCatalogDelta {
757 provider: "acme".into(),
758 base_url_fingerprint: fp.clone(),
759 fetched_at: 1_000,
760 offerings: vec![CatalogOffering {
761 provider: "acme".into(),
762 wire_model_id: "fresh-row".into(),
763 endpoint_key: "chat".into(),
764 ..Default::default()
765 }],
766 },
767 3_600,
768 );
769 cache.record_success(
770 ProviderCatalogDelta {
771 provider: "beta".into(),
772 base_url_fingerprint: fp.clone(),
773 fetched_at: 0,
774 offerings: vec![CatalogOffering {
775 provider: "beta".into(),
776 wire_model_id: "stale-row".into(),
777 endpoint_key: "chat".into(),
778 ..Default::default()
779 }],
780 },
781 10, // tiny ttl → stale at now=1_100
782 );
783
784 let fresh = cache.all_fresh_offerings(1_100);
785 assert_eq!(fresh.len(), 1);
786 assert_eq!(fresh[0].wire_model_id, "fresh-row");
787
788 // #4139: pickers still see stale rows; only the fresh helper drops them.
789 let visible = cache.all_visible_offerings(1_100);
790 assert_eq!(visible.len(), 2);
791 assert!(visible.iter().any(|row| row.wire_model_id == "fresh-row"));
792 assert!(visible.iter().any(|row| row.wire_model_id == "stale-row"));
793 }
794
795 #[test]
796 fn snapshot_feeds_route_resolver_offerings() {
797 // The compiled snapshot projects into the exact type RouteResolver consumes,
798 // proving catalog rows reach routing only through the offering seam.
799 let snapshot = CatalogCompiler::new().with_models_dev(&fixture()).compile();
800 let offerings = snapshot.to_offerings();
801
802 let glm = offerings
803 .iter()
804 .find(|o| o.provider.as_str() == "zhipuai" && o.wire_model_id.as_str() == "glm-5.2")
805 .expect("GLM offering reaches the route resolver seam");
806 assert_eq!(glm.limits.context_tokens, Some(1_000_000));
807 assert_eq!(glm.limits.output_tokens, Some(131_072));
808 // Audio-only row never becomes a routing offering.
809 assert!(
810 !offerings
811 .iter()
812 .any(|o| o.wire_model_id.as_str() == "glm-voice")
813 );
814 }
815
816 // ---------------------------------------------------------------------------
817 // #3385 / #4188: the committed offline/stale bundled Models.dev asset.
818 // ---------------------------------------------------------------------------
819
820 #[test]
821 fn bundled_asset_parses() {
822 // The committed asset must `include_str!`-load and deserialize into the
823 // parser's `ModelsDevCatalog` shape. This is the build-time guard that keeps
824 // `bundled_models_dev_catalog()` panic-free in shipped builds.
825 let catalog = ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
826 .expect("committed bundled asset must be valid Models.dev JSON");
827 assert!(
828 !catalog.providers.is_empty(),
829 "bundled asset must carry provider rows"
830 );
831 // The helper returns the same parsed catalog.
832 assert_eq!(*bundled_models_dev_catalog(), catalog);
833 }
834
835 #[test]
836 fn bundled_deepseek_flash_routes_support_image_input() {
837 // #6421: the official Vision guide documents deepseek-flash; the pricing
838 // guide maps both legacy Flash names to it (verified 2026-09-23).
839 // https://api-docs.deepseek.com/guides/vision/
840 // https://api-docs.deepseek.com/quick_start/pricing/
841 let rows = bundled_catalog_offerings();
842 for model in [
843 "deepseek-flash",
844 "deepseek-v4-flash",
845 "deepseek-v4-flash-vision-exp",
846 ] {
847 let row = find(&rows, "deepseek", model);
848 assert_eq!(
849 crate::models_dev::image_input_support(row.modalities.as_ref()),
850 crate::route::CapabilityState::Supported,
851 "native DeepSeek route {model} must retain its documented vision capability"
852 );
853 }
854 let text_only = find(&rows, "deepseek", "deepseek-v4-pro");
855 assert_eq!(
856 crate::models_dev::image_input_support(text_only.modalities.as_ref()),
857 crate::route::CapabilityState::Unsupported,
858 "a Flash correction must not widen other routes"
859 );
860 }
861
862 #[test]
863 fn bundled_seed_canonical_entries_use_upstream_keys_and_yield_to_provider_rows() {
864 // #6396 slice B: canonical entries carry upstream `vendor/model` keys. The
865 // xiaomi-mimo provider rows still win offline, and a vendor namespace
866 // never leaks out as its own (non-route) provider.
867 let catalog = bundled_models_dev_catalog();
868 for key in ["xiaomi/mimo-v2.6-pro", "xiaomi/mimo-v2.6-flash"] {
869 assert!(catalog.model(key).is_some(), "{key} canonical entry");
870 }
871 let rows = bundled_catalog_offerings();
872 assert!(
873 rows.iter().all(|row| row.provider != "xiaomi"),
874 "vendor namespace must normalize onto xiaomi-mimo"
875 );
876 for model in ["mimo-v2.6-pro", "mimo-v2.6-flash"] {
877 let row = find(&rows, "xiaomi-mimo", model);
878 assert_eq!(
879 row.canonical_model, None,
880 "{model} comes from the provider row"
881 );
882 // The text-only hold is gone (#6396): upstream lists image input,
883 // and a wrong claim costs one rejected, retried request.
884 assert_eq!(
885 row.to_offering().capabilities.image_input,
886 crate::route::CapabilityState::Supported,
887 "{model} carries upstream's image input"
888 );
889 }
890 }
891
892 #[test]
893 fn bundled_seed_cold_start_joins_namespaced_entries_without_provider_rows() {
894 // #6396: with the provider rows gone, the upstream-shaped canonical
895 // entries alone still put MiMo 2.6 on the xiaomi-mimo route offline.
896 let mut catalog = bundled_models_dev_catalog().clone();
897 let mimo = catalog
898 .providers
899 .get_mut("xiaomi-mimo")
900 .expect("xiaomi-mimo provider");
901 mimo.models.remove("mimo-v2.6-pro");
902 mimo.models.remove("mimo-v2.6-flash");
903 let rows = bundled_offerings_from_models_dev(&catalog);
904 for model in ["mimo-v2.6-pro", "mimo-v2.6-flash"] {
905 let row = find(&rows, "xiaomi-mimo", model);
906 let canonical = format!("xiaomi/{model}");
907 assert_eq!(row.canonical_model.as_deref(), Some(canonical.as_str()));
908 assert_eq!(row.source, CatalogSource::Bundled);
909 assert_eq!(row.reasoning, Some(true));
910 assert_eq!(row.tool_call, Some(true));
911 assert!(row.cost.is_none(), "MiMo stays unpriced offline");
912 assert!(!row.default_for_provider);
913 }
914 }
915
916 /// The bundled Claude row as it read before the seed was generated: text-only.
917 fn stale_text_only_seed_row() -> CatalogOffering {
918 let mut row = find(&bundled_catalog_offerings(), "anthropic", "claude-opus-5").clone();
919 row.modalities = Some(ModelsDevModalities {
920 input: vec!["text".into()],
921 output: vec!["text".into()],
922 });
923 row
924 }
925
926 #[test]
927 fn bundled_text_only_rows_leave_image_input_unknown_not_unsupported() {
928 // #6396: the hand-kept seed listed every Anthropic row as `input: [text]`
929 // while the provider accepts images, and a cold start stripped them. The
930 // seed is generated now, but it still lags providers, so simulate a stale
931 // text-only row: it must not strip the user's images.
932 let seed = &stale_text_only_seed_row();
933 assert_eq!(seed.source, CatalogSource::Bundled);
934 assert_eq!(
935 crate::models_dev::image_input_support(seed.modalities.as_ref()),
936 crate::route::CapabilityState::Unsupported,
937 "fixture premise: the seed row is text-only"
938 );
939 assert_eq!(
940 seed.to_offering().capabilities.image_input,
941 crate::route::CapabilityState::Unknown
942 );
943
944 // The same statement from a live catalog is a real refusal.
945 let live = CatalogOffering {
946 source: CatalogSource::ModelsDevLive { fetched_at: 1 },
947 ..seed.clone()
948 };
949 assert_eq!(
950 live.to_offering().capabilities.image_input,
951 crate::route::CapabilityState::Unsupported
952 );
953
954 // A layer that re-sources the row without restating modalities keeps the
955 // seed's low trust for them.
956 let re_sourced = CatalogOffering {
957 source: CatalogSource::Live {
958 base_url_fingerprint: "fp".into(),
959 fetched_at: 1,
960 },
961 modalities_source: Some(CatalogSource::Bundled),
962 ..seed.clone()
963 };
964 assert_eq!(
965 re_sourced.to_offering().capabilities.image_input,
966 crate::route::CapabilityState::Unknown
967 );
968 }
969
970 #[test]
971 fn offline_resolver_keeps_images_for_a_text_only_seed_row() {
972 let route = crate::route::RouteResolver::new()
973 .resolve(&crate::route::RouteRequest {
974 explicit_provider: Some(crate::ProviderKind::Anthropic),
975 model_selector: Some(crate::route::LogicalModelRef::from("claude-opus-5")),
976 saved_provider_model: None,
977 base_url_override: None,
978 limit_overrides: Vec::new(),
979 })
980 .expect("bundled Anthropic route resolves offline");
981 assert_ne!(
982 route.capabilities().image_input,
983 crate::route::CapabilityState::Unsupported
984 );
985 }
986
987 #[test]
988 fn signed_patch_keeps_the_seed_as_the_modality_authority() {
989 let seed = stale_text_only_seed_row();
990 let key = seed.merge_key();
991 let mut rows = BTreeMap::from([(key.clone(), seed)]);
992 let facts = crate::cloud_facts::ScopedFacts {
993 facts_version: 1,
994 key_id: "cwf-test-only".into(),
995 models: vec![crate::cloud_facts::ModelFact {
996 provider: key.0.clone(),
997 id: key.1.clone(),
998 context_window: Some(9000),
999 ..Default::default()
1000 }],
1001 ..Default::default()
1002 };
1003 crate::cloud_facts::catalog_patch::apply_model_patches(&mut rows, &facts, 1);
1004 let row = &rows[&key];
1005 assert!(matches!(row.source, CatalogSource::CloudFacts { .. }));
1006 assert_eq!(row.modalities_source(), &CatalogSource::Bundled);
1007 assert_eq!(
1008 row.to_offering().capabilities.image_input,
1009 crate::route::CapabilityState::Unknown
1010 );
1011 }
1012
1013 #[test]
1014 fn bundled_asset_meta_describes_offline_fallback_not_competing_truth() {
1015 // #4188: the asset must document itself as offline/stale fallback, not a
1016 // competing curated source of truth alongside live Models.dev.
1017 let raw: serde_json::Value =
1018 serde_json::from_str(BUNDLED_MODELS_DEV_JSON).expect("bundled JSON");
1019 let meta = raw
1020 .get("_meta")
1021 .and_then(|m| m.as_object())
1022 .expect("_meta object");
1023 let role = meta
1024 .get("role")
1025 .and_then(|v| v.as_str())
1026 .unwrap_or_default();
1027 assert!(
1028 role.to_ascii_lowercase().contains("not a competing"),
1029 "_meta.role must demote the bundled asset: {role}"
1030 );
1031 assert!(
1032 role.to_ascii_lowercase().contains("live"),
1033 "_meta.role must point at live Models.dev preference: {role}"
1034 );
1035 }
1036
1037 #[test]
1038 fn bundled_asset_yields_real_chat_offerings_for_key_models() {
1039 let rows = bundled_catalog_offerings();
1040 assert!(
1041 rows.len() >= 20,
1042 "expected dozens of bundled chat offerings, got {}",
1043 rows.len()
1044 );
1045
1046 // A GLM and a Kimi row carry their real (non-default) context windows,
1047 // proving real facts flow rather than `RouteLimits::default()` (unknown).
1048 let glm = find(&rows, "zai", "GLM-5.2");
1049 assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
1050 assert!(
1051 !glm.default_for_provider,
1052 "GLM-5.2 is no longer the Z.ai default"
1053 );
1054
1055 // GLM-5.3 is the Z.ai default (matching DEFAULT_ZAI_MODEL); its limits
1056 // still inherit from glm-5.2 until Z.ai publishes distinct 5.3 numbers.
1057 let glm53 = find(&rows, "zai", "GLM-5.3");
1058 assert_eq!(
1059 glm53.limit.as_ref().and_then(|l| l.context),
1060 glm.limit.as_ref().and_then(|l| l.context)
1061 );
1062 assert_eq!(
1063 glm53.limit.as_ref().and_then(|l| l.output),
1064 glm.limit.as_ref().and_then(|l| l.output)
1065 );
1066 assert!(
1067 glm53.default_for_provider,
1068 "GLM-5.3 must be the Z.ai default"
1069 );
1070
1071 let kimi_k27 = find(&rows, "moonshot", "kimi-k2.7-code");
1072 assert_eq!(
1073 kimi_k27.limit.as_ref().and_then(|l| l.context),
1074 Some(262_144)
1075 );
1076
1077 let kimi_k3 = find(&rows, "moonshot", "kimi-k3");
1078 assert_eq!(
1079 kimi_k3.limit.as_ref().and_then(|l| l.context),
1080 Some(1_048_576)
1081 );
1082 // Upstream's output figure; requests stay capped at 131,072 by the
1083 // compatibility limit table in crates/models (intersected at dispatch).
1084 assert_eq!(
1085 kimi_k3.limit.as_ref().and_then(|l| l.output),
1086 Some(1_048_576)
1087 );
1088 let kimi_k3_input_modalities = kimi_k3
1089 .modalities
1090 .as_ref()
1091 .expect("K3 modalities")
1092 .input
1093 .iter()
1094 .map(String::as_str)
1095 .collect::<Vec<_>>();
1096 assert_eq!(kimi_k3_input_modalities, ["text", "image", "video"]);
1097
1098 let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
1099 assert_eq!(
1100 minimax_m3.limit.as_ref().and_then(|limit| limit.context),
1101 Some(1_000_000)
1102 );
1103 let input_modalities = minimax_m3
1104 .modalities
1105 .as_ref()
1106 .expect("M3 modalities")
1107 .input
1108 .iter()
1109 .map(String::as_str)
1110 .collect::<Vec<_>>();
1111 assert_eq!(input_modalities, ["text", "image", "video"]);
1112 // Codewhale's recorded controls, applied by a correction (#6396).
1113 assert_eq!(
1114 minimax_m3.reasoning_options[0]
1115 .get("default")
1116 .and_then(serde_json::Value::as_str),
1117 Some("disabled")
1118 );
1119
1120 let grok_46 = find(&rows, "xai", "grok-4.6");
1121 assert!(grok_46.default_for_provider);
1122 assert_eq!(
1123 grok_46.limit.as_ref().and_then(|limit| limit.context),
1124 Some(500_000)
1125 );
1126 assert_eq!(grok_46.attachment, Some(true));
1127 assert_eq!(grok_46.structured_output, Some(true));
1128 let grok_input_modalities = grok_46
1129 .modalities
1130 .as_ref()
1131 .expect("Grok 4.6 modalities")
1132 .input
1133 .iter()
1134 .map(String::as_str)
1135 .collect::<Vec<_>>();
1136 assert_eq!(grok_input_modalities, ["text", "image", "pdf"]);
1137 assert_eq!(
1138 grok_46.reasoning_options[0]
1139 .get("default")
1140 .and_then(serde_json::Value::as_str),
1141 Some("high")
1142 );
1143 let grok_45 = find(&rows, "xai", "grok-4.5");
1144 assert_eq!(
1145 grok_45.reasoning_options[0]
1146 .get("default")
1147 .and_then(serde_json::Value::as_str),
1148 Some("high")
1149 );
1150 let grok_45_values = grok_45.reasoning_options[0]
1151 .get("values")
1152 .and_then(serde_json::Value::as_array)
1153 .expect("Grok 4.5 effort values");
1154 assert_eq!(
1155 grok_45_values
1156 .iter()
1157 .filter_map(|value| value.as_str())
1158 .collect::<Vec<_>>(),
1159 ["low", "medium", "high"]
1160 );
1161
1162 let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
1163 assert_eq!(
1164 minimax_m2_7.limit.as_ref().and_then(|limit| limit.context),
1165 Some(204_800)
1166 );
1167 assert_eq!(
1168 minimax_m2_7.reasoning_options[0]
1169 .get("default")
1170 .and_then(serde_json::Value::as_str),
1171 Some("always_on")
1172 );
1173
1174 // Audio/TTS rows are absent (the asset only ships chat models, but assert
1175 // the filter contract anyway).
1176 assert!(
1177 rows.iter().all(|r| !r.wire_model_id.contains("tts")),
1178 "no TTS rows should reach the offering layer"
1179 );
1180 }
1181
1182 #[test]
1183 fn bundled_asset_pricing_is_honest() {
1184 let rows = bundled_catalog_offerings();
1185
1186 // DeepSeek-native rows are intentionally unpriced here (priced via the
1187 // time-aware DeepSeek table elsewhere); pricing them would also break the
1188 // route layer's `unpriced_offering_stays_unknown` invariant.
1189 let deepseek = find(&rows, "deepseek", "deepseek-v4-pro");
1190 assert!(
1191 deepseek.cost.is_none(),
1192 "DeepSeek-native rows must stay unpriced in the bundled asset"
1193 );
1194
1195 // Any row that *does* carry a cost must expose a usable input/output rate
1196 // (the honesty rule: no cache-only / empty cost objects that would render as
1197 // a rate-less Token at the route layer).
1198 for row in &rows {
1199 if let Some(cost) = row.cost.as_ref() {
1200 assert!(
1201 cost.input.is_some() || cost.output.is_some(),
1202 "{}/{}: priced row must have an input or output rate",
1203 row.provider,
1204 row.wire_model_id
1205 );
1206 }
1207 }
1208
1209 // A sampled priced row matches the in-repo USD table (crates/tui pricing):
1210 // GLM-5.1 at the 2026-07-09 Z.ai published rates.
1211 let glm51 = find(&rows, "zai", "glm-5.1");
1212 let cost = glm51.cost.as_ref().expect("glm-5.1 is priced");
1213 assert_eq!(cost.input, Some(1.40));
1214 assert_eq!(cost.output, Some(4.40));
1215 assert_eq!(cost.cache_read, Some(0.26));
1216
1217 // The default Z.ai route is the GLM Coding Plan, which bills credit
1218 // multipliers, so a Codewhale correction keeps GLM-5.3 unpriced there even
1219 // though Models.dev lists a USD rate (#6396).
1220 let glm53 = find(&rows, "zai", "GLM-5.3");
1221 assert!(glm53.cost.is_none());
1222
1223 let glm53_flash = find(&rows, "zai", "GLM-5.3-Flash");
1224 let cost = glm53_flash
1225 .cost
1226 .as_ref()
1227 .expect("GLM-5.3-Flash must ship priced at durable list rates");
1228 assert_eq!(cost.input, Some(0.15));
1229 assert_eq!(cost.output, Some(0.50));
1230 assert_eq!(cost.cache_read, Some(0.03));
1231 assert_eq!(
1232 glm53_flash.limit.as_ref().and_then(|l| l.context),
1233 Some(1_000_000)
1234 );
1235 assert!(
1236 !glm53_flash.default_for_provider,
1237 "GLM-5.3-Flash is a picker row, not the Z.ai default"
1238 );
1239
1240 // OpenRouter qwen3.8-flash lists durable (non-promo) rates on models.dev
1241 // as of 2026-08-26. Unlike GLM-5.3-Flash's explicit 50% promo, this row
1242 // must ship priced. It is not a family default.
1243 let qwen38_flash = find(&rows, "openrouter", "qwen/qwen3.8-flash");
1244 assert!(
1245 !qwen38_flash.default_for_provider,
1246 "qwen3.8-flash is a suffix variant and must not be the OpenRouter default"
1247 );
1248 let cost = qwen38_flash
1249 .cost
1250 .as_ref()
1251 .expect("qwen/qwen3.8-flash must ship priced (durable list rates, no promo)");
1252 // The rates are upstream's, pinned by the seed lock; a re-lock that moves
1253 // them shows up in `seed lock`'s review report, not here.
1254 assert!(cost.input.is_some_and(|rate| rate > 0.0));
1255 assert!(cost.output.is_some_and(|rate| rate > 0.0));
1256 assert_eq!(
1257 qwen38_flash.limit.as_ref().and_then(|l| l.context),
1258 Some(1_000_000)
1259 );
1260 assert_eq!(
1261 qwen38_flash.limit.as_ref().and_then(|l| l.output),
1262 Some(131_072)
1263 );
1264
1265 // M3 has input-length and service tiers that the flat catalog cost shape
1266 // cannot represent, so the bundled route row stays honestly unpriced.
1267 let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
1268 assert!(minimax_m3.cost.is_none());
1269
1270 // Grok 4.6 also has a prompt-length tier, starting at 200K input tokens.
1271 // The usage-aware TUI table prices it; a flat catalog row would underbill.
1272 let grok_46 = find(&rows, "xai", "grok-4.6");
1273 assert!(grok_46.cost.is_none());
1274
1275 let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
1276 let cost = minimax_m2_7.cost.as_ref().expect("M2.7 is priced");
1277 assert_eq!(cost.input, Some(0.30));
1278 assert_eq!(cost.output, Some(1.20));
1279 assert_eq!(cost.cache_read, Some(0.06));
1280 assert_eq!(cost.cache_write, Some(0.375));
1281 }
1282
1283 #[test]
1284 fn live_offerings_normalize_models_dev_provider_aliases() {
1285 // Live Models.dev ids that must map onto CodeWhale kinds (#4186/#4187).
1286 let raw = r#"{
1287 "models": {},
1288 "providers": {
1289 "moonshotai": {
1290 "id": "moonshotai",
1291 "models": {
1292 "kimi-k2.5": {
1293 "id": "kimi-k2.5",
1294 "modalities": { "input": ["text"], "output": ["text"] }
1295 }
1296 }
1297 },
1298 "togetherai": {
1299 "id": "togetherai",
1300 "models": {
1301 "deepseek-ai/DeepSeek-V4-Pro": {
1302 "id": "deepseek-ai/DeepSeek-V4-Pro",
1303 "modalities": { "input": ["text"], "output": ["text"] }
1304 }
1305 }
1306 },
1307 "zhipuai": {
1308 "id": "zhipuai",
1309 "models": {
1310 "glm-5.2": {
1311 "id": "glm-5.2",
1312 "modalities": { "input": ["text"], "output": ["text"] }
1313 }
1314 }
1315 },
1316 "brand-new-gateway": {
1317 "id": "brand-new-gateway",
1318 "models": {
1319 "x-1": {
1320 "id": "x-1",
1321 "modalities": { "input": ["text"], "output": ["text"] }
1322 }
1323 }
1324 }
1325 }
1326 }"#;
1327 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
1328 let rows = live_offerings_from_models_dev(&catalog, 1_700);
1329
1330 // Layer 10, and no endpoint fingerprint: a models.dev row is external
1331 // enrichment about a model, not a provider's statement about an endpoint.
1332 // Stamping `Live` here put every enriched row above the signed layer that
1333 // is supposed to be able to correct it.
1334 assert_eq!(
1335 find(&rows, "moonshot", "kimi-k2.5").source,
1336 CatalogSource::ModelsDevLive { fetched_at: 1_700 }
1337 );
1338 find(&rows, "together", "deepseek-ai/DeepSeek-V4-Pro");
1339 find(&rows, "zai", "glm-5.2");
1340 // Unknown upstream providers keep their Models.dev id.
1341 find(&rows, "brand-new-gateway", "x-1");
1342 assert!(rows.iter().all(|r| r.provider != "moonshotai"));
1343 assert!(rows.iter().all(|r| r.provider != "togetherai"));
1344 assert!(rows.iter().all(|r| r.provider != "zhipuai"));
1345 }
1346
1347 fn offering(provider: &str, wire: &str, source: CatalogSource) -> CatalogOffering {
1348 CatalogOffering {
1349 provider: provider.to_string(),
1350 wire_model_id: wire.to_string(),
1351 endpoint_key: "chat".to_string(),
1352 source,
1353 ..CatalogOffering::default()
1354 }
1355 }
1356
1357 /// The layer-25 "signed CWC catalog" this test used to pin is gone: it never
1358 /// had a fetcher, and signed cloud facts (layer 15, under the provider roster)
1359 /// is the client's one online catalog authority. What still has to hold on the
1360 /// same wire is that the provider's own roster outranks models.dev.
1361 #[test]
1362 fn provider_live_beats_models_dev_on_the_same_wire() {
1363 let snapshot = CatalogCompiler::new()
1364 .with_bundled(vec![offering(
1365 "command-code",
1366 "deepseek/deepseek-v4-flash",
1367 CatalogSource::Bundled,
1368 )])
1369 .with_models_dev_live(vec![offering(
1370 "command-code",
1371 "deepseek/deepseek-v4-flash",
1372 CatalogSource::ModelsDevLive { fetched_at: 1 },
1373 )])
1374 .with_provider_live(vec![offering(
1375 "command-code",
1376 "deepseek/deepseek-v4-flash",
1377 CatalogSource::Live {
1378 base_url_fingerprint: "fixture".into(),
1379 fetched_at: 2,
1380 },
1381 )])
1382 .compile();
1383 let row = find(
1384 &snapshot.offerings,
1385 "command-code",
1386 "deepseek/deepseek-v4-flash",
1387 );
1388 assert!(matches!(
1389 row.source,
1390 CatalogSource::Live { ref base_url_fingerprint, fetched_at: 2 }
1391 if base_url_fingerprint == "fixture"
1392 ));
1393 }
1394
1395 #[test]
1396 fn endpoint_is_baseten_recognizes_the_host_not_the_spelling() {
1397 assert!(endpoint_is_baseten(BASETEN_BASE_URL));
1398 assert!(endpoint_is_baseten(&format!("{BASETEN_BASE_URL}/")));
1399 assert!(endpoint_is_baseten("HTTPS://INFERENCE.BASETEN.CO/v1"));
1400 assert!(!endpoint_is_baseten("https://api.groq.com/openai/v1"));
1401 assert!(!endpoint_is_baseten("https://127.0.0.1:9/v1"));
1402 assert!(!endpoint_is_baseten(""));
1403 }
1404
1405 #[test]
1406 fn stepfun_bundled_coding_models_preserve_default_and_plan_pricing_boundary() {
1407 let rows: Vec<_> = bundled_catalog_offerings()
1408 .into_iter()
1409 .filter(|row| row.provider == "stepfun")
1410 .collect();
1411 assert_eq!(rows.len(), 4);
1412 assert_eq!(
1413 rows.iter()
1414 .find(|row| row.default_for_provider)
1415 .unwrap()
1416 .wire_model_id,
1417 "step-3.7-flash"
1418 );
1419 for row in &rows {
1420 assert_eq!(row.reasoning, Some(true));
1421 assert_eq!(row.tool_call, Some(true));
1422 assert!(row.cost.is_none(), "Step Plan shares ids, not PAYG billing");
1423 assert_eq!(row.modalities.as_ref().unwrap().output, ["text"]);
1424 }
1425 let step5 = rows
1426 .iter()
1427 .find(|row| row.wire_model_id == "step-5-preview")
1428 .unwrap();
1429 assert_eq!(step5.limit.as_ref().unwrap().context, Some(1_000_000));
1430 // Models.dev lists 65,536; the old seed said 1,000,000. The lower bound is
1431 // the one a request can always use (#6396).
1432 assert_eq!(step5.limit.as_ref().unwrap().output, Some(65_536));
1433 assert_eq!(
1434 step5.modalities.as_ref().unwrap().input,
1435 ["text", "image", "video"]
1436 );
1437 assert_eq!(
1438 step5.reasoning_options[0]["values"],
1439 serde_json::json!(["low", "medium", "high"])
1440 );
1441 let march = rows
1442 .iter()
1443 .find(|row| row.wire_model_id == "step-3.5-flash-2603")
1444 .unwrap();
1445 assert_eq!(
1446 march.reasoning_options[0]["values"],
1447 serde_json::json!(["low", "high"])
1448 );
1449 assert_eq!(march.limit.as_ref().unwrap().output, Some(256_000));
1450 }
1451
1452 // ---- Codewhale corrections (#6396) -------------------------------------
1453
1454 const LIVE_CORRECTION_FIXTURE: &str = r#"{
1455 "providers": {
1456 "xiaomi": {
1457 "id": "xiaomi",
1458 "models": {
1459 "mimo-v2.6-pro": {
1460 "id": "mimo-v2.6-pro",
1461 "modalities": { "input": ["text", "image"], "output": ["text"] },
1462 "limit": { "context": 1048576, "output": 131072 },
1463 "cost": { "input": 0.435, "output": 0.87, "cache_read": 0.0036 }
1464 }
1465 }
1466 },
1467 "alibaba-token-plan": {
1468 "id": "alibaba-token-plan",
1469 "models": {
1470 "qwen3.8-max": {
1471 "id": "qwen3.8-max",
1472 "limit": { "context": 1000000, "output": 131072 },
1473 "cost": { "input": 0, "output": 0, "cache_read": 0, "cache_write": 0 }
1474 }
1475 }
1476 },
1477 "deepseek": {
1478 "id": "deepseek",
1479 "models": {
1480 "deepseek-v4-pro": {
1481 "id": "deepseek-v4-pro",
1482 "limit": { "context": 1000000, "output": 393216 },
1483 "cost": { "input": 0.435, "output": 0.87 }
1484 }
1485 }
1486 },
1487 "anthropic": {
1488 "id": "anthropic",
1489 "models": {
1490 "claude-opus-5": {
1491 "id": "claude-opus-5",
1492 "limit": { "context": 1000000, "output": 128000 },
1493 "cost": { "input": 5, "output": 25 }
1494 }
1495 }
1496 }
1497 }
1498 }"#;
1499
1500 fn live_corrected_rows() -> Vec<CatalogOffering> {
1501 let catalog = ModelsDevCatalog::parse_json(LIVE_CORRECTION_FIXTURE).expect("fixture parses");
1502 live_offerings_from_models_dev(&catalog, 1_700_000_000)
1503 }
1504
1505 fn codewhale_source() -> CatalogSource {
1506 CatalogSource::CodewhaleBundled {
1507 revision: corrections::bundled_corrections().revision.clone(),
1508 }
1509 }
1510
1511 #[test]
1512 fn committed_corrections_parse_and_name_rows_codewhale_carries() {
1513 let parsed = corrections::CatalogCorrections::parse(corrections::CATALOG_CORRECTIONS_JSON)
1514 .expect("committed corrections are valid");
1515 let seed = bundled_offerings_from_models_dev(bundled_models_dev_catalog());
1516 for rule in &parsed.providers {
1517 assert!(
1518 seed.iter().any(|row| row.provider == rule.provider),
1519 "provider rule {} names no seed provider",
1520 rule.provider
1521 );
1522 }
1523 for correction in &parsed.models {
1524 let fact = &correction.fact;
1525 find(&seed, &fact.provider, &fact.id);
1526 }
1527 }
1528
1529 #[test]
1530 fn live_refresh_cannot_undo_a_withheld_price() {
1531 let rows = live_corrected_rows();
1532 for (provider, model) in [
1533 ("xiaomi-mimo", "mimo-v2.6-pro"),
1534 ("modelstudio-token-plan", "qwen3.8-max"),
1535 ("deepseek", "deepseek-v4-pro"),
1536 ] {
1537 let row = find(&rows, provider, model);
1538 assert_eq!(row.cost, None, "{provider}/{model} keeps no flat price");
1539 assert!(
1540 matches!(row.source, CatalogSource::ModelsDevLive { .. }),
1541 "a corrected row stays on its own layer"
1542 );
1543 assert_eq!(row.pricing_source(), &codewhale_source());
1544 assert_eq!(
1545 row.to_offering().pricing,
1546 crate::route::PricingSku::UnknownOrStale,
1547 "{provider}/{model} must read as unknown, never as a rate or free"
1548 );
1549 }
1550 // Facts the correction does not touch stay the live layer's.
1551 let mimo = find(&rows, "xiaomi-mimo", "mimo-v2.6-pro");
1552 assert_eq!(
1553 mimo.modalities_source(),
1554 &CatalogSource::ModelsDevLive {
1555 fetched_at: 1_700_000_000
1556 }
1557 );
1558 assert_eq!(
1559 mimo.to_offering().capabilities.image_input,
1560 crate::route::CapabilityState::Supported
1561 );
1562 assert_eq!(mimo.limit.as_ref().and_then(|l| l.context), Some(1_048_576));
1563
1564 // Rows no correction names are untouched.
1565 let claude = find(&rows, "anthropic", "claude-opus-5");
1566 assert!(matches!(claude.source, CatalogSource::ModelsDevLive { .. }));
1567 assert_eq!(claude.cost.as_ref().and_then(|c| c.input), Some(5.0));
1568 }
1569
1570 #[test]
1571 fn deepseek_output_limit_correction_holds_live_and_yields_to_signed_facts() {
1572 let rows = live_corrected_rows();
1573 let row = find(&rows, "deepseek", "deepseek-v4-pro").clone();
1574 assert_eq!(row.limit.as_ref().and_then(|l| l.output), Some(384_000));
1575 assert_eq!(row.to_offering().limits.output_tokens, Some(384_000));
1576
1577 let key = row.merge_key();
1578 let mut map = BTreeMap::from([(key.clone(), row)]);
1579 let facts = crate::cloud_facts::ScopedFacts {
1580 facts_version: 1,
1581 key_id: "cwf-test-only".into(),
1582 models: vec![crate::cloud_facts::ModelFact {
1583 provider: key.0.clone(),
1584 id: key.1.clone(),
1585 max_output: Some(393_216),
1586 pricing: Some(crate::cloud_facts::PricingFact {
1587 input_per_m: Some(0.435),
1588 output_per_m: Some(0.87),
1589 ..Default::default()
1590 }),
1591 ..Default::default()
1592 }],
1593 ..Default::default()
1594 };
1595 crate::cloud_facts::catalog_patch::apply_model_patches(&mut map, &facts, 1);
1596 let signed = &map[&key];
1597 assert!(matches!(signed.source, CatalogSource::CloudFacts { .. }));
1598 assert_eq!(signed.limit.as_ref().and_then(|l| l.output), Some(393_216));
1599 assert!(matches!(
1600 signed.to_offering().pricing,
1601 crate::route::PricingSku::Token { .. }
1602 ));
1603 }
1604
1605 #[test]
1606 fn offline_seed_rows_carry_the_same_corrections() {
1607 let rows = bundled_catalog_offerings();
1608 let deepseek = find(&rows, "deepseek", "deepseek-v4-pro");
1609 assert_eq!(deepseek.source, CatalogSource::Bundled);
1610 assert_eq!(deepseek.pricing_source(), &codewhale_source());
1611 assert_eq!(deepseek.cost, None);
1612 assert_eq!(
1613 deepseek.limit.as_ref().and_then(|l| l.output),
1614 Some(384_000)
1615 );
1616 // The seed still owns the modality statement, so its low trust holds.
1617 assert_eq!(deepseek.modalities_source(), &CatalogSource::Bundled);
1618 assert_eq!(deepseek.modalities_source, None);
1619
1620 let claude = find(&rows, "anthropic", "claude-opus-5");
1621 assert_eq!(claude.source, CatalogSource::Bundled);
1622 }
1623
1624 #[test]
1625 fn corrections_never_add_or_hide_rows() {
1626 let parsed = corrections::CatalogCorrections::parse(
1627 r#"{
1628 "revision": "test",
1629 "models": [
1630 { "provider": "nobody", "id": "ghost", "context_window": 9000, "reason": "r" }
1631 ]
1632 }"#,
1633 )
1634 .expect("valid");
1635 let mut rows = bundled_offerings_from_models_dev(bundled_models_dev_catalog());
1636 let before = rows.len();
1637 parsed.apply_to(&mut rows);
1638 assert_eq!(rows.len(), before);
1639 assert!(!rows.iter().any(|row| row.wire_model_id == "ghost"));
1640
1641 for (json, why) in [
1642 (
1643 r#"{"revision":"t","models":[{"provider":"p","id":"m","op":"hide","pricing_withheld":"x"}]}"#,
1644 "hide",
1645 ),
1646 (
1647 r#"{"revision":"t","models":[{"provider":"p","id":"m","allow_unlisted":true,"context_window":1,"reason":"x"}]}"#,
1648 "allow_unlisted",
1649 ),
1650 (
1651 r#"{"revision":"t","models":[{"provider":"p","id":"m","max_output":1}]}"#,
1652 "missing reason",
1653 ),
1654 (
1655 r#"{"revision":"t","models":[{"provider":"p","id":"m","reason":"x"}]}"#,
1656 "changes nothing",
1657 ),
1658 (
1659 r#"{"revision":"t","models":[{"provider":"p","id":"m","note":"n","pricing_withheld":"x"}]}"#,
1660 "annotation",
1661 ),
1662 (
1663 r#"{"revision":"t","providers":[{"provider":"p","pricing_withheld":""}]}"#,
1664 "empty provider reason",
1665 ),
1666 (r#"{"revision":"","models":[]}"#, "empty revision"),
1667 ] {
1668 assert!(
1669 corrections::CatalogCorrections::parse(json).is_err(),
1670 "{why} must be refused"
1671 );
1672 }
1673 }
1674
1675 #[test]
1676 fn pricing_withheld_round_trips_and_older_payloads_still_parse() {
1677 let fact = crate::cloud_facts::ModelFact {
1678 provider: "p".into(),
1679 id: "m".into(),
1680 pricing_withheld: Some("tiered".into()),
1681 ..Default::default()
1682 };
1683 let json = serde_json::to_string(&fact).expect("serializes");
1684 assert!(json.contains("pricing_withheld"));
1685 let back: crate::cloud_facts::ModelFact = serde_json::from_str(&json).expect("parses");
1686 assert_eq!(back, fact);
1687
1688 let older: crate::cloud_facts::ModelFact =
1689 serde_json::from_str(r#"{"provider":"p","id":"m","context_window":9000}"#)
1690 .expect("payload without the field parses");
1691 assert_eq!(older.pricing_withheld, None);
1692 let plain = serde_json::to_string(&older).expect("serializes");
1693 assert!(!plain.contains("pricing_withheld"));
1694 }
1695
1696 #[test]
1697 fn reasoning_controls_correction_holds_on_a_live_row() {
1698 let catalog = ModelsDevCatalog::parse_json(
1699 r#"{
1700 "providers": {
1701 "xai": {
1702 "id": "xai",
1703 "models": {
1704 "grok-4.6": {
1705 "id": "grok-4.6",
1706 "reasoning": true,
1707 "reasoning_options": [
1708 { "type": "effort", "values": ["low", "medium", "high", "xhigh"] }
1709 ]
1710 }
1711 }
1712 }
1713 }
1714 }"#,
1715 )
1716 .expect("fixture parses");
1717 let rows = live_offerings_from_models_dev(&catalog, 1);
1718 let grok = find(&rows, "xai", "grok-4.6");
1719 assert_eq!(
1720 grok.reasoning_options[0]
1721 .get("default")
1722 .and_then(serde_json::Value::as_str),
1723 Some("high"),
1724 "Codewhale's documented default survives a live refresh"
1725 );
1726 assert!(matches!(grok.source, CatalogSource::ModelsDevLive { .. }));
1727
1728 // A signed annotation already on the row is kept alongside the controls.
1729 let mut row = grok.clone();
1730 row.reasoning_options
1731 .push(serde_json::json!({ "cloud_facts": { "op": "upsert" } }));
1732 let key = row.merge_key();
1733 let mut map = BTreeMap::from([(key.clone(), row)]);
1734 let options = vec![serde_json::json!({ "type": "effort", "values": ["high"] })];
1735 crate::cloud_facts::catalog_patch::apply_model_patches(
1736 &mut map,
1737 &crate::cloud_facts::ScopedFacts {
1738 facts_version: 1,
1739 key_id: "cwf-test-only".into(),
1740 models: vec![crate::cloud_facts::ModelFact {
1741 provider: key.0.clone(),
1742 id: key.1.clone(),
1743 reasoning_options: Some(options.clone()),
1744 ..Default::default()
1745 }],
1746 ..Default::default()
1747 },
1748 1,
1749 );
1750 let patched = &map[&key].reasoning_options;
1751 assert_eq!(patched[0], options[0]);
1752 assert!(
1753 patched
1754 .iter()
1755 .any(|value| value.get("cloud_facts").is_some())
1756 );
1757
1758 assert!(
1759 corrections::CatalogCorrections::parse(
1760 r#"{"revision":"t","models":[{"provider":"p","id":"m","reasoning_options":[]}]}"#
1761 )
1762 .is_err(),
1763 "a reasoning correction needs a reason"
1764 );
1765 }
1766
1766 lines RUST