返回 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 to_offering_projects_routing_identity_and_limits() {
109 let rows = bundled_offerings_from_models_dev(&fixture());
110 let glm = find(&rows, "zhipuai", "glm-5.2").to_offering();
111
112 assert_eq!(glm.provider.as_str(), "zhipuai");
113 assert_eq!(glm.wire_model_id.as_str(), "glm-5.2");
114 assert_eq!(glm.canonical_model, None);
115 assert_eq!(glm.endpoint_key, "chat");
116 assert_eq!(glm.limits.context_tokens, Some(1_000_000));
117 assert_eq!(glm.limits.output_tokens, Some(131_072));
118 assert_eq!(
119 glm.capabilities.attachments,
120 crate::route::CapabilityState::Unsupported
121 );
122 assert_eq!(
123 glm.capabilities.reasoning,
124 crate::route::CapabilityState::Supported
125 );
126 assert_eq!(
127 glm.capabilities.native_tool_calls,
128 crate::route::CapabilityState::Supported
129 );
130 assert_eq!(
131 glm.capabilities.structured_output,
132 crate::route::CapabilityState::Supported
133 );
134 assert_eq!(
135 glm.capabilities.streaming,
136 crate::route::CapabilityState::Unknown
137 );
138 }
139
140 #[test]
141 fn compiler_merges_layers_with_override_precedence() {
142 // Bundled default for synthetic provider "acme".
143 let bundled = vec![CatalogOffering {
144 provider: "acme".into(),
145 wire_model_id: "synth-chat-1".into(),
146 endpoint_key: "chat".into(),
147 default_for_provider: true,
148 family: Some("synth".into()),
149 source: CatalogSource::Bundled,
150 ..Default::default()
151 }];
152 // Live refresh adds a new row AND restates the bundled one with a cost.
153 let live = vec![
154 CatalogOffering {
155 provider: "acme".into(),
156 wire_model_id: "synth-chat-1".into(),
157 endpoint_key: "chat".into(),
158 cost: Some(ModelsDevCost {
159 input: Some(2.0),
160 ..Default::default()
161 }),
162 source: CatalogSource::Live {
163 base_url_fingerprint: "fp".into(),
164 fetched_at: 100,
165 },
166 ..Default::default()
167 },
168 CatalogOffering {
169 provider: "acme".into(),
170 wire_model_id: "synth-chat-2".into(),
171 endpoint_key: "chat".into(),
172 source: CatalogSource::Live {
173 base_url_fingerprint: "fp".into(),
174 fetched_at: 100,
175 },
176 ..Default::default()
177 },
178 ];
179 // User override pins a custom canonical model on synth-chat-1.
180 let overrides = vec![CatalogOffering {
181 provider: "acme".into(),
182 wire_model_id: "synth-chat-1".into(),
183 canonical_model: Some("acme-canonical".into()),
184 endpoint_key: "chat".into(),
185 source: CatalogSource::UserOverride,
186 ..Default::default()
187 }];
188
189 let snapshot = CatalogCompiler::new()
190 .with_bundled(bundled)
191 .with_live(live)
192 .with_overrides(overrides)
193 .compile();
194
195 // Two distinct (provider, wire) identities survive de-duplication.
196 assert_eq!(snapshot.offerings.len(), 2);
197
198 let one = find(&snapshot.offerings, "acme", "synth-chat-1");
199 // Highest-precedence layer (override) wins the identity collision.
200 assert_eq!(one.source, CatalogSource::UserOverride);
201 assert_eq!(one.canonical_model.as_deref(), Some("acme-canonical"));
202
203 let two = find(&snapshot.offerings, "acme", "synth-chat-2");
204 assert!(matches!(two.source, CatalogSource::Live { .. }));
205 }
206
207 #[test]
208 fn cache_scopes_by_provider_and_base_url_fingerprint() {
209 let fp_a = base_url_fingerprint("https://api.example.com/v1");
210 let fp_b = base_url_fingerprint("https://other.example.com/v1");
211 assert_ne!(fp_a, fp_b, "different hosts must not share a fingerprint");
212
213 let mut cache = ProviderCatalogCache::new();
214 let row = |id: &str| CatalogOffering {
215 provider: "acme".into(),
216 wire_model_id: id.into(),
217 endpoint_key: "chat".into(),
218 ..Default::default()
219 };
220
221 // Same provider, two different base URLs.
222 cache.record_success(
223 ProviderCatalogDelta {
224 provider: "acme".into(),
225 base_url_fingerprint: fp_a.clone(),
226 fetched_at: 1_000,
227 offerings: vec![row("from-a")],
228 },
229 3_600,
230 );
231 cache.record_success(
232 ProviderCatalogDelta {
233 provider: "acme".into(),
234 base_url_fingerprint: fp_b.clone(),
235 fetched_at: 1_000,
236 offerings: vec![row("from-b")],
237 },
238 3_600,
239 );
240 // Different provider, SAME base URL as fp_a.
241 cache.record_success(
242 ProviderCatalogDelta {
243 provider: "beta".into(),
244 base_url_fingerprint: fp_a.clone(),
245 fetched_at: 1_000,
246 offerings: vec![row("from-beta")],
247 },
248 3_600,
249 );
250
251 let a = cache.fresh_offerings("acme", &fp_a, 1_100);
252 assert_eq!(a.len(), 1);
253 assert_eq!(a[0].wire_model_id, "from-a");
254 // Same provider, different base URL must not leak rows across.
255 let b = cache.fresh_offerings("acme", &fp_b, 1_100);
256 assert_eq!(b[0].wire_model_id, "from-b");
257 // Different provider on the same base URL must not share rows either.
258 let beta = cache.fresh_offerings("beta", &fp_a, 1_100);
259 assert_eq!(beta[0].wire_model_id, "from-beta");
260 assert_eq!(cache.entries.len(), 3);
261 }
262
263 #[test]
264 fn fingerprint_folds_cosmetic_base_url_differences() {
265 let canonical = base_url_fingerprint("https://API.Example.com/v1");
266 assert_eq!(canonical.len(), 64, "endpoint fingerprints use SHA-256");
267 assert_eq!(
268 canonical,
269 base_url_fingerprint("https://api.example.com/v1/"),
270 "trailing slash + host case must not change the cache scope"
271 );
272 assert_eq!(
273 canonical,
274 base_url_fingerprint(" https://api.example.com:443/v1 "),
275 "default https port + surrounding whitespace must fold away"
276 );
277 // Path case is significant (providers can be case-sensitive on the path).
278 assert_ne!(
279 canonical,
280 base_url_fingerprint("https://api.example.com/V1")
281 );
282
283 // Port stripping is scheme-aware: :80 is http's default (folds away), but
284 // :443 on http is a non-default port and must stay distinct from bare http.
285 assert_eq!(
286 base_url_fingerprint("http://h.example.com:80/v1"),
287 base_url_fingerprint("http://h.example.com/v1"),
288 "http default port :80 must fold away"
289 );
290 assert_ne!(
291 base_url_fingerprint("http://h.example.com:443/v1"),
292 base_url_fingerprint("http://h.example.com/v1"),
293 ":443 is not http's default port and must not fold"
294 );
295 }
296
297 #[test]
298 fn fingerprint_never_hashes_secret_bearing_url_text() {
299 let expected = base_url_fingerprint("https://api.example.com/v1");
300 for url in [
301 "https://user:secret@api.example.com/v1",
302 "https://api.example.com/v1?api_key=secret",
303 "https://api.example.com/v1#secret",
304 ] {
305 assert_eq!(base_url_fingerprint(url), expected, "{url}");
306 }
307 }
308
309 #[test]
310 fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
311 let fp = base_url_fingerprint("https://api.example.com");
312 let mut cache = ProviderCatalogCache::new();
313 cache.record_success(
314 ProviderCatalogDelta {
315 provider: "acme".into(),
316 base_url_fingerprint: fp.clone(),
317 fetched_at: 1_000,
318 offerings: vec![CatalogOffering {
319 provider: "acme".into(),
320 wire_model_id: "synth-chat-1".into(),
321 endpoint_key: "chat".into(),
322 ..Default::default()
323 }],
324 },
325 100, // ttl
326 );
327
328 // Within TTL: fresh.
329 assert_eq!(cache.status("acme", &fp, 1_050), CatalogStatus::Fresh);
330 assert_eq!(cache.fresh_offerings("acme", &fp, 1_050).len(), 1);
331
332 // Past TTL: stale, and excluded from fresh offerings.
333 match cache.status("acme", &fp, 1_200) {
334 CatalogStatus::Stale { age_secs } => assert_eq!(age_secs, 200),
335 other => panic!("expected stale, got {other:?}"),
336 }
337 assert!(cache.fresh_offerings("acme", &fp, 1_200).is_empty());
338 // But the rows are still present in the cache for explicit fallback display.
339 assert_eq!(cache.get("acme", &fp).unwrap().offerings.len(), 1);
340 }
341
342 #[test]
343 fn ttl_zero_is_always_stale() {
344 let fp = base_url_fingerprint("https://api.example.com");
345 let mut cache = ProviderCatalogCache::new();
346 cache.record_success(
347 ProviderCatalogDelta {
348 provider: "acme".into(),
349 base_url_fingerprint: fp.clone(),
350 fetched_at: 1_000,
351 offerings: vec![],
352 },
353 0,
354 );
355 assert!(cache.get("acme", &fp).unwrap().is_stale(1_000));
356 }
357
358 #[test]
359 fn unknown_scope_reports_unknown_status() {
360 let cache = ProviderCatalogCache::new();
361 let fp = base_url_fingerprint("https://api.example.com");
362 assert_eq!(cache.status("acme", &fp, 1_000), CatalogStatus::Unknown);
363 assert!(cache.fresh_offerings("acme", &fp, 1_000).is_empty());
364 }
365
366 #[test]
367 fn refresh_failure_preserves_prior_rows_and_marks_failed() {
368 let fp = base_url_fingerprint("https://api.example.com");
369 let mut cache = ProviderCatalogCache::new();
370 cache.record_success(
371 ProviderCatalogDelta {
372 provider: "acme".into(),
373 base_url_fingerprint: fp.clone(),
374 fetched_at: 1_000,
375 offerings: vec![CatalogOffering {
376 provider: "acme".into(),
377 wire_model_id: "synth-chat-1".into(),
378 endpoint_key: "chat".into(),
379 ..Default::default()
380 }],
381 },
382 3_600,
383 );
384
385 for reason in [
386 CatalogRefreshError::Unauthorized,
387 CatalogRefreshError::Forbidden,
388 CatalogRefreshError::NotFound,
389 CatalogRefreshError::RateLimited,
390 CatalogRefreshError::InvalidResponse,
391 CatalogRefreshError::EmptyList,
392 CatalogRefreshError::Network,
393 ] {
394 cache.record_failure("acme", &fp, reason);
395 let entry = cache.get("acme", &fp).expect("entry survives failure");
396 // Prior successful rows remain available after a failed refresh.
397 assert_eq!(entry.offerings.len(), 1, "{reason:?} dropped prior rows");
398 assert_eq!(entry.status, CatalogStatus::Failed { reason });
399 // fetched_at is NOT bumped by a failure.
400 assert_eq!(entry.fetched_at, 1_000);
401 // ...but a Failed entry must NOT contribute to fresh offerings even
402 // while still within its TTL window (now=1_100, ttl=3_600). The rows
403 // are reachable only via get() for explicit fallback display.
404 assert!(
405 cache.fresh_offerings("acme", &fp, 1_100).is_empty(),
406 "{reason:?}: failed entry served fresh offerings within TTL"
407 );
408 assert!(cache.all_fresh_offerings(1_100).is_empty());
409 assert_eq!(
410 cache.status("acme", &fp, 1_100),
411 CatalogStatus::Failed { reason }
412 );
413 }
414 }
415
416 #[test]
417 fn failure_without_prior_creates_observable_empty_entry() {
418 let fp = base_url_fingerprint("https://api.example.com");
419 let mut cache = ProviderCatalogCache::new();
420 cache.record_failure("acme", &fp, CatalogRefreshError::Unauthorized);
421
422 let entry = cache.get("acme", &fp).expect("failure is observable");
423 assert!(entry.offerings.is_empty());
424 assert_eq!(
425 entry.status,
426 CatalogStatus::Failed {
427 reason: CatalogRefreshError::Unauthorized
428 }
429 );
430 }
431
432 #[test]
433 fn record_success_stamps_live_provenance_on_rows() {
434 let fp = base_url_fingerprint("https://api.example.com");
435 let mut cache = ProviderCatalogCache::new();
436 // Row arrives mislabeled as Bundled; ingest must normalize provenance.
437 cache.record_success(
438 ProviderCatalogDelta {
439 provider: "acme".into(),
440 base_url_fingerprint: fp.clone(),
441 fetched_at: 4_242,
442 offerings: vec![CatalogOffering {
443 provider: "acme".into(),
444 wire_model_id: "synth-chat-1".into(),
445 endpoint_key: "chat".into(),
446 source: CatalogSource::Bundled,
447 ..Default::default()
448 }],
449 },
450 3_600,
451 );
452 let entry = cache.get("acme", &fp).unwrap();
453 assert_eq!(
454 entry.offerings[0].source,
455 CatalogSource::Live {
456 base_url_fingerprint: fp,
457 fetched_at: 4_242,
458 }
459 );
460 }
461
462 #[test]
463 fn cache_serialization_round_trips_and_contains_no_secrets() {
464 let fp = base_url_fingerprint("https://api.example.com/v1");
465 let mut cache = ProviderCatalogCache::new();
466 cache.record_success(
467 ProviderCatalogDelta {
468 provider: "zhipuai".into(),
469 base_url_fingerprint: fp.clone(),
470 fetched_at: 1_700,
471 offerings: bundled_offerings_from_models_dev(&fixture()),
472 },
473 3_600,
474 );
475
476 let json = serde_json::to_string_pretty(&cache).expect("cache serializes");
477 let round: ProviderCatalogCache = serde_json::from_str(&json).expect("cache round-trips");
478 assert_eq!(round, cache);
479
480 // The persisted shape carries model facts but has no field that could hold
481 // a credential. Guard against a future field reintroducing one.
482 let lower = json.to_lowercase();
483 for needle in [
484 "api_key",
485 "apikey",
486 "api-key",
487 "authorization",
488 "secret",
489 "password",
490 "bearer",
491 "access_token",
492 ] {
493 assert!(
494 !lower.contains(needle),
495 "cache JSON unexpectedly contains `{needle}`"
496 );
497 }
498 // Sanity: it did serialize meaningful provider/model facts.
499 assert!(json.contains("glm-5.2"));
500 assert!(json.contains("base_url_fingerprint"));
501 }
502
503 #[test]
504 fn all_fresh_offerings_spans_providers_and_skips_stale() {
505 let fp = base_url_fingerprint("https://api.example.com");
506 let mut cache = ProviderCatalogCache::new();
507 cache.record_success(
508 ProviderCatalogDelta {
509 provider: "acme".into(),
510 base_url_fingerprint: fp.clone(),
511 fetched_at: 1_000,
512 offerings: vec![CatalogOffering {
513 provider: "acme".into(),
514 wire_model_id: "fresh-row".into(),
515 endpoint_key: "chat".into(),
516 ..Default::default()
517 }],
518 },
519 3_600,
520 );
521 cache.record_success(
522 ProviderCatalogDelta {
523 provider: "beta".into(),
524 base_url_fingerprint: fp.clone(),
525 fetched_at: 0,
526 offerings: vec![CatalogOffering {
527 provider: "beta".into(),
528 wire_model_id: "stale-row".into(),
529 endpoint_key: "chat".into(),
530 ..Default::default()
531 }],
532 },
533 10, // tiny ttl → stale at now=1_100
534 );
535
536 let fresh = cache.all_fresh_offerings(1_100);
537 assert_eq!(fresh.len(), 1);
538 assert_eq!(fresh[0].wire_model_id, "fresh-row");
539
540 // #4139: pickers still see stale rows; only the fresh helper drops them.
541 let visible = cache.all_visible_offerings(1_100);
542 assert_eq!(visible.len(), 2);
543 assert!(visible.iter().any(|row| row.wire_model_id == "fresh-row"));
544 assert!(visible.iter().any(|row| row.wire_model_id == "stale-row"));
545 }
546
547 #[test]
548 fn snapshot_feeds_route_resolver_offerings() {
549 // The compiled snapshot projects into the exact type RouteResolver consumes,
550 // proving catalog rows reach routing only through the offering seam.
551 let snapshot = CatalogCompiler::new().with_models_dev(&fixture()).compile();
552 let offerings = snapshot.to_offerings();
553
554 let glm = offerings
555 .iter()
556 .find(|o| o.provider.as_str() == "zhipuai" && o.wire_model_id.as_str() == "glm-5.2")
557 .expect("GLM offering reaches the route resolver seam");
558 assert_eq!(glm.limits.context_tokens, Some(1_000_000));
559 assert_eq!(glm.limits.output_tokens, Some(131_072));
560 // Audio-only row never becomes a routing offering.
561 assert!(
562 !offerings
563 .iter()
564 .any(|o| o.wire_model_id.as_str() == "glm-voice")
565 );
566 }
567
568 // ---------------------------------------------------------------------------
569 // #3385 / #4188: the committed offline/stale bundled Models.dev asset.
570 // ---------------------------------------------------------------------------
571
572 #[test]
573 fn bundled_asset_parses() {
574 // The committed asset must `include_str!`-load and deserialize into the
575 // parser's `ModelsDevCatalog` shape. This is the build-time guard that keeps
576 // `bundled_models_dev_catalog()` panic-free in shipped builds.
577 let catalog = ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
578 .expect("committed bundled asset must be valid Models.dev JSON");
579 assert!(
580 !catalog.providers.is_empty(),
581 "bundled asset must carry provider rows"
582 );
583 // The helper returns the same parsed catalog.
584 assert_eq!(bundled_models_dev_catalog(), catalog);
585 }
586
587 #[test]
588 fn bundled_asset_meta_describes_offline_fallback_not_competing_truth() {
589 // #4188: the asset must document itself as offline/stale fallback, not a
590 // competing curated source of truth alongside live Models.dev.
591 let raw: serde_json::Value =
592 serde_json::from_str(BUNDLED_MODELS_DEV_JSON).expect("bundled JSON");
593 let meta = raw
594 .get("_meta")
595 .and_then(|m| m.as_object())
596 .expect("_meta object");
597 let role = meta
598 .get("role")
599 .and_then(|v| v.as_str())
600 .unwrap_or_default();
601 assert!(
602 role.to_ascii_lowercase().contains("not a competing"),
603 "_meta.role must demote the bundled asset: {role}"
604 );
605 assert!(
606 role.to_ascii_lowercase().contains("live"),
607 "_meta.role must point at live Models.dev preference: {role}"
608 );
609 }
610
611 #[test]
612 fn bundled_asset_yields_real_chat_offerings_for_key_models() {
613 let rows = bundled_catalog_offerings();
614 assert!(
615 rows.len() >= 20,
616 "expected dozens of bundled chat offerings, got {}",
617 rows.len()
618 );
619
620 // A GLM and a Kimi row carry their real (non-default) context windows,
621 // proving real facts flow rather than `RouteLimits::default()` (unknown).
622 let glm = find(&rows, "zai", "GLM-5.2");
623 assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
624 assert!(glm.default_for_provider);
625
626 // GLM-5.3 is a peer row whose limits are INHERITED FROM glm-5.2 pending
627 // official Z.ai release metadata. Adding it must not move the default.
628 let glm53 = find(&rows, "zai", "GLM-5.3");
629 assert_eq!(
630 glm53.limit.as_ref().and_then(|l| l.context),
631 glm.limit.as_ref().and_then(|l| l.context)
632 );
633 assert_eq!(
634 glm53.limit.as_ref().and_then(|l| l.output),
635 glm.limit.as_ref().and_then(|l| l.output)
636 );
637 assert!(
638 !glm53.default_for_provider,
639 "GLM-5.3 must not become the Z.ai default"
640 );
641
642 let kimi_k27 = find(&rows, "moonshot", "kimi-k2.7-code");
643 assert_eq!(
644 kimi_k27.limit.as_ref().and_then(|l| l.context),
645 Some(262_144)
646 );
647
648 let kimi_k3 = find(&rows, "moonshot", "kimi-k3");
649 assert_eq!(
650 kimi_k3.limit.as_ref().and_then(|l| l.context),
651 Some(1_048_576)
652 );
653 assert_eq!(kimi_k3.limit.as_ref().and_then(|l| l.output), Some(131_072));
654 let kimi_k3_input_modalities = kimi_k3
655 .modalities
656 .as_ref()
657 .expect("K3 modalities")
658 .input
659 .iter()
660 .map(String::as_str)
661 .collect::<Vec<_>>();
662 assert_eq!(kimi_k3_input_modalities, ["text", "image", "video"]);
663
664 let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
665 assert_eq!(
666 minimax_m3.limit.as_ref().and_then(|limit| limit.context),
667 Some(1_000_000)
668 );
669 let input_modalities = minimax_m3
670 .modalities
671 .as_ref()
672 .expect("M3 modalities")
673 .input
674 .iter()
675 .map(String::as_str)
676 .collect::<Vec<_>>();
677 assert_eq!(input_modalities, ["text", "image", "video"]);
678 assert_eq!(
679 minimax_m3.reasoning_options[0]
680 .get("default")
681 .and_then(serde_json::Value::as_str),
682 Some("disabled")
683 );
684
685 let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
686 assert_eq!(
687 minimax_m2_7.limit.as_ref().and_then(|limit| limit.context),
688 Some(204_800)
689 );
690 assert_eq!(
691 minimax_m2_7.reasoning_options[0]
692 .get("default")
693 .and_then(serde_json::Value::as_str),
694 Some("always_on")
695 );
696
697 // Audio/TTS rows are absent (the asset only ships chat models, but assert
698 // the filter contract anyway).
699 assert!(
700 rows.iter().all(|r| !r.wire_model_id.contains("tts")),
701 "no TTS rows should reach the offering layer"
702 );
703 }
704
705 #[test]
706 fn bundled_asset_pricing_is_honest() {
707 let rows = bundled_catalog_offerings();
708
709 // DeepSeek-native rows are intentionally unpriced here (priced via the
710 // time-aware DeepSeek table elsewhere); pricing them would also break the
711 // route layer's `unpriced_offering_stays_unknown` invariant.
712 let deepseek = find(&rows, "deepseek", "deepseek-v4-pro");
713 assert!(
714 deepseek.cost.is_none(),
715 "DeepSeek-native rows must stay unpriced in the bundled asset"
716 );
717
718 // Any row that *does* carry a cost must expose a usable input/output rate
719 // (the honesty rule: no cache-only / empty cost objects that would render as
720 // a rate-less Token at the route layer).
721 for row in &rows {
722 if let Some(cost) = row.cost.as_ref() {
723 assert!(
724 cost.input.is_some() || cost.output.is_some(),
725 "{}/{}: priced row must have an input or output rate",
726 row.provider,
727 row.wire_model_id
728 );
729 }
730 }
731
732 // A sampled priced row matches the in-repo USD table (crates/tui pricing):
733 // GLM-5.1 at the 2026-07-09 Z.ai published rates.
734 let glm51 = find(&rows, "zai", "glm-5.1");
735 let cost = glm51.cost.as_ref().expect("glm-5.1 is priced");
736 assert_eq!(cost.input, Some(1.40));
737 assert_eq!(cost.output, Some(4.40));
738 assert_eq!(cost.cache_read, Some(0.26));
739
740 // GLM-5.3 was not live on the Z.ai API when it was added (2026-08-03) and
741 // Zhipu has published no rate for it, so every glm-5.3 row stays unpriced
742 // rather than inheriting glm-5.2's published rates.
743 for row in &rows {
744 if row.wire_model_id.to_ascii_lowercase().contains("glm-5.3") {
745 assert!(
746 row.cost.is_none(),
747 "{}/{}: glm-5.3 must stay unpriced until Z.ai publishes rates",
748 row.provider,
749 row.wire_model_id
750 );
751 }
752 }
753
754 // M3 has input-length and service tiers that the flat catalog cost shape
755 // cannot represent, so the bundled route row stays honestly unpriced.
756 let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
757 assert!(minimax_m3.cost.is_none());
758
759 let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
760 let cost = minimax_m2_7.cost.as_ref().expect("M2.7 is priced");
761 assert_eq!(cost.input, Some(0.30));
762 assert_eq!(cost.output, Some(1.20));
763 assert_eq!(cost.cache_read, Some(0.06));
764 assert_eq!(cost.cache_write, Some(0.375));
765 }
766
767 #[test]
768 fn live_offerings_normalize_models_dev_provider_aliases() {
769 // Live Models.dev ids that must map onto CodeWhale kinds (#4186/#4187).
770 let raw = r#"{
771 "models": {},
772 "providers": {
773 "moonshotai": {
774 "id": "moonshotai",
775 "models": {
776 "kimi-k2.5": {
777 "id": "kimi-k2.5",
778 "modalities": { "input": ["text"], "output": ["text"] }
779 }
780 }
781 },
782 "togetherai": {
783 "id": "togetherai",
784 "models": {
785 "deepseek-ai/DeepSeek-V4-Pro": {
786 "id": "deepseek-ai/DeepSeek-V4-Pro",
787 "modalities": { "input": ["text"], "output": ["text"] }
788 }
789 }
790 },
791 "zhipuai": {
792 "id": "zhipuai",
793 "models": {
794 "glm-5.2": {
795 "id": "glm-5.2",
796 "modalities": { "input": ["text"], "output": ["text"] }
797 }
798 }
799 },
800 "brand-new-gateway": {
801 "id": "brand-new-gateway",
802 "models": {
803 "x-1": {
804 "id": "x-1",
805 "modalities": { "input": ["text"], "output": ["text"] }
806 }
807 }
808 }
809 }
810 }"#;
811 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
812 let rows = live_offerings_from_models_dev(&catalog, "fp-models-dev", 1_700);
813
814 assert_eq!(
815 find(&rows, "moonshot", "kimi-k2.5").source,
816 CatalogSource::Live {
817 base_url_fingerprint: "fp-models-dev".into(),
818 fetched_at: 1_700,
819 }
820 );
821 find(&rows, "together", "deepseek-ai/DeepSeek-V4-Pro");
822 find(&rows, "zai", "glm-5.2");
823 // Unknown upstream providers keep their Models.dev id.
824 find(&rows, "brand-new-gateway", "x-1");
825 assert!(rows.iter().all(|r| r.provider != "moonshotai"));
826 assert!(rows.iter().all(|r| r.provider != "togetherai"));
827 assert!(rows.iter().all(|r| r.provider != "zhipuai"));
828 }
829
829 lines RUST