返回 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 compiler_layer_order_and_policy_deny_never_overridden() {
209 let row = |source: CatalogSource, model: &str, family: &str| CatalogOffering {
210 provider: "zai-coding-cn".into(),
211 wire_model_id: model.into(),
212 endpoint_key: "chat".into(),
213 family: Some(family.into()),
214 source,
215 ..Default::default()
216 };
217 let policy = crate::route::CatalogPolicy {
218 rules: vec![crate::route::PolicyRule {
219 effect: crate::route::PolicyEffect::Deny,
220 action: crate::route::PolicyAction::ModelUse,
221 resource: "*-cn/*".to_string(),
222 }],
223 };
224 let snapshot = CatalogCompiler::new()
225 .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")])
226 .with_models_dev_live(vec![row(
227 CatalogSource::ModelsDevLive { fetched_at: 1 },
228 "glm-5",
229 "models-dev",
230 )])
231 .with_provider_live(vec![row(
232 CatalogSource::Live {
233 base_url_fingerprint: "fp".into(),
234 fetched_at: 2,
235 },
236 "glm-5",
237 "provider",
238 )])
239 .with_config(vec![row(CatalogSource::ConfigOverride, "glm-5", "config")])
240 .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")])
241 .with_policy(policy)
242 .compile();
243
244 assert!(
245 snapshot.offerings.is_empty(),
246 "policy DENY after every layer must drop the row; layers cannot override it"
247 );
248
249 let allowed = CatalogCompiler::new()
250 .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")])
251 .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")])
252 .compile();
253 let kept = find(&allowed.offerings, "zai-coding-cn", "glm-5");
254 assert_eq!(kept.source, CatalogSource::UserOverride);
255 assert_eq!(kept.family.as_deref(), Some("user"));
256 }
257
258 #[test]
259 fn cache_scopes_by_provider_and_base_url_fingerprint() {
260 let fp_a = base_url_fingerprint("https://api.example.com/v1");
261 let fp_b = base_url_fingerprint("https://other.example.com/v1");
262 assert_ne!(fp_a, fp_b, "different hosts must not share a fingerprint");
263
264 let mut cache = ProviderCatalogCache::new();
265 let row = |id: &str| CatalogOffering {
266 provider: "acme".into(),
267 wire_model_id: id.into(),
268 endpoint_key: "chat".into(),
269 ..Default::default()
270 };
271
272 // Same provider, two different base URLs.
273 cache.record_success(
274 ProviderCatalogDelta {
275 provider: "acme".into(),
276 base_url_fingerprint: fp_a.clone(),
277 fetched_at: 1_000,
278 offerings: vec![row("from-a")],
279 },
280 3_600,
281 );
282 cache.record_success(
283 ProviderCatalogDelta {
284 provider: "acme".into(),
285 base_url_fingerprint: fp_b.clone(),
286 fetched_at: 1_000,
287 offerings: vec![row("from-b")],
288 },
289 3_600,
290 );
291 // Different provider, SAME base URL as fp_a.
292 cache.record_success(
293 ProviderCatalogDelta {
294 provider: "beta".into(),
295 base_url_fingerprint: fp_a.clone(),
296 fetched_at: 1_000,
297 offerings: vec![row("from-beta")],
298 },
299 3_600,
300 );
301
302 let a = cache.fresh_offerings("acme", &fp_a, 1_100);
303 assert_eq!(a.len(), 1);
304 assert_eq!(a[0].wire_model_id, "from-a");
305 // Same provider, different base URL must not leak rows across.
306 let b = cache.fresh_offerings("acme", &fp_b, 1_100);
307 assert_eq!(b[0].wire_model_id, "from-b");
308 // Different provider on the same base URL must not share rows either.
309 let beta = cache.fresh_offerings("beta", &fp_a, 1_100);
310 assert_eq!(beta[0].wire_model_id, "from-beta");
311 assert_eq!(cache.entries.len(), 3);
312 }
313
314 #[test]
315 fn fingerprint_folds_cosmetic_base_url_differences() {
316 let canonical = base_url_fingerprint("https://API.Example.com/v1");
317 assert_eq!(canonical.len(), 64, "endpoint fingerprints use SHA-256");
318 assert_eq!(
319 canonical,
320 base_url_fingerprint("https://api.example.com/v1/"),
321 "trailing slash + host case must not change the cache scope"
322 );
323 assert_eq!(
324 canonical,
325 base_url_fingerprint(" https://api.example.com:443/v1 "),
326 "default https port + surrounding whitespace must fold away"
327 );
328 // Path case is significant (providers can be case-sensitive on the path).
329 assert_ne!(
330 canonical,
331 base_url_fingerprint("https://api.example.com/V1")
332 );
333
334 // Port stripping is scheme-aware: :80 is http's default (folds away), but
335 // :443 on http is a non-default port and must stay distinct from bare http.
336 assert_eq!(
337 base_url_fingerprint("http://h.example.com:80/v1"),
338 base_url_fingerprint("http://h.example.com/v1"),
339 "http default port :80 must fold away"
340 );
341 assert_ne!(
342 base_url_fingerprint("http://h.example.com:443/v1"),
343 base_url_fingerprint("http://h.example.com/v1"),
344 ":443 is not http's default port and must not fold"
345 );
346 }
347
348 #[test]
349 fn fingerprint_never_hashes_secret_bearing_url_text() {
350 let expected = base_url_fingerprint("https://api.example.com/v1");
351 for url in [
352 "https://user:secret@api.example.com/v1",
353 "https://api.example.com/v1?api_key=secret",
354 "https://api.example.com/v1#secret",
355 ] {
356 assert_eq!(base_url_fingerprint(url), expected, "{url}");
357 }
358 }
359
360 #[test]
361 fn fingerprint_strips_userinfo_from_a_scheme_less_base_url() {
362 // A base_url typed without a scheme took the fall-through branch, which
363 // only split off `?`/`#` — so `user:pass@host` went into SHA-256 verbatim,
364 // against the documented "userinfo never enters the digest function".
365 let expected = base_url_fingerprint("api.example.com/v1");
366 for url in [
367 "user:secret@api.example.com/v1",
368 "user:other-secret@api.example.com/v1",
369 "token@api.example.com/v1",
370 ] {
371 assert_eq!(base_url_fingerprint(url), expected, "{url}");
372 }
373 }
374
375 #[test]
376 fn fingerprint_of_an_empty_base_url_is_the_redacted_constant() {
377 // The fall-through's `unwrap_or(REDACTED)` never fired — `split` always
378 // yields at least one (possibly empty) piece — so an empty base URL
379 // fingerprinted the empty string instead of the redacted sentinel.
380 let redacted = base_url_fingerprint("ftp://api.example.com");
381 for url in ["", " ", "?api_key=secret"] {
382 assert_eq!(base_url_fingerprint(url), redacted, "{url:?}");
383 }
384 // SHA-256("") is what empty/whitespace hashed to before the sentinel
385 // mapping. That digest is a persisted cache/receipt key, so flipping it
386 // back would be another undeclared persisted-key change.
387 const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
388 assert_ne!(redacted, EMPTY_SHA256);
389 }
390
391 #[test]
392 fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
393 let fp = base_url_fingerprint("https://api.example.com");
394 let mut cache = ProviderCatalogCache::new();
395 cache.record_success(
396 ProviderCatalogDelta {
397 provider: "acme".into(),
398 base_url_fingerprint: fp.clone(),
399 fetched_at: 1_000,
400 offerings: vec![CatalogOffering {
401 provider: "acme".into(),
402 wire_model_id: "synth-chat-1".into(),
403 endpoint_key: "chat".into(),
404 ..Default::default()
405 }],
406 },
407 100, // ttl
408 );
409
410 // Within TTL: fresh.
411 assert_eq!(cache.status("acme", &fp, 1_050), CatalogStatus::Fresh);
412 assert_eq!(cache.fresh_offerings("acme", &fp, 1_050).len(), 1);
413
414 // Past TTL: stale, and excluded from fresh offerings.
415 match cache.status("acme", &fp, 1_200) {
416 CatalogStatus::Stale { age_secs } => assert_eq!(age_secs, 200),
417 other => panic!("expected stale, got {other:?}"),
418 }
419 assert!(cache.fresh_offerings("acme", &fp, 1_200).is_empty());
420 // But the rows are still present in the cache for explicit fallback display.
421 assert_eq!(cache.get("acme", &fp).unwrap().offerings.len(), 1);
422 }
423
424 #[test]
425 fn ttl_zero_is_always_stale() {
426 let fp = base_url_fingerprint("https://api.example.com");
427 let mut cache = ProviderCatalogCache::new();
428 cache.record_success(
429 ProviderCatalogDelta {
430 provider: "acme".into(),
431 base_url_fingerprint: fp.clone(),
432 fetched_at: 1_000,
433 offerings: vec![],
434 },
435 0,
436 );
437 assert!(cache.get("acme", &fp).unwrap().is_stale(1_000));
438 }
439
440 #[test]
441 fn unknown_scope_reports_unknown_status() {
442 let cache = ProviderCatalogCache::new();
443 let fp = base_url_fingerprint("https://api.example.com");
444 assert_eq!(cache.status("acme", &fp, 1_000), CatalogStatus::Unknown);
445 assert!(cache.fresh_offerings("acme", &fp, 1_000).is_empty());
446 }
447
448 #[test]
449 fn refresh_failure_preserves_prior_rows_and_marks_failed() {
450 let fp = base_url_fingerprint("https://api.example.com");
451 let mut cache = ProviderCatalogCache::new();
452 cache.record_success(
453 ProviderCatalogDelta {
454 provider: "acme".into(),
455 base_url_fingerprint: fp.clone(),
456 fetched_at: 1_000,
457 offerings: vec![CatalogOffering {
458 provider: "acme".into(),
459 wire_model_id: "synth-chat-1".into(),
460 endpoint_key: "chat".into(),
461 ..Default::default()
462 }],
463 },
464 3_600,
465 );
466
467 for reason in [
468 CatalogRefreshError::Unauthorized,
469 CatalogRefreshError::Forbidden,
470 CatalogRefreshError::NotFound,
471 CatalogRefreshError::RateLimited,
472 CatalogRefreshError::InvalidResponse,
473 CatalogRefreshError::EmptyList,
474 CatalogRefreshError::Network,
475 ] {
476 cache.record_failure("acme", &fp, reason);
477 let entry = cache.get("acme", &fp).expect("entry survives failure");
478 // Prior successful rows remain available after a failed refresh.
479 assert_eq!(entry.offerings.len(), 1, "{reason:?} dropped prior rows");
480 assert_eq!(entry.status, CatalogStatus::Failed { reason });
481 // fetched_at is NOT bumped by a failure.
482 assert_eq!(entry.fetched_at, 1_000);
483 // ...but a Failed entry must NOT contribute to fresh offerings even
484 // while still within its TTL window (now=1_100, ttl=3_600). The rows
485 // are reachable only via get() for explicit fallback display.
486 assert!(
487 cache.fresh_offerings("acme", &fp, 1_100).is_empty(),
488 "{reason:?}: failed entry served fresh offerings within TTL"
489 );
490 assert!(cache.all_fresh_offerings(1_100).is_empty());
491 assert_eq!(
492 cache.status("acme", &fp, 1_100),
493 CatalogStatus::Failed { reason }
494 );
495 }
496 }
497
498 #[test]
499 fn failure_without_prior_creates_observable_empty_entry() {
500 let fp = base_url_fingerprint("https://api.example.com");
501 let mut cache = ProviderCatalogCache::new();
502 cache.record_failure("acme", &fp, CatalogRefreshError::Unauthorized);
503
504 let entry = cache.get("acme", &fp).expect("failure is observable");
505 assert!(entry.offerings.is_empty());
506 assert_eq!(
507 entry.status,
508 CatalogStatus::Failed {
509 reason: CatalogRefreshError::Unauthorized
510 }
511 );
512 }
513
514 #[test]
515 fn record_success_stamps_live_provenance_on_rows() {
516 let fp = base_url_fingerprint("https://api.example.com");
517 let mut cache = ProviderCatalogCache::new();
518 // Row arrives mislabeled as Bundled; ingest must normalize provenance.
519 cache.record_success(
520 ProviderCatalogDelta {
521 provider: "acme".into(),
522 base_url_fingerprint: fp.clone(),
523 fetched_at: 4_242,
524 offerings: vec![CatalogOffering {
525 provider: "acme".into(),
526 wire_model_id: "synth-chat-1".into(),
527 endpoint_key: "chat".into(),
528 source: CatalogSource::Bundled,
529 ..Default::default()
530 }],
531 },
532 3_600,
533 );
534 let entry = cache.get("acme", &fp).unwrap();
535 assert_eq!(
536 entry.offerings[0].source,
537 CatalogSource::Live {
538 base_url_fingerprint: fp,
539 fetched_at: 4_242,
540 }
541 );
542 }
543
544 #[test]
545 fn cache_serialization_round_trips_and_contains_no_secrets() {
546 let fp = base_url_fingerprint("https://api.example.com/v1");
547 let mut cache = ProviderCatalogCache::new();
548 cache.record_success(
549 ProviderCatalogDelta {
550 provider: "zhipuai".into(),
551 base_url_fingerprint: fp.clone(),
552 fetched_at: 1_700,
553 offerings: bundled_offerings_from_models_dev(&fixture()),
554 },
555 3_600,
556 );
557
558 let json = serde_json::to_string_pretty(&cache).expect("cache serializes");
559 let round: ProviderCatalogCache = serde_json::from_str(&json).expect("cache round-trips");
560 assert_eq!(round, cache);
561
562 // The persisted shape carries model facts but has no field that could hold
563 // a credential. Guard against a future field reintroducing one.
564 let lower = json.to_lowercase();
565 for needle in [
566 "api_key",
567 "apikey",
568 "api-key",
569 "authorization",
570 "secret",
571 "password",
572 "bearer",
573 "access_token",
574 ] {
575 assert!(
576 !lower.contains(needle),
577 "cache JSON unexpectedly contains `{needle}`"
578 );
579 }
580 // Sanity: it did serialize meaningful provider/model facts.
581 assert!(json.contains("glm-5.2"));
582 assert!(json.contains("base_url_fingerprint"));
583 }
584
585 #[test]
586 fn all_fresh_offerings_spans_providers_and_skips_stale() {
587 let fp = base_url_fingerprint("https://api.example.com");
588 let mut cache = ProviderCatalogCache::new();
589 cache.record_success(
590 ProviderCatalogDelta {
591 provider: "acme".into(),
592 base_url_fingerprint: fp.clone(),
593 fetched_at: 1_000,
594 offerings: vec![CatalogOffering {
595 provider: "acme".into(),
596 wire_model_id: "fresh-row".into(),
597 endpoint_key: "chat".into(),
598 ..Default::default()
599 }],
600 },
601 3_600,
602 );
603 cache.record_success(
604 ProviderCatalogDelta {
605 provider: "beta".into(),
606 base_url_fingerprint: fp.clone(),
607 fetched_at: 0,
608 offerings: vec![CatalogOffering {
609 provider: "beta".into(),
610 wire_model_id: "stale-row".into(),
611 endpoint_key: "chat".into(),
612 ..Default::default()
613 }],
614 },
615 10, // tiny ttl → stale at now=1_100
616 );
617
618 let fresh = cache.all_fresh_offerings(1_100);
619 assert_eq!(fresh.len(), 1);
620 assert_eq!(fresh[0].wire_model_id, "fresh-row");
621
622 // #4139: pickers still see stale rows; only the fresh helper drops them.
623 let visible = cache.all_visible_offerings(1_100);
624 assert_eq!(visible.len(), 2);
625 assert!(visible.iter().any(|row| row.wire_model_id == "fresh-row"));
626 assert!(visible.iter().any(|row| row.wire_model_id == "stale-row"));
627 }
628
629 #[test]
630 fn snapshot_feeds_route_resolver_offerings() {
631 // The compiled snapshot projects into the exact type RouteResolver consumes,
632 // proving catalog rows reach routing only through the offering seam.
633 let snapshot = CatalogCompiler::new().with_models_dev(&fixture()).compile();
634 let offerings = snapshot.to_offerings();
635
636 let glm = offerings
637 .iter()
638 .find(|o| o.provider.as_str() == "zhipuai" && o.wire_model_id.as_str() == "glm-5.2")
639 .expect("GLM offering reaches the route resolver seam");
640 assert_eq!(glm.limits.context_tokens, Some(1_000_000));
641 assert_eq!(glm.limits.output_tokens, Some(131_072));
642 // Audio-only row never becomes a routing offering.
643 assert!(
644 !offerings
645 .iter()
646 .any(|o| o.wire_model_id.as_str() == "glm-voice")
647 );
648 }
649
650 // ---------------------------------------------------------------------------
651 // #3385 / #4188: the committed offline/stale bundled Models.dev asset.
652 // ---------------------------------------------------------------------------
653
654 #[test]
655 fn bundled_asset_parses() {
656 // The committed asset must `include_str!`-load and deserialize into the
657 // parser's `ModelsDevCatalog` shape. This is the build-time guard that keeps
658 // `bundled_models_dev_catalog()` panic-free in shipped builds.
659 let catalog = ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
660 .expect("committed bundled asset must be valid Models.dev JSON");
661 assert!(
662 !catalog.providers.is_empty(),
663 "bundled asset must carry provider rows"
664 );
665 // The helper returns the same parsed catalog.
666 assert_eq!(*bundled_models_dev_catalog(), catalog);
667 }
668
669 #[test]
670 fn bundled_asset_meta_describes_offline_fallback_not_competing_truth() {
671 // #4188: the asset must document itself as offline/stale fallback, not a
672 // competing curated source of truth alongside live Models.dev.
673 let raw: serde_json::Value =
674 serde_json::from_str(BUNDLED_MODELS_DEV_JSON).expect("bundled JSON");
675 let meta = raw
676 .get("_meta")
677 .and_then(|m| m.as_object())
678 .expect("_meta object");
679 let role = meta
680 .get("role")
681 .and_then(|v| v.as_str())
682 .unwrap_or_default();
683 assert!(
684 role.to_ascii_lowercase().contains("not a competing"),
685 "_meta.role must demote the bundled asset: {role}"
686 );
687 assert!(
688 role.to_ascii_lowercase().contains("live"),
689 "_meta.role must point at live Models.dev preference: {role}"
690 );
691 }
692
693 #[test]
694 fn bundled_asset_yields_real_chat_offerings_for_key_models() {
695 let rows = bundled_catalog_offerings();
696 assert!(
697 rows.len() >= 20,
698 "expected dozens of bundled chat offerings, got {}",
699 rows.len()
700 );
701
702 // A GLM and a Kimi row carry their real (non-default) context windows,
703 // proving real facts flow rather than `RouteLimits::default()` (unknown).
704 let glm = find(&rows, "zai", "GLM-5.2");
705 assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
706 assert!(
707 !glm.default_for_provider,
708 "GLM-5.2 is no longer the Z.ai default"
709 );
710
711 // GLM-5.3 is the Z.ai default (matching DEFAULT_ZAI_MODEL); its limits
712 // still inherit from glm-5.2 until Z.ai publishes distinct 5.3 numbers.
713 let glm53 = find(&rows, "zai", "GLM-5.3");
714 assert_eq!(
715 glm53.limit.as_ref().and_then(|l| l.context),
716 glm.limit.as_ref().and_then(|l| l.context)
717 );
718 assert_eq!(
719 glm53.limit.as_ref().and_then(|l| l.output),
720 glm.limit.as_ref().and_then(|l| l.output)
721 );
722 assert!(
723 glm53.default_for_provider,
724 "GLM-5.3 must be the Z.ai default"
725 );
726
727 let kimi_k27 = find(&rows, "moonshot", "kimi-k2.7-code");
728 assert_eq!(
729 kimi_k27.limit.as_ref().and_then(|l| l.context),
730 Some(262_144)
731 );
732
733 let kimi_k3 = find(&rows, "moonshot", "kimi-k3");
734 assert_eq!(
735 kimi_k3.limit.as_ref().and_then(|l| l.context),
736 Some(1_048_576)
737 );
738 assert_eq!(kimi_k3.limit.as_ref().and_then(|l| l.output), Some(131_072));
739 let kimi_k3_input_modalities = kimi_k3
740 .modalities
741 .as_ref()
742 .expect("K3 modalities")
743 .input
744 .iter()
745 .map(String::as_str)
746 .collect::<Vec<_>>();
747 assert_eq!(kimi_k3_input_modalities, ["text", "image", "video"]);
748
749 let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
750 assert_eq!(
751 minimax_m3.limit.as_ref().and_then(|limit| limit.context),
752 Some(1_000_000)
753 );
754 let input_modalities = minimax_m3
755 .modalities
756 .as_ref()
757 .expect("M3 modalities")
758 .input
759 .iter()
760 .map(String::as_str)
761 .collect::<Vec<_>>();
762 assert_eq!(input_modalities, ["text", "image", "video"]);
763 assert_eq!(
764 minimax_m3.reasoning_options[0]
765 .get("default")
766 .and_then(serde_json::Value::as_str),
767 Some("disabled")
768 );
769
770 let grok_46 = find(&rows, "xai", "grok-4.6");
771 assert!(grok_46.default_for_provider);
772 assert_eq!(
773 grok_46.limit.as_ref().and_then(|limit| limit.context),
774 Some(500_000)
775 );
776 assert_eq!(grok_46.attachment, Some(true));
777 assert_eq!(grok_46.structured_output, Some(true));
778 let grok_input_modalities = grok_46
779 .modalities
780 .as_ref()
781 .expect("Grok 4.6 modalities")
782 .input
783 .iter()
784 .map(String::as_str)
785 .collect::<Vec<_>>();
786 assert_eq!(grok_input_modalities, ["text", "image"]);
787 assert_eq!(
788 grok_46.reasoning_options[0]
789 .get("default")
790 .and_then(serde_json::Value::as_str),
791 Some("high")
792 );
793 let grok_45 = find(&rows, "xai", "grok-4.5");
794 assert_eq!(
795 grok_45.reasoning_options[0]
796 .get("default")
797 .and_then(serde_json::Value::as_str),
798 Some("high")
799 );
800 let grok_45_values = grok_45.reasoning_options[0]
801 .get("values")
802 .and_then(serde_json::Value::as_array)
803 .expect("Grok 4.5 effort values");
804 assert_eq!(
805 grok_45_values
806 .iter()
807 .filter_map(|value| value.as_str())
808 .collect::<Vec<_>>(),
809 ["low", "medium", "high"]
810 );
811
812 let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
813 assert_eq!(
814 minimax_m2_7.limit.as_ref().and_then(|limit| limit.context),
815 Some(204_800)
816 );
817 assert_eq!(
818 minimax_m2_7.reasoning_options[0]
819 .get("default")
820 .and_then(serde_json::Value::as_str),
821 Some("always_on")
822 );
823
824 // Audio/TTS rows are absent (the asset only ships chat models, but assert
825 // the filter contract anyway).
826 assert!(
827 rows.iter().all(|r| !r.wire_model_id.contains("tts")),
828 "no TTS rows should reach the offering layer"
829 );
830 }
831
832 #[test]
833 fn bundled_asset_pricing_is_honest() {
834 let rows = bundled_catalog_offerings();
835
836 // DeepSeek-native rows are intentionally unpriced here (priced via the
837 // time-aware DeepSeek table elsewhere); pricing them would also break the
838 // route layer's `unpriced_offering_stays_unknown` invariant.
839 let deepseek = find(&rows, "deepseek", "deepseek-v4-pro");
840 assert!(
841 deepseek.cost.is_none(),
842 "DeepSeek-native rows must stay unpriced in the bundled asset"
843 );
844
845 // Any row that *does* carry a cost must expose a usable input/output rate
846 // (the honesty rule: no cache-only / empty cost objects that would render as
847 // a rate-less Token at the route layer).
848 for row in &rows {
849 if let Some(cost) = row.cost.as_ref() {
850 assert!(
851 cost.input.is_some() || cost.output.is_some(),
852 "{}/{}: priced row must have an input or output rate",
853 row.provider,
854 row.wire_model_id
855 );
856 }
857 }
858
859 // A sampled priced row matches the in-repo USD table (crates/tui pricing):
860 // GLM-5.1 at the 2026-07-09 Z.ai published rates.
861 let glm51 = find(&rows, "zai", "glm-5.1");
862 let cost = glm51.cost.as_ref().expect("glm-5.1 is priced");
863 assert_eq!(cost.input, Some(1.40));
864 assert_eq!(cost.output, Some(4.40));
865 assert_eq!(cost.cache_read, Some(0.26));
866
867 // GLM-5.3 is live on the Coding Plan, but Z.ai has published no USD PAYG
868 // rate for it. Coding Plan credit multipliers are not USD, so every
869 // glm-5.3 row *except Flash* stays unpriced rather than inheriting
870 // glm-5.2's rates. GLM-5.3-Flash has a published list (2026-08-26).
871 for row in &rows {
872 let wire = row.wire_model_id.to_ascii_lowercase();
873 if wire.contains("glm-5.3") && !wire.contains("flash") {
874 assert!(
875 row.cost.is_none(),
876 "{}/{}: glm-5.3 must stay unpriced until Z.ai publishes rates",
877 row.provider,
878 row.wire_model_id
879 );
880 }
881 }
882
883 let glm53_flash = find(&rows, "zai", "GLM-5.3-Flash");
884 let cost = glm53_flash
885 .cost
886 .as_ref()
887 .expect("GLM-5.3-Flash must ship priced at durable list rates");
888 assert_eq!(cost.input, Some(0.15));
889 assert_eq!(cost.output, Some(0.50));
890 assert_eq!(cost.cache_read, Some(0.03));
891 assert_eq!(
892 glm53_flash.limit.as_ref().and_then(|l| l.context),
893 Some(1_000_000)
894 );
895 assert!(
896 !glm53_flash.default_for_provider,
897 "GLM-5.3-Flash is a picker row, not the Z.ai default"
898 );
899
900 // OpenRouter qwen3.8-flash lists durable (non-promo) rates on models.dev
901 // as of 2026-08-26. Unlike GLM-5.3-Flash's explicit 50% promo, this row
902 // must ship priced. It is not a family default.
903 let qwen38_flash = find(&rows, "openrouter", "qwen/qwen3.8-flash");
904 assert!(
905 !qwen38_flash.default_for_provider,
906 "qwen3.8-flash is a suffix variant and must not be the OpenRouter default"
907 );
908 let cost = qwen38_flash
909 .cost
910 .as_ref()
911 .expect("qwen/qwen3.8-flash must ship priced (durable list rates, no promo)");
912 assert_eq!(cost.input, Some(0.16));
913 assert_eq!(cost.output, Some(0.47));
914 assert_eq!(cost.cache_read, Some(0.016));
915 assert_eq!(cost.cache_write, Some(0.20));
916 assert_eq!(
917 qwen38_flash.limit.as_ref().and_then(|l| l.context),
918 Some(1_000_000)
919 );
920 assert_eq!(
921 qwen38_flash.limit.as_ref().and_then(|l| l.output),
922 Some(131_072)
923 );
924
925 // M3 has input-length and service tiers that the flat catalog cost shape
926 // cannot represent, so the bundled route row stays honestly unpriced.
927 let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
928 assert!(minimax_m3.cost.is_none());
929
930 // Grok 4.6 also has a prompt-length tier, starting at 200K input tokens.
931 // The usage-aware TUI table prices it; a flat catalog row would underbill.
932 let grok_46 = find(&rows, "xai", "grok-4.6");
933 assert!(grok_46.cost.is_none());
934
935 let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
936 let cost = minimax_m2_7.cost.as_ref().expect("M2.7 is priced");
937 assert_eq!(cost.input, Some(0.30));
938 assert_eq!(cost.output, Some(1.20));
939 assert_eq!(cost.cache_read, Some(0.06));
940 assert_eq!(cost.cache_write, Some(0.375));
941 }
942
943 #[test]
944 fn live_offerings_normalize_models_dev_provider_aliases() {
945 // Live Models.dev ids that must map onto CodeWhale kinds (#4186/#4187).
946 let raw = r#"{
947 "models": {},
948 "providers": {
949 "moonshotai": {
950 "id": "moonshotai",
951 "models": {
952 "kimi-k2.5": {
953 "id": "kimi-k2.5",
954 "modalities": { "input": ["text"], "output": ["text"] }
955 }
956 }
957 },
958 "togetherai": {
959 "id": "togetherai",
960 "models": {
961 "deepseek-ai/DeepSeek-V4-Pro": {
962 "id": "deepseek-ai/DeepSeek-V4-Pro",
963 "modalities": { "input": ["text"], "output": ["text"] }
964 }
965 }
966 },
967 "zhipuai": {
968 "id": "zhipuai",
969 "models": {
970 "glm-5.2": {
971 "id": "glm-5.2",
972 "modalities": { "input": ["text"], "output": ["text"] }
973 }
974 }
975 },
976 "brand-new-gateway": {
977 "id": "brand-new-gateway",
978 "models": {
979 "x-1": {
980 "id": "x-1",
981 "modalities": { "input": ["text"], "output": ["text"] }
982 }
983 }
984 }
985 }
986 }"#;
987 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
988 let rows = live_offerings_from_models_dev(&catalog, 1_700);
989
990 // Layer 10, and no endpoint fingerprint: a models.dev row is external
991 // enrichment about a model, not a provider's statement about an endpoint.
992 // Stamping `Live` here put every enriched row above the signed layer that
993 // is supposed to be able to correct it.
994 assert_eq!(
995 find(&rows, "moonshot", "kimi-k2.5").source,
996 CatalogSource::ModelsDevLive { fetched_at: 1_700 }
997 );
998 find(&rows, "together", "deepseek-ai/DeepSeek-V4-Pro");
999 find(&rows, "zai", "glm-5.2");
1000 // Unknown upstream providers keep their Models.dev id.
1001 find(&rows, "brand-new-gateway", "x-1");
1002 assert!(rows.iter().all(|r| r.provider != "moonshotai"));
1003 assert!(rows.iter().all(|r| r.provider != "togetherai"));
1004 assert!(rows.iter().all(|r| r.provider != "zhipuai"));
1005 }
1006
1007 fn offering(provider: &str, wire: &str, source: CatalogSource) -> CatalogOffering {
1008 CatalogOffering {
1009 provider: provider.to_string(),
1010 wire_model_id: wire.to_string(),
1011 endpoint_key: "chat".to_string(),
1012 source,
1013 ..CatalogOffering::default()
1014 }
1015 }
1016
1017 /// The layer-25 "signed CWC catalog" this test used to pin is gone: it never
1018 /// had a fetcher, and signed cloud facts (layer 15, under the provider roster)
1019 /// is the client's one online catalog authority. What still has to hold on the
1020 /// same wire is that the provider's own roster outranks models.dev.
1021 #[test]
1022 fn provider_live_beats_models_dev_on_the_same_wire() {
1023 let snapshot = CatalogCompiler::new()
1024 .with_bundled(vec![offering(
1025 "command-code",
1026 "deepseek/deepseek-v4-flash",
1027 CatalogSource::Bundled,
1028 )])
1029 .with_models_dev_live(vec![offering(
1030 "command-code",
1031 "deepseek/deepseek-v4-flash",
1032 CatalogSource::ModelsDevLive { fetched_at: 1 },
1033 )])
1034 .with_provider_live(vec![offering(
1035 "command-code",
1036 "deepseek/deepseek-v4-flash",
1037 CatalogSource::Live {
1038 base_url_fingerprint: "fixture".into(),
1039 fetched_at: 2,
1040 },
1041 )])
1042 .compile();
1043 let row = find(
1044 &snapshot.offerings,
1045 "command-code",
1046 "deepseek/deepseek-v4-flash",
1047 );
1048 assert!(matches!(
1049 row.source,
1050 CatalogSource::Live { ref base_url_fingerprint, fetched_at: 2 }
1051 if base_url_fingerprint == "fixture"
1052 ));
1053 }
1054
1055 #[test]
1056 fn endpoint_is_baseten_recognizes_the_host_not_the_spelling() {
1057 assert!(endpoint_is_baseten(BASETEN_BASE_URL));
1058 assert!(endpoint_is_baseten(&format!("{BASETEN_BASE_URL}/")));
1059 assert!(endpoint_is_baseten("HTTPS://INFERENCE.BASETEN.CO/v1"));
1060 assert!(!endpoint_is_baseten("https://api.groq.com/openai/v1"));
1061 assert!(!endpoint_is_baseten("https://127.0.0.1:9/v1"));
1062 assert!(!endpoint_is_baseten(""));
1063 }
1064
1065 #[test]
1066 fn stepfun_bundled_coding_models_preserve_default_and_plan_pricing_boundary() {
1067 let rows: Vec<_> = bundled_catalog_offerings()
1068 .into_iter()
1069 .filter(|row| row.provider == "stepfun")
1070 .collect();
1071 assert_eq!(rows.len(), 4);
1072 assert_eq!(
1073 rows.iter()
1074 .find(|row| row.default_for_provider)
1075 .unwrap()
1076 .wire_model_id,
1077 "step-3.7-flash"
1078 );
1079 for row in &rows {
1080 assert_eq!(row.reasoning, Some(true));
1081 assert_eq!(row.tool_call, Some(true));
1082 assert!(row.cost.is_none(), "Step Plan shares ids, not PAYG billing");
1083 assert_eq!(row.modalities.as_ref().unwrap().output, ["text"]);
1084 }
1085 let step5 = rows
1086 .iter()
1087 .find(|row| row.wire_model_id == "step-5-preview")
1088 .unwrap();
1089 assert_eq!(step5.limit.as_ref().unwrap().context, Some(1_000_000));
1090 assert_eq!(step5.limit.as_ref().unwrap().output, Some(1_000_000));
1091 assert_eq!(
1092 step5.modalities.as_ref().unwrap().input,
1093 ["text", "image", "video"]
1094 );
1095 assert_eq!(
1096 step5.reasoning_options[0]["values"],
1097 serde_json::json!(["low", "medium", "high"])
1098 );
1099 let march = rows
1100 .iter()
1101 .find(|row| row.wire_model_id == "step-3.5-flash-2603")
1102 .unwrap();
1103 assert_eq!(
1104 march.reasoning_options[0]["values"],
1105 serde_json::json!(["low", "high"])
1106 );
1107 assert_eq!(march.limit.as_ref().unwrap().output, None);
1108 }
1109
1109 lines RUST