返回 CodeWhale
tests.rs
根目录 / crates / config / src / route / tests.rs
1 //! Behavior tests for the route foundation (#2608 / #3084 / #3384).
2
3 use super::descriptor::ProviderDescriptor;
4 use super::errors::RouteError;
5 use super::ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, WireModelId};
6 use super::resolver::{RouteRequest, RouteResolver};
7 use super::{
8 CapabilityState, LimitField, OverrideSource, RequestProtocol, ResolvedAuthSource,
9 RouteCapabilities, SourcedLimitOverride,
10 };
11 use crate::ProviderKind;
12 use crate::models_dev::ModelsDevCatalog;
13
14 /// Build a request with only an explicit provider + a model selector string.
15 fn req(provider: Option<ProviderKind>, model: Option<&str>) -> RouteRequest {
16 RouteRequest {
17 explicit_provider: provider,
18 model_selector: model.map(LogicalModelRef::from),
19 saved_provider_model: None,
20 base_url_override: None,
21 limit_overrides: Vec::new(),
22 }
23 }
24
25 fn models_dev_route_resolver() -> RouteResolver {
26 let raw = r#"{
27 "providers": {
28 "zai": {
29 "models": {
30 "glm-5.2": {
31 "id": "glm-5.2",
32 "base_model": "zhipuai/glm-5.2",
33 "default": true,
34 "modalities": { "input": ["text"], "output": ["text"] },
35 "limit": { "context": 1000000, "input": 900000, "output": 131072 }
36 }
37 }
38 },
39 "openrouter": {
40 "models": {
41 "z-ai/glm-5.2": {
42 "id": "z-ai/glm-5.2",
43 "base_model": "zhipuai/glm-5.2",
44 "modalities": { "input": ["text"], "output": ["text"] },
45 "limit": { "context": 128000, "output": 32768 }
46 }
47 }
48 }
49 }
50 }"#;
51 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
52 let mut offerings = catalog
53 .provider_offerings("zai")
54 .expect("zai provider offerings");
55 offerings.extend(
56 catalog
57 .provider_offerings("openrouter")
58 .expect("openrouter provider offerings"),
59 );
60 RouteResolver::from_offerings(offerings)
61 }
62
63 /// Concentrate ids pass through verbatim (plain catalog id, upstream
64 /// `provider/model` pin) and only the gateway's own `concentrate/` namespace
65 /// is stripped, so `concentrate/auto` reaches the gateway's `auto` router
66 /// while Codewhale's bare `auto` stays the resolver sentinel (provider
67 /// default). Every selector rides the Responses protocol.
68 #[test]
69 fn concentrate_passes_ids_through_and_strips_only_its_own_namespace() {
70 let r = models_dev_route_resolver();
71 for (raw, wire) in [
72 ("gpt-5.6-sol", "gpt-5.6-sol"),
73 ("openai/gpt-5.6-sol", "openai/gpt-5.6-sol"),
74 ("anthropic/claude-fable-5", "anthropic/claude-fable-5"),
75 ("concentrate/auto", "auto"),
76 ("concentrate/deepseek-v4-pro", "deepseek-v4-pro"),
77 ] {
78 let out = r
79 .resolve(&req(Some(ProviderKind::Concentrate), Some(raw)))
80 .unwrap_or_else(|e| panic!("{raw} should resolve on concentrate: {e}"));
81 assert_eq!(out.provider_kind(), ProviderKind::Concentrate, "{raw}");
82 assert_eq!(out.wire_model_id().as_str(), wire, "{raw} wire id");
83 assert_eq!(
84 out.protocol(),
85 RequestProtocol::Responses,
86 "{raw} must ride the Responses wire"
87 );
88 }
89
90 // Codewhale's own `auto` sentinel resolves to the provider default, never
91 // to a literal "auto" wire id.
92 let out = r
93 .resolve(&req(Some(ProviderKind::Concentrate), Some("auto")))
94 .expect("auto resolves");
95 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
96 assert_eq!(out.protocol(), RequestProtocol::Responses);
97 }
98
99 #[test]
100 fn provider_id_from_kind_uses_canonical_id() {
101 assert_eq!(
102 ProviderId::from_kind(ProviderKind::Deepseek).as_str(),
103 "deepseek"
104 );
105 assert_eq!(
106 ProviderId::from_kind(ProviderKind::Openrouter).as_str(),
107 "openrouter"
108 );
109 }
110
111 #[test]
112 fn model_id_and_wire_model_id_are_distinct_types() {
113 // This test asserts the values; the *type* distinction is enforced by the
114 // compiler: a function taking `WireModelId` rejects a `ModelId` argument.
115 let canonical = ModelId::from("deepseek-v4-pro");
116 let wire = WireModelId::from("deepseek-ai/DeepSeek-V4-Pro");
117 assert_eq!(canonical.as_str(), "deepseek-v4-pro");
118 assert_eq!(wire.as_str(), "deepseek-ai/DeepSeek-V4-Pro");
119 }
120
121 #[test]
122 fn logical_model_ref_auto_is_sentinel() {
123 assert!(LogicalModelRef::from("auto").is_auto());
124 assert!(!LogicalModelRef::from("deepseek-v4-pro").is_auto());
125 }
126
127 #[test]
128 fn logical_model_ref_namespace_hint_parses_curated_prefixes() {
129 let cases = [
130 ("deepseek-ai/DeepSeek-V4-Pro", NamespaceHint::DeepseekAi),
131 ("deepseek/deepseek-v4-pro", NamespaceHint::Deepseek),
132 ("anthropic/claude-foo", NamespaceHint::Anthropic),
133 ("openai/gpt-foo", NamespaceHint::Openai),
134 ("qwen/qwen-foo", NamespaceHint::Qwen),
135 ];
136 for (raw, expected) in cases {
137 assert_eq!(
138 LogicalModelRef::from(raw).namespace_hint(),
139 Some(expected),
140 "{raw} should parse to {expected:?}"
141 );
142 }
143 assert_eq!(LogicalModelRef::from("plain-model").namespace_hint(), None);
144 assert_eq!(LogicalModelRef::from("auto").namespace_hint(), None);
145 }
146
147 /// By construction there is NO path from a namespace prefix to a provider.
148 ///
149 /// This is enforced by the *absence* of any `From<NamespaceHint>` /
150 /// `From<LogicalModelRef>` for `ProviderId`. The following lines are the
151 /// canonical way to mint a `ProviderId` and demonstrate the only supported
152 /// source is an explicit `ProviderKind`, never a parsed prefix.
153 #[test]
154 fn no_namespace_hint_or_logical_ref_to_provider_id_conversion() {
155 let hint = LogicalModelRef::from("deepseek-ai/DeepSeek-V4-Pro").namespace_hint();
156 assert_eq!(hint, Some(NamespaceHint::DeepseekAi));
157 // A ProviderId may ONLY be built from an explicit ProviderKind or string,
158 // never derived from the hint above. (If a `From<NamespaceHint>` for
159 // `ProviderId` were ever added, this seam would silently break #2608.)
160 let provider = ProviderId::from_kind(ProviderKind::Together);
161 assert_eq!(provider.as_str(), "together");
162 }
163
164 #[test]
165 fn newtypes_serialize_transparently() {
166 let id = ProviderId::from("deepseek");
167 assert_eq!(serde_json::to_string(&id).unwrap(), "\"deepseek\"");
168 let wire = WireModelId::from("deepseek-ai/DeepSeek-V4-Pro");
169 assert_eq!(
170 serde_json::to_string(&wire).unwrap(),
171 "\"deepseek-ai/DeepSeek-V4-Pro\""
172 );
173 }
174
175 #[test]
176 fn resolver_reports_auth_as_unresolved_until_preflight() {
177 let candidate = RouteResolver::new()
178 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
179 .expect("route resolves");
180
181 assert_eq!(candidate.auth(), &ResolvedAuthSource::Unresolved);
182 assert_eq!(
183 serde_json::to_value(&candidate).expect("candidate serializes")["auth"],
184 "unresolved"
185 );
186 }
187
188 #[test]
189 fn resolver_applies_every_limit_override_field_before_construction() {
190 let mut request = req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro"));
191 request.limit_overrides = vec![
192 SourcedLimitOverride {
193 field: LimitField::ContextTokens,
194 value: Some(111),
195 source: OverrideSource::UserContextWindow,
196 },
197 SourcedLimitOverride {
198 field: LimitField::InputTokens,
199 value: Some(222),
200 source: OverrideSource::CodexPublicApiLimitStrip,
201 },
202 SourcedLimitOverride {
203 field: LimitField::OutputTokens,
204 value: Some(333),
205 source: OverrideSource::CodexRosterCorrection,
206 },
207 ];
208
209 let candidate = RouteResolver::new()
210 .resolve(&request)
211 .expect("route with overrides resolves");
212
213 assert_eq!(candidate.limits().context_tokens, Some(111));
214 assert_eq!(candidate.limits().input_tokens, Some(222));
215 assert_eq!(candidate.limits().output_tokens, Some(333));
216 }
217
218 #[test]
219 fn resolver_applies_repeated_limit_overrides_in_request_order() {
220 let mut request = req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro"));
221 request.limit_overrides = vec![
222 SourcedLimitOverride {
223 field: LimitField::ContextTokens,
224 value: Some(111),
225 source: OverrideSource::CodexRosterCorrection,
226 },
227 SourcedLimitOverride {
228 field: LimitField::ContextTokens,
229 value: None,
230 source: OverrideSource::CodexPublicApiLimitStrip,
231 },
232 SourcedLimitOverride {
233 field: LimitField::ContextTokens,
234 value: Some(333),
235 source: OverrideSource::UserContextWindow,
236 },
237 ];
238
239 let candidate = RouteResolver::new()
240 .resolve(&request)
241 .expect("route with ordered overrides resolves");
242
243 assert_eq!(candidate.limits().context_tokens, Some(333));
244 }
245
246 #[test]
247 fn resolver_records_exact_limit_override_provenance() {
248 let overrides = vec![
249 SourcedLimitOverride {
250 field: LimitField::InputTokens,
251 value: None,
252 source: OverrideSource::CodexPublicApiLimitStrip,
253 },
254 SourcedLimitOverride {
255 field: LimitField::ContextTokens,
256 value: Some(128_000),
257 source: OverrideSource::CodexRosterCorrection,
258 },
259 ];
260 let mut request = req(Some(ProviderKind::OpenaiCodex), None);
261 request.limit_overrides = overrides.clone();
262
263 let candidate = RouteResolver::new()
264 .resolve(&request)
265 .expect("Codex route with sourced overrides resolves");
266
267 assert_eq!(candidate.applied_limit_overrides(), overrides.as_slice());
268 }
269
270 #[test]
271 fn resolved_auth_source_existing_variants_keep_wire_compatibility() {
272 let cases = [
273 (ResolvedAuthSource::Cli, "cli"),
274 (ResolvedAuthSource::ConfigFile, "config_file"),
275 (ResolvedAuthSource::Keyring, "keyring"),
276 (ResolvedAuthSource::Env, "env"),
277 (ResolvedAuthSource::Command, "command"),
278 (ResolvedAuthSource::Secret, "secret"),
279 (ResolvedAuthSource::Missing, "missing"),
280 ];
281
282 for (source, wire) in cases {
283 let encoded = serde_json::to_string(&source).expect("auth source serializes");
284 assert_eq!(encoded, format!("\"{wire}\""));
285 let decoded: ResolvedAuthSource =
286 serde_json::from_str(&encoded).expect("auth source deserializes");
287 assert_eq!(decoded, source);
288 }
289 }
290
291 #[test]
292 fn descriptor_for_every_kind_has_nonempty_transport_facts() {
293 for kind in ProviderKind::ALL {
294 let d = ProviderDescriptor::for_kind(kind);
295 assert!(!d.id().as_str().is_empty(), "{kind:?} id empty");
296 assert!(
297 !d.default_base_url().is_empty(),
298 "{kind:?} default_base_url empty"
299 );
300 assert!(
301 !d.default_wire_model().as_str().is_empty(),
302 "{kind:?} default_wire_model empty"
303 );
304 if d.wire_policy().fixed().is_some() {
305 let _: RequestProtocol = d
306 .protocol_for_endpoint("chat")
307 .expect("fixed provider protocol");
308 }
309 }
310 }
311
312 #[test]
313 fn descriptor_protocol_matches_provider_wire() {
314 for kind in ProviderKind::ALL {
315 let d = ProviderDescriptor::for_kind(kind);
316 if matches!(
317 kind,
318 ProviderKind::Deepseek
319 | ProviderKind::OpencodeZen
320 | ProviderKind::OpencodeGo
321 | ProviderKind::Codewhale
322 ) {
323 assert_eq!(d.wire_policy(), crate::provider::WirePolicy::ModelAware);
324 assert_eq!(
325 d.protocol_for_endpoint("chat"),
326 Some(RequestProtocol::ChatCompletions)
327 );
328 assert_eq!(
329 d.protocol_for_endpoint("responses"),
330 Some(RequestProtocol::Responses)
331 );
332 if kind == ProviderKind::Deepseek {
333 assert_eq!(
334 d.protocol_for_endpoint("messages"),
335 Some(RequestProtocol::AnthropicMessages)
336 );
337 assert_eq!(d.protocol_for_endpoint("models/gemini"), None);
338 continue;
339 }
340 assert_eq!(
341 d.protocol_for_endpoint("messages"),
342 Some(RequestProtocol::AnthropicMessages)
343 );
344 assert_eq!(d.protocol_for_endpoint("models/gemini"), None);
345 continue;
346 }
347 assert_eq!(
348 d.protocol_for_endpoint("chat"),
349 kind.provider().wire_policy().fixed(),
350 "{kind:?} protocol must equal the provider wire policy"
351 );
352 let expected = match kind {
353 ProviderKind::OpenaiCodex | ProviderKind::Concentrate => {
354 Some(RequestProtocol::Responses)
355 }
356 ProviderKind::DeepseekAnthropic
357 | ProviderKind::Anthropic
358 | ProviderKind::MinimaxAnthropic
359 | ProviderKind::Openmodel
360 | ProviderKind::ModelstudioTokenPlanAnthropic
361 | ProviderKind::ModelstudioCodingPlanAnthropic => {
362 Some(RequestProtocol::AnthropicMessages)
363 }
364 _ => Some(RequestProtocol::ChatCompletions),
365 };
366 assert_eq!(
367 d.protocol_for_endpoint("chat"),
368 expected,
369 "{kind:?} protocol mismatch"
370 );
371 }
372 }
373
374 #[test]
375 fn resolver_explicit_provider_scoped_model_maps_to_wire_id() {
376 let r = RouteResolver::new();
377 let out = r
378 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
379 .expect("should resolve");
380 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
381 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
382 assert_eq!(
383 out.canonical_model().map(ModelId::as_str),
384 Some("deepseek-v4-pro")
385 );
386 }
387
388 #[test]
389 fn resolver_routes_only_official_deepseek_flash_over_responses() {
390 let resolver = RouteResolver::new();
391
392 let default_flash = resolver
393 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-flash")))
394 .expect("official default Flash route resolves");
395 assert_eq!(default_flash.protocol(), RequestProtocol::Responses);
396 assert_eq!(default_flash.endpoint().endpoint_key, "responses");
397 assert_eq!(
398 default_flash.capabilities().server_side_web_search,
399 CapabilityState::Supported
400 );
401
402 let flash = resolver
403 .resolve(&req(
404 Some(ProviderKind::Deepseek),
405 Some("deepseek-v4-flash"),
406 ))
407 .expect("official Flash route resolves");
408 assert_eq!(flash.protocol(), RequestProtocol::Responses);
409 assert_eq!(flash.endpoint().endpoint_key, "responses");
410 assert_eq!(
411 flash.capabilities().server_side_web_search,
412 CapabilityState::Supported
413 );
414
415 let pro = resolver
416 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
417 .expect("official Pro route resolves");
418 assert_eq!(pro.protocol(), RequestProtocol::ChatCompletions);
419 assert_eq!(pro.endpoint().endpoint_key, "chat");
420 assert_eq!(
421 pro.capabilities().server_side_web_search,
422 CapabilityState::Supported
423 );
424
425 let future = resolver
426 .resolve(&req(
427 Some(ProviderKind::Deepseek),
428 Some("deepseek-v5-future"),
429 ))
430 .expect("unknown future model preserves direct-provider pass-through");
431 assert_eq!(future.protocol(), RequestProtocol::ChatCompletions);
432 assert_eq!(future.endpoint().endpoint_key, "chat");
433
434 let legacy_unknown = resolver
435 .resolve(&req(
436 Some(ProviderKind::Deepseek),
437 Some("deepseek-coder-future"),
438 ))
439 .expect("unknown unversioned model preserves historical Chat pass-through");
440 assert_eq!(legacy_unknown.protocol(), RequestProtocol::ChatCompletions);
441 assert_eq!(legacy_unknown.endpoint().endpoint_key, "chat");
442
443 let custom = resolver
444 .resolve(&RouteRequest {
445 explicit_provider: Some(ProviderKind::Deepseek),
446 model_selector: Some(LogicalModelRef::from("deepseek-v4-flash")),
447 saved_provider_model: None,
448 base_url_override: Some("https://compatible.example/v1".to_string()),
449 limit_overrides: Vec::new(),
450 })
451 .expect("custom compatible Flash route remains pass-through");
452 assert_eq!(custom.protocol(), RequestProtocol::ChatCompletions);
453 assert_eq!(custom.endpoint().endpoint_key, "chat");
454 assert_eq!(
455 custom.capabilities().server_side_web_search,
456 CapabilityState::Unknown
457 );
458 }
459
460 #[test]
461 fn resolver_sources_deepseek_files_api_only_on_exact_official_flash_routes() {
462 let resolver = RouteResolver::new();
463
464 for model in ["deepseek-flash", "deepseek-v4-flash"] {
465 let route = resolver
466 .resolve(&req(Some(ProviderKind::Deepseek), Some(model)))
467 .expect("official Flash route resolves");
468 assert_eq!(
469 route.capabilities().files_api,
470 CapabilityState::Supported,
471 "{model}"
472 );
473 }
474
475 for model in [
476 "deepseek-v4-pro",
477 "deepseek-v4-flash-vision-exp",
478 "deepseek-v5-future",
479 ] {
480 let route = resolver
481 .resolve(&req(Some(ProviderKind::Deepseek), Some(model)))
482 .expect("direct DeepSeek route resolves");
483 assert_eq!(
484 route.capabilities().files_api,
485 CapabilityState::Unknown,
486 "{model} must not inherit the Files API fact"
487 );
488 }
489
490 let custom = resolver
491 .resolve(&RouteRequest {
492 explicit_provider: Some(ProviderKind::Deepseek),
493 model_selector: Some(LogicalModelRef::from("deepseek-flash")),
494 saved_provider_model: None,
495 base_url_override: Some("https://compatible.example/v1".to_string()),
496 limit_overrides: Vec::new(),
497 })
498 .expect("custom compatible endpoint resolves");
499 assert_eq!(custom.capabilities().files_api, CapabilityState::Unknown);
500
501 let aggregator = resolver
502 .resolve(&req(
503 Some(ProviderKind::Openrouter),
504 Some("deepseek/deepseek-v4-flash"),
505 ))
506 .expect("aggregator route resolves");
507 assert_eq!(
508 aggregator.capabilities().files_api,
509 CapabilityState::Unknown
510 );
511 }
512
513 #[test]
514 fn resolver_uncatalogued_deepseek_preview_keeps_chat_and_verbatim_model() {
515 let model = "deepseek-v4.1-flash-expires-on-0910";
516 for base_url in [
517 "https://api.deepseek.com",
518 "https://api.deepseek.com/v1",
519 "https://api.deepseek.com/beta",
520 ] {
521 let route = RouteResolver::new()
522 .resolve(&RouteRequest {
523 base_url_override: Some(base_url.to_string()),
524 ..req(Some(ProviderKind::Deepseek), Some(model))
525 })
526 .expect("uncatalogued preview keeps direct-provider pass-through");
527 assert_eq!(route.provider_kind(), ProviderKind::Deepseek);
528 assert_eq!(route.wire_model_id().as_str(), model);
529 assert!(
530 route.canonical_model().is_none(),
531 "no fallback to a known model"
532 );
533 assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
534 assert_eq!(route.endpoint().endpoint_key, "chat");
535 assert_eq!(route.endpoint().base_url, base_url);
536 assert_eq!(route.capabilities().image_input, CapabilityState::Unknown);
537 assert_eq!(route.limits().output_tokens, None);
538 }
539 }
540
541 #[test]
542 fn resolver_routes_deepseek_vision_exp_over_chat_with_image_input() {
543 for base_url_override in [
544 None,
545 Some("https://api.deepseek.com/v1"),
546 Some("https://api.deepseek.com/beta"),
547 ] {
548 let route = RouteResolver::new()
549 .resolve(&RouteRequest {
550 explicit_provider: Some(ProviderKind::Deepseek),
551 model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")),
552 saved_provider_model: None,
553 base_url_override: base_url_override.map(str::to_string),
554 limit_overrides: Vec::new(),
555 })
556 .expect("experimental vision route resolves");
557
558 assert_eq!(route.provider_kind(), ProviderKind::Deepseek);
559 assert_eq!(
560 route.canonical_model().map(ModelId::as_str),
561 Some("deepseek-v4-flash-vision-exp")
562 );
563 assert_eq!(
564 route.wire_model_id().as_str(),
565 "deepseek-v4-flash-vision-exp"
566 );
567 assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
568 assert_eq!(route.endpoint().endpoint_key, "chat");
569 assert_eq!(
570 route.capabilities().image_input,
571 CapabilityState::Supported,
572 "official endpoint {base_url_override:?} must retain the exact vision fact"
573 );
574 assert_eq!(route.capabilities().reasoning, CapabilityState::Supported);
575 assert_eq!(
576 route.capabilities().native_tool_calls,
577 CapabilityState::Supported
578 );
579 assert_eq!(route.limits().context_tokens, Some(1_000_000));
580 assert_eq!(route.limits().output_tokens, Some(384_000));
581 }
582 }
583
584 #[test]
585 fn resolver_keeps_deepseek_flash_image_input_on_the_official_route() {
586 // #6421: the curated DeepSeek rows win over the Models.dev asset, so the
587 // resolved route (what the engine strips images against) must carry the
588 // documented Flash vision fact itself — default selector included.
589 for selector in [None, Some("deepseek-flash"), Some("deepseek-v4-flash")] {
590 let route = RouteResolver::new()
591 .resolve(&req(Some(ProviderKind::Deepseek), selector))
592 .expect("official DeepSeek Flash route resolves");
593 assert_eq!(
594 route.capabilities().image_input,
595 CapabilityState::Supported,
596 "{selector:?} must keep image input on the official endpoint"
597 );
598 }
599 let pro = RouteResolver::new()
600 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
601 .expect("official DeepSeek Pro route resolves");
602 assert_eq!(
603 pro.capabilities().image_input,
604 CapabilityState::Unsupported,
605 "the Flash correction must not widen Pro"
606 );
607 }
608
609 #[test]
610 fn resolver_keeps_deepseek_flash_image_input_on_the_messages_route() {
611 // #6521 review: Flash vision is documented for Messages too. Both
612 // spellings of that route must resolve with image input — the legacy
613 // `deepseek-anthropic` kind and canonical DeepSeek with `wire =
614 // "anthropic"` (which selects the `/anthropic` base URL) — while a custom
615 // compatible host and Pro stay unwidened.
616 let messages = |kind: ProviderKind, selector: &str, base_url: Option<&str>| {
617 let mut request = req(Some(kind), Some(selector));
618 request.base_url_override = base_url.map(str::to_string);
619 RouteResolver::new()
620 .resolve(&request)
621 .expect("DeepSeek Messages route resolves")
622 .capabilities()
623 .image_input
624 };
625 for selector in ["deepseek-flash", "deepseek-v4-flash"] {
626 assert_eq!(
627 messages(ProviderKind::DeepseekAnthropic, selector, None),
628 CapabilityState::Supported,
629 "deepseek-anthropic {selector}"
630 );
631 assert_eq!(
632 messages(
633 ProviderKind::Deepseek,
634 selector,
635 Some("https://api.deepseek.com/anthropic")
636 ),
637 CapabilityState::Supported,
638 "deepseek wire=anthropic {selector}"
639 );
640 assert_ne!(
641 messages(
642 ProviderKind::Deepseek,
643 selector,
644 Some("https://proxy.example.com/anthropic")
645 ),
646 CapabilityState::Supported,
647 "a custom compatible host is not DeepSeek's documented route"
648 );
649 }
650 assert_ne!(
651 messages(ProviderKind::DeepseekAnthropic, "deepseek-v4-pro", None),
652 CapabilityState::Supported,
653 "Pro stays text-only on Messages"
654 );
655 }
656
657 #[test]
658 fn resolver_keeps_custom_deepseek_same_name_capabilities_unverified() {
659 let route = RouteResolver::new()
660 .resolve(&RouteRequest {
661 explicit_provider: Some(ProviderKind::Deepseek),
662 model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")),
663 saved_provider_model: None,
664 base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()),
665 limit_overrides: Vec::new(),
666 })
667 .expect("same-name custom proxy route resolves");
668
669 assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
670 assert_eq!(route.endpoint().endpoint_key, "chat");
671 assert_eq!(
672 route.wire_model_id().as_str(),
673 "deepseek-v4-flash-vision-exp"
674 );
675 assert_eq!(
676 route.capabilities(),
677 RouteCapabilities::default(),
678 "a custom proxy cannot inherit first-party capability facts by reusing the model id"
679 );
680 }
681
682 #[test]
683 fn resolver_aggregator_preserves_prefixed_wire_id_without_inferring_deepseek() {
684 let r = RouteResolver::new();
685 let out = r
686 .resolve(&req(
687 Some(ProviderKind::Together),
688 Some("deepseek-ai/DeepSeek-V4-Pro"),
689 ))
690 .expect("aggregator should resolve");
691 // Provider stays Together, NOT Deepseek, despite the deepseek-ai/ prefix.
692 assert_eq!(out.provider_kind(), ProviderKind::Together);
693 assert_ne!(out.provider_kind(), ProviderKind::Deepseek);
694 // Wire id preserved verbatim.
695 assert_eq!(out.wire_model_id().as_str(), "deepseek-ai/DeepSeek-V4-Pro");
696 }
697
698 #[test]
699 fn resolver_openrouter_keeps_provider_for_every_namespace_prefix() {
700 let r = RouteResolver::new();
701 let prefixes = [
702 "deepseek-ai/DeepSeek-V4-Pro",
703 "deepseek/deepseek-v4-pro",
704 "anthropic/claude-foo",
705 "openai/gpt-foo",
706 "qwen/qwen-foo",
707 ];
708 for raw in prefixes {
709 let selector = LogicalModelRef::from(raw);
710 // The selector DOES carry a namespace hint...
711 assert!(
712 selector.namespace_hint().is_some(),
713 "{raw} should have a namespace hint"
714 );
715 let out = r
716 .resolve(&req(Some(ProviderKind::Openrouter), Some(raw)))
717 .unwrap_or_else(|e| panic!("{raw} should resolve on openrouter: {e}"));
718 // ...but the provider stays Openrouter regardless.
719 assert_eq!(
720 out.provider_kind(),
721 ProviderKind::Openrouter,
722 "{raw} must not change provider"
723 );
724 assert_eq!(out.wire_model_id().as_str(), raw, "{raw} wire id verbatim");
725 }
726 }
727
728 #[test]
729 fn resolver_no_explicit_provider_does_not_infer_deepseek_from_prefix() {
730 let r = RouteResolver::new();
731 // explicit_provider=None => default scope (Deepseek). A prefixed selector
732 // is foreign for the strict-direct default, so it ERRORS rather than being
733 // silently accepted as a deepseek model: the prefix never *selects* it.
734 let out = r.resolve(&req(None, Some("deepseek/deepseek-v4-pro")));
735 match out {
736 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
737 assert_eq!(provider.as_str(), "deepseek");
738 assert_eq!(model, "deepseek/deepseek-v4-pro");
739 }
740 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
741 }
742 }
743
744 #[test]
745 fn resolver_auto_is_sentinel_not_literal_model() {
746 let r = RouteResolver::new();
747 let out = r
748 .resolve(&req(Some(ProviderKind::Deepseek), Some("auto")))
749 .expect("auto should resolve");
750 // The logical selector is the auto sentinel...
751 assert!(out.logical_model().is_auto());
752 // ...and "auto" is NOT put on the wire as a literal model.
753 assert_ne!(out.wire_model_id().as_str(), "auto");
754 assert_eq!(out.wire_model_id().as_str(), "deepseek-flash");
755 assert_eq!(out.protocol(), RequestProtocol::Responses);
756 }
757
758 #[test]
759 fn resolver_can_use_models_dev_offering_for_provider_scoped_route() {
760 let r = models_dev_route_resolver();
761 let out = r
762 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
763 .expect("Models.dev-backed Z.ai route should resolve");
764
765 assert_eq!(out.provider_kind(), ProviderKind::Zai);
766 assert_eq!(out.provider_id().as_str(), "zai");
767 assert_eq!(out.wire_model_id().as_str(), "glm-5.2");
768 assert_eq!(
769 out.canonical_model().map(ModelId::as_str),
770 Some("zhipuai/glm-5.2")
771 );
772 }
773
774 #[test]
775 fn resolver_auto_uses_models_dev_default_offering_when_available() {
776 let r = models_dev_route_resolver();
777 let out = r
778 .resolve(&req(Some(ProviderKind::Zai), Some("auto")))
779 .expect("auto should resolve through catalog default");
780
781 assert!(out.logical_model().is_auto());
782 assert_eq!(
783 out.wire_model_id().as_str(),
784 "glm-5.2",
785 "catalog default should win over the built-in Z.ai spelling"
786 );
787 assert_eq!(
788 out.canonical_model().map(ModelId::as_str),
789 Some("zhipuai/glm-5.2")
790 );
791 }
792
793 #[test]
794 fn resolver_auto_falls_back_to_descriptor_default_without_catalog_default() {
795 // Z.ai offerings exist in the catalog snapshot but none is marked
796 // `default: true`. `auto` must then fall back to the provider descriptor's
797 // built-in default wire model rather than picking an arbitrary catalog row.
798 let raw = r#"{
799 "providers": {
800 "zai": {
801 "models": {
802 "glm-5-turbo": {
803 "id": "glm-5-turbo",
804 "modalities": { "input": ["text"], "output": ["text"] }
805 }
806 }
807 }
808 }
809 }"#;
810 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
811 let offerings = catalog
812 .provider_offerings("zai")
813 .expect("zai provider offerings");
814 let r = RouteResolver::from_offerings(offerings);
815
816 let out = r
817 .resolve(&req(Some(ProviderKind::Zai), Some("auto")))
818 .expect("auto should resolve to the descriptor default");
819
820 assert!(out.logical_model().is_auto());
821 assert_eq!(
822 out.wire_model_id().as_str(),
823 "GLM-5.3",
824 "no catalog default → descriptor built-in default wins"
825 );
826 assert_eq!(
827 out.canonical_model(),
828 None,
829 "descriptor fallback carries no catalog canonical link"
830 );
831 }
832
833 #[test]
834 fn resolver_models_dev_prefixed_wire_id_stays_inside_provider_scope() {
835 let r = models_dev_route_resolver();
836 let out = r
837 .resolve(&req(Some(ProviderKind::Openrouter), Some("z-ai/glm-5.2")))
838 .expect("OpenRouter Models.dev row should resolve");
839
840 assert_eq!(out.provider_kind(), ProviderKind::Openrouter);
841 assert_ne!(out.provider_kind(), ProviderKind::Zai);
842 assert_eq!(out.wire_model_id().as_str(), "z-ai/glm-5.2");
843 assert_eq!(
844 out.canonical_model().map(ModelId::as_str),
845 Some("zhipuai/glm-5.2")
846 );
847 }
848
849 #[test]
850 fn resolver_carries_models_dev_limits_into_ready_candidate() {
851 let r = models_dev_route_resolver();
852 let out = r
853 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
854 .expect("Z.AI Models.dev row should resolve");
855
856 assert_eq!(out.limits().context_tokens, Some(1_000_000));
857 assert_eq!(out.limits().input_tokens, Some(900_000));
858 assert_eq!(out.limits().output_tokens, Some(131_072));
859 assert!(out.limits().has_known_limit());
860 }
861
862 #[test]
863 fn minimax_anthropic_routes_use_catalog_limits_and_messages_protocol() {
864 let resolver = RouteResolver::new();
865
866 for (model, context) in [("MiniMax-M3", 1_000_000), ("MiniMax-M2.7", 204_800)] {
867 let route = resolver
868 .resolve(&req(Some(ProviderKind::MinimaxAnthropic), Some(model)))
869 .expect("MiniMax Messages route should resolve");
870
871 assert_eq!(route.provider_kind(), ProviderKind::MinimaxAnthropic);
872 assert_eq!(route.wire_model_id().as_str(), model);
873 assert_eq!(route.protocol(), RequestProtocol::AnthropicMessages);
874 assert_eq!(
875 route.endpoint().protocol,
876 RequestProtocol::AnthropicMessages
877 );
878 assert_eq!(
879 route.endpoint().base_url,
880 "https://api.minimax.io/anthropic"
881 );
882 assert_eq!(route.limits().context_tokens, Some(context));
883 }
884 }
885
886 #[test]
887 fn resolver_keeps_limits_provider_scoped_for_same_canonical_model() {
888 let r = models_dev_route_resolver();
889 let direct = r
890 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
891 .expect("direct Z.AI route should resolve");
892 let hosted = r
893 .resolve(&req(Some(ProviderKind::Openrouter), Some("z-ai/glm-5.2")))
894 .expect("hosted OpenRouter route should resolve");
895
896 assert_eq!(
897 direct.canonical_model().map(ModelId::as_str),
898 hosted.canonical_model().map(ModelId::as_str)
899 );
900 assert_eq!(direct.limits().context_tokens, Some(1_000_000));
901 assert_eq!(hosted.limits().context_tokens, Some(128_000));
902 assert_eq!(hosted.limits().output_tokens, Some(32_768));
903 }
904
905 #[test]
906 fn resolver_strict_direct_rejects_clearly_foreign_selector() {
907 let r = RouteResolver::new();
908 let out = r.resolve(&req(Some(ProviderKind::Zai), Some("anthropic/claude-foo")));
909 match out {
910 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
911 assert_eq!(provider.as_str(), "zai");
912 assert_eq!(model, "anthropic/claude-foo");
913 }
914 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
915 }
916 }
917
918 #[test]
919 fn resolver_direct_owned_row_match_survives_casing_mismatch() {
920 let r = RouteResolver::new();
921 let out = r
922 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
923 .expect("lowercase selector on the owning Z.ai row must resolve");
924 assert_eq!(out.provider_kind(), ProviderKind::Zai);
925 assert_eq!(out.wire_model_id().as_str(), "GLM-5.2");
926 assert!(out.limits().has_known_limit());
927
928 let saved = RouteRequest {
929 saved_provider_model: Some(WireModelId::from("glm-5.2")),
930 ..req(Some(ProviderKind::Zai), None)
931 };
932 let out = r
933 .resolve(&saved)
934 .expect("saved lowercase Z.ai model must resolve");
935 assert_eq!(out.wire_model_id().as_str(), "GLM-5.2");
936
937 let out = r
938 .resolve(&req(Some(ProviderKind::Deepseek), Some("Deepseek-V4-Pro")))
939 .expect("DeepSeek's own casing variant must resolve");
940 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
941 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
942
943 let custom = RouteRequest {
944 explicit_provider: Some(ProviderKind::Zai),
945 model_selector: Some(LogicalModelRef::from("glm-5.2")),
946 saved_provider_model: None,
947 base_url_override: Some("https://compatible.example.test/v1".to_string()),
948 limit_overrides: Vec::new(),
949 };
950 let out = r
951 .resolve(&custom)
952 .expect("custom endpoint keeps model pass-through semantics");
953 assert_eq!(out.wire_model_id().as_str(), "glm-5.2");
954 assert!(!out.limits().has_known_limit());
955 }
956
957 #[test]
958 fn resolver_direct_casefold_match_requires_one_owned_row() {
959 let raw = r#"{
960 "providers": {
961 "zai": {
962 "models": {
963 "CaseModel": {
964 "id": "CaseModel",
965 "modalities": { "input": ["text"], "output": ["text"] },
966 "limit": { "context": 1000 }
967 },
968 "casemodel": {
969 "id": "casemodel",
970 "modalities": { "input": ["text"], "output": ["text"] },
971 "limit": { "context": 2000 }
972 }
973 }
974 }
975 }
976 }"#;
977 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
978 let offerings = catalog
979 .provider_offerings("zai")
980 .expect("Z.ai provider offerings");
981 let r = RouteResolver::from_offerings(offerings);
982
983 let out = r
984 .resolve(&req(Some(ProviderKind::Zai), Some("CASEMODEL")))
985 .expect("ambiguous casefold stays an unknown direct model");
986 assert_eq!(out.wire_model_id().as_str(), "CASEMODEL");
987 assert!(
988 !out.limits().has_known_limit(),
989 "ambiguous rows must not lend arbitrary catalog metadata"
990 );
991 }
992
993 #[test]
994 fn resolver_strict_direct_rejects_other_provider_known_bare_offering() {
995 let r = RouteResolver::new();
996 let out = r.resolve(&req(Some(ProviderKind::Zai), Some("deepseek-v4-pro")));
997 match out {
998 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
999 assert_eq!(provider.as_str(), "zai");
1000 assert_eq!(model, "deepseek-v4-pro");
1001 }
1002 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
1003 }
1004 }
1005
1006 #[test]
1007 fn resolver_custom_endpoint_allows_namespaced_selector_for_strict_provider() {
1008 let r = RouteResolver::new();
1009 let request = RouteRequest {
1010 explicit_provider: Some(ProviderKind::Deepseek),
1011 model_selector: Some(LogicalModelRef::from("vendor/custom-coder")),
1012 saved_provider_model: None,
1013 base_url_override: Some("https://example.local/v1".to_string()),
1014 limit_overrides: Vec::new(),
1015 };
1016 let out = r
1017 .resolve(&request)
1018 .expect("custom endpoint should defer model validation upstream");
1019 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
1020 assert_eq!(out.wire_model_id().as_str(), "vendor/custom-coder");
1021 assert_eq!(out.endpoint().base_url, "https://example.local/v1");
1022 }
1023
1024 #[test]
1025 fn resolver_treats_every_official_deepseek_endpoint_as_strict_direct() {
1026 let resolver = RouteResolver::new();
1027 for base_url in [
1028 "https://api.deepseek.com",
1029 "https://api.deepseek.com/v1/",
1030 "https://api.deepseek.com/beta",
1031 ] {
1032 let request = RouteRequest {
1033 explicit_provider: Some(ProviderKind::Deepseek),
1034 model_selector: Some(LogicalModelRef::from("anthropic/claude-foo")),
1035 saved_provider_model: None,
1036 base_url_override: Some(base_url.to_string()),
1037 limit_overrides: Vec::new(),
1038 };
1039 assert!(
1040 matches!(
1041 resolver.resolve(&request),
1042 Err(RouteError::ForeignModelForDirectProvider { .. })
1043 ),
1044 "official endpoint {base_url} must retain DeepSeek's strict namespace"
1045 );
1046 }
1047 }
1048
1049 #[test]
1050 fn resolver_does_not_trust_deepseek_hostname_substrings() {
1051 let resolver = RouteResolver::new();
1052 let request = RouteRequest {
1053 explicit_provider: Some(ProviderKind::Deepseek),
1054 model_selector: Some(LogicalModelRef::from("vendor/custom-coder")),
1055 saved_provider_model: None,
1056 base_url_override: Some("https://api.deepseek.com.evil.example/v1".to_string()),
1057 limit_overrides: Vec::new(),
1058 };
1059 let route = resolver
1060 .resolve(&request)
1061 .expect("lookalike host must be treated as a custom endpoint");
1062 assert_eq!(route.wire_model_id().as_str(), "vendor/custom-coder");
1063 }
1064
1065 #[test]
1066 fn resolver_explicit_custom_with_base_url_override_passes_model_through_verbatim() {
1067 // #1519: an explicit `Custom` provider with a base_url override resolves via
1068 // the LocalOrCustom pass-through, preserving even a namespaced selector as
1069 // the verbatim wire id and binding the override endpoint + Chat Completions.
1070 let r = RouteResolver::new();
1071 let request = RouteRequest {
1072 explicit_provider: Some(ProviderKind::Custom),
1073 model_selector: Some(LogicalModelRef::from("vendor/custom-model-v1")),
1074 saved_provider_model: None,
1075 base_url_override: Some("https://api.example.com/v1".to_string()),
1076 limit_overrides: Vec::new(),
1077 };
1078 let out = r
1079 .resolve(&request)
1080 .expect("custom provider should resolve via pass-through");
1081 assert_eq!(out.provider_kind(), ProviderKind::Custom);
1082 assert_eq!(out.provider_id().as_str(), "custom");
1083 assert_eq!(out.wire_model_id().as_str(), "vendor/custom-model-v1");
1084 assert_eq!(out.endpoint().base_url, "https://api.example.com/v1");
1085 assert_eq!(
1086 out.protocol(),
1087 crate::route::RequestProtocol::ChatCompletions
1088 );
1089 assert!(out.validation().ok);
1090 assert!(out.validation().messages.is_empty());
1091 }
1092
1093 #[test]
1094 fn resolver_strict_direct_rejects_models_dev_offering_from_another_provider() {
1095 let r = models_dev_route_resolver();
1096 let out = r.resolve(&req(Some(ProviderKind::Deepseek), Some("glm-5.2")));
1097 match out {
1098 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
1099 assert_eq!(provider.as_str(), "deepseek");
1100 assert_eq!(model, "glm-5.2");
1101 }
1102 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
1103 }
1104 }
1105
1106 // ---------------------------------------------------------------------------
1107 // #3385: the DEFAULT resolver now sources the bundled Models.dev catalog asset,
1108 // so real provider/model facts (context windows) reach candidates.
1109 // ---------------------------------------------------------------------------
1110
1111 #[test]
1112 fn default_resolver_yields_real_facts_from_bundled_catalog() {
1113 let r = RouteResolver::new();
1114
1115 // A GLM row (Z.ai) resolves to a real, non-default context window — proof
1116 // the bundled asset feeds the default resolver rather than the old 4-row
1117 // seam, which only knew deepseek/together/openrouter and left everything
1118 // else at `RouteLimits::default()` (unknown).
1119 let glm = r
1120 .resolve(&req(Some(ProviderKind::Zai), Some("GLM-5.2")))
1121 .expect("Z.ai GLM-5.2 should resolve from the bundled catalog");
1122 assert_eq!(glm.provider_kind(), ProviderKind::Zai);
1123 assert_eq!(glm.wire_model_id().as_str(), "GLM-5.2");
1124 assert_eq!(
1125 glm.limits().context_tokens,
1126 Some(1_000_000),
1127 "GLM-5.2 must carry its real context window, not the unknown default"
1128 );
1129 assert_eq!(glm.limits().output_tokens, Some(131_072));
1130 assert!(glm.limits().has_known_limit());
1131
1132 // A Kimi row (Moonshot) likewise resolves with its real window — a model
1133 // the 4-row seam never knew about at all.
1134 let kimi_k27 = r
1135 .resolve(&req(Some(ProviderKind::Moonshot), Some("kimi-k2.7-code")))
1136 .expect("Moonshot kimi-k2.7-code should resolve from the bundled catalog");
1137 assert_eq!(kimi_k27.limits().context_tokens, Some(262_144));
1138 assert_eq!(kimi_k27.limits().output_tokens, Some(262_144));
1139
1140 let kimi_k3 = r
1141 .resolve(&req(Some(ProviderKind::Moonshot), Some("kimi-k3")))
1142 .expect("Moonshot kimi-k3 should resolve from the bundled catalog");
1143 assert_eq!(kimi_k3.limits().context_tokens, Some(1_048_576));
1144 assert_eq!(kimi_k3.limits().output_tokens, Some(1_048_576));
1145
1146 // With the #3085 pricing keystone present on the release branch, the asset's
1147 // provider-scoped `cost` now projects onto the candidate via
1148 // `route_pricing_sku`, so a priced Z.ai row carries a real per-token meter
1149 // rather than `UnknownOrStale` — the "lighting up" that #3385 + #3085 deliver
1150 // together.
1151 let glm51 = r
1152 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.1")))
1153 .expect("Z.ai glm-5.1 should resolve from the bundled catalog");
1154 assert_eq!(glm51.limits().context_tokens, Some(200_000));
1155 assert!(matches!(
1156 glm51.pricing(),
1157 Some(super::candidate::PricingSku::Token { .. })
1158 ));
1159 }
1160
1161 #[test]
1162 fn default_resolver_preserves_seam_canonical_joins() {
1163 // The bundled asset is merged UNDER the hand seam, so the seam's curated
1164 // canonical-model joins still win: a DeepSeek-native selector keeps its
1165 // canonical id, and an aggregator-prefixed wire id still maps back to the
1166 // canonical DeepSeek model. (This is what keeps the existing route
1167 // invariants green after the asset was wired in.)
1168 let r = RouteResolver::new();
1169
1170 let direct = r
1171 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1172 .expect("deepseek-v4-pro resolves");
1173 assert_eq!(
1174 direct.canonical_model().map(ModelId::as_str),
1175 Some("deepseek-v4-pro")
1176 );
1177
1178 let hosted = r
1179 .resolve(&req(
1180 Some(ProviderKind::Together),
1181 Some("deepseek-ai/DeepSeek-V4-Pro"),
1182 ))
1183 .expect("together hosted deepseek resolves");
1184 assert_eq!(
1185 hosted.canonical_model().map(ModelId::as_str),
1186 Some("deepseek-v4-pro"),
1187 "seam canonical join must survive the asset merge"
1188 );
1189 assert_eq!(
1190 hosted.wire_model_id().as_str(),
1191 "deepseek-ai/DeepSeek-V4-Pro"
1192 );
1193 }
1194
1195 #[test]
1196 fn together_inkling_aliases_use_the_exact_wire_identity_without_invented_metadata() {
1197 let resolver = RouteResolver::new();
1198
1199 for requested in ["inkling", "together-inkling", "thinkingmachines/inkling"] {
1200 let route = resolver
1201 .resolve(&req(Some(ProviderKind::Together), Some(requested)))
1202 .expect("Together Inkling route should resolve");
1203 assert_eq!(route.provider_kind(), ProviderKind::Together, "{requested}");
1204 assert_eq!(
1205 route.wire_model_id().as_str(),
1206 "thinkingmachines/inkling",
1207 "{requested}"
1208 );
1209 assert!(route.canonical_model().is_none(), "{requested}");
1210 assert!(!route.limits().has_known_limit(), "{requested}");
1211 assert!(matches!(
1212 route.pricing(),
1213 Some(super::candidate::PricingSku::UnknownOrStale)
1214 ));
1215 }
1216 }
1217
1218 #[test]
1219 fn together_custom_endpoint_preserves_its_explicit_model_id() {
1220 let resolver = RouteResolver::new();
1221 let route = resolver
1222 .resolve(&RouteRequest {
1223 explicit_provider: Some(ProviderKind::Together),
1224 model_selector: Some(LogicalModelRef::from("inkling")),
1225 saved_provider_model: None,
1226 base_url_override: Some("http://127.0.0.1:8000/v1".to_string()),
1227 limit_overrides: Vec::new(),
1228 })
1229 .expect("custom Together-compatible endpoint should resolve");
1230
1231 assert_eq!(route.wire_model_id().as_str(), "inkling");
1232 }
1233
1234 #[test]
1235 fn openrouter_qwen37_plus_aliases_use_exact_catalog_wire_identity() {
1236 let resolver = RouteResolver::new();
1237
1238 for requested in ["qwen3.7-plus", "qwen-3.7-plus", "qwen/qwen3.7-plus"] {
1239 let route = resolver
1240 .resolve(&req(Some(ProviderKind::Openrouter), Some(requested)))
1241 .expect("OpenRouter Qwen 3.7 Plus route should resolve");
1242 assert_eq!(route.wire_model_id().as_str(), "qwen/qwen3.7-plus");
1243 assert_eq!(route.limits().context_tokens, Some(1_000_000));
1244 assert!(matches!(
1245 route.pricing(),
1246 Some(super::candidate::PricingSku::Token {
1247 input_per_mtok: Some(_),
1248 output_per_mtok: Some(_)
1249 })
1250 ));
1251 }
1252 }
1253
1254 #[test]
1255 fn openrouter_custom_endpoint_preserves_qwen37_alias() {
1256 let route = RouteResolver::new()
1257 .resolve(&RouteRequest {
1258 explicit_provider: Some(ProviderKind::Openrouter),
1259 model_selector: Some(LogicalModelRef::from("qwen3.7-plus")),
1260 saved_provider_model: None,
1261 base_url_override: Some("https://gateway.example.test/v1".to_string()),
1262 limit_overrides: Vec::new(),
1263 })
1264 .expect("custom OpenRouter-compatible endpoint should resolve");
1265
1266 assert_eq!(route.wire_model_id().as_str(), "qwen3.7-plus");
1267 }
1268
1269 #[test]
1270 fn opencode_go_resolver_preserves_documented_chat_routes() {
1271 let resolver = RouteResolver::new();
1272 // Literal review fixture: the 2026-09-08 endpoint table plus previously
1273 // accepted IDs retained for compatibility. Do not derive from the contract.
1274 let chat_models = [
1275 "deepseek-v4-pro",
1276 "grok-4.5",
1277 "glm-5.2",
1278 "glm-5.1",
1279 "kimi-k3",
1280 "kimi-k2.7-code",
1281 "kimi-k2.6",
1282 "deepseek-v4-flash",
1283 "mimo-v2.5",
1284 "mimo-v2.5-pro",
1285 "glm-5.3-flash",
1286 "glm-5.3",
1287 "longcat-2.0",
1288 "deepseek-v4-flash-vision-exp",
1289 "hy4-preview",
1290 "hy3",
1291 "omen-alpha",
1292 ];
1293 assert!(
1294 chat_models
1295 .iter()
1296 .all(|id| crate::opencode_go_models().contains(id))
1297 );
1298
1299 for model in chat_models {
1300 for requested in [model.to_string(), format!("opencode-go/{model}")] {
1301 let route = resolver
1302 .resolve(&req(Some(ProviderKind::OpencodeGo), Some(&requested)))
1303 .unwrap_or_else(|error| panic!("{requested} should resolve: {error}"));
1304 assert_eq!(
1305 route.provider_kind(),
1306 ProviderKind::OpencodeGo,
1307 "{requested}"
1308 );
1309 assert_eq!(route.wire_model_id().as_str(), model, "{requested}");
1310 assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
1311 }
1312 }
1313
1314 let automatic = resolver
1315 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("auto")))
1316 .expect("OpenCode Go auto should resolve to its Chat default");
1317 assert_eq!(automatic.wire_model_id().as_str(), "deepseek-v4-pro");
1318 }
1319
1320 #[test]
1321 fn opencode_go_resolver_uses_documented_protocols_and_refuses_unknown_models() {
1322 let resolver = RouteResolver::new();
1323 let fixtures = [
1324 ("deepseek-v4.1-flash", RequestProtocol::ChatCompletions),
1325 ("grok-4.6", RequestProtocol::Responses),
1326 ("gpt-5.6-luna", RequestProtocol::Responses),
1327 ("muse-spark-1.3-contributor", RequestProtocol::Responses),
1328 ("muse-spark-1.2-contributor", RequestProtocol::Responses),
1329 ("minimax-m3", RequestProtocol::AnthropicMessages),
1330 ("minimax-m2.7", RequestProtocol::AnthropicMessages),
1331 ("minimax-m2.5", RequestProtocol::AnthropicMessages),
1332 ("qwen3.8-max", RequestProtocol::AnthropicMessages),
1333 ("qwen3.8-flash", RequestProtocol::AnthropicMessages),
1334 ("qwen3.7-max", RequestProtocol::AnthropicMessages),
1335 ("qwen3.7-plus", RequestProtocol::AnthropicMessages),
1336 ("qwen3.6-plus", RequestProtocol::AnthropicMessages),
1337 ];
1338 for (model, protocol) in fixtures {
1339 for requested in [model.to_string(), format!("opencode-go/{model}")] {
1340 for base_url_override in [None, Some("https://go-gateway.example.test/v1".to_string())]
1341 {
1342 let request = RouteRequest {
1343 explicit_provider: Some(ProviderKind::OpencodeGo),
1344 model_selector: Some(LogicalModelRef::from(requested.as_str())),
1345 base_url_override,
1346 ..RouteRequest::default()
1347 };
1348 let route = resolver.resolve(&request).expect("documented Go route");
1349 assert_eq!(route.wire_model_id().as_str(), model);
1350 assert_eq!(route.protocol(), protocol, "{requested}");
1351 if request.base_url_override.is_some() {
1352 assert!(
1353 !route.limits().has_known_limit(),
1354 "custom endpoint has no sourced limits"
1355 );
1356 }
1357 }
1358 }
1359 }
1360 for model in [
1361 "claude-unproven",
1362 "opencode-go/unknown",
1363 "openai/gpt-5.6-luna",
1364 ] {
1365 for base_url_override in [None, Some("https://go-gateway.example.test/v1".into())] {
1366 assert!(
1367 resolver
1368 .resolve(&RouteRequest {
1369 explicit_provider: Some(ProviderKind::OpencodeGo),
1370 model_selector: Some(LogicalModelRef::from(model)),
1371 base_url_override,
1372 ..RouteRequest::default()
1373 })
1374 .is_err(),
1375 "unknown {model} must not select a default or another wire"
1376 );
1377 }
1378 }
1379 }
1380
1381 #[test]
1382 fn opencode_go_protocol_cannot_be_downgraded_by_stale_catalog_metadata() {
1383 let mut offerings = super::offering::bundled_offerings();
1384 let row = offerings
1385 .iter_mut()
1386 .find(|row| {
1387 row.provider.as_str() == "opencode-go" && row.wire_model_id.as_str() == "minimax-m3"
1388 })
1389 .unwrap();
1390 row.endpoint_key = "chat".into();
1391 let route = RouteResolver::from_offerings(offerings)
1392 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("minimax-m3")))
1393 .unwrap();
1394 assert_eq!(route.protocol(), RequestProtocol::AnthropicMessages);
1395 }
1396
1397 #[test]
1398 fn codewhale_route_picks_its_wire_per_model_and_stays_open_to_the_account_catalog() {
1399 use super::CODEWHALE_FALLBACK_MODELS;
1400
1401 let resolver = RouteResolver::new();
1402 for model in CODEWHALE_FALLBACK_MODELS {
1403 let route = resolver
1404 .resolve(&req(Some(ProviderKind::Codewhale), Some(model)))
1405 .unwrap_or_else(|error| panic!("{model} should resolve: {error}"));
1406 assert_eq!(route.provider_kind(), ProviderKind::Codewhale);
1407 // Ids pass through exactly as the account catalog returns them.
1408 assert_eq!(route.wire_model_id().as_str(), *model);
1409 let expected = if model.starts_with("anthropic/") {
1410 RequestProtocol::AnthropicMessages
1411 } else {
1412 RequestProtocol::ChatCompletions
1413 };
1414 assert_eq!(route.protocol(), expected, "{model}");
1415 }
1416
1417 let automatic = resolver
1418 .resolve(&req(Some(ProviderKind::Codewhale), Some("auto")))
1419 .expect("the Codewhale route has a bootstrap default");
1420 assert_eq!(
1421 automatic.wire_model_id().as_str(),
1422 "deepseek/deepseek-v4-pro"
1423 );
1424 assert_eq!(automatic.protocol(), RequestProtocol::ChatCompletions);
1425
1426 // A provider the account connected after this build shipped must still
1427 // resolve: the account catalog, not a compiled roster, owns the list.
1428 let unseen = resolver
1429 .resolve(&req(Some(ProviderKind::Codewhale), Some("xai/grok-4.6")))
1430 .expect("an account-connected provider this build never heard of");
1431 assert_eq!(unseen.wire_model_id().as_str(), "xai/grok-4.6");
1432 assert_eq!(unseen.protocol(), RequestProtocol::ChatCompletions);
1433 let unseen_anthropic = resolver
1434 .resolve(&req(
1435 Some(ProviderKind::Codewhale),
1436 Some("anthropic/claude-opus-4-8"),
1437 ))
1438 .expect("the Anthropic namespace routes to the Messages passthrough");
1439 assert_eq!(
1440 unseen_anthropic.protocol(),
1441 RequestProtocol::AnthropicMessages
1442 );
1443 }
1444
1445 #[test]
1446 fn codewhale_api_base_env_refuses_cleartext_off_loopback() {
1447 use crate::provider::codewhale_api_base;
1448
1449 assert_eq!(
1450 codewhale_api_base("https://api.codewhale.net/v1/").as_deref(),
1451 Some("https://api.codewhale.net/v1")
1452 );
1453 assert_eq!(
1454 codewhale_api_base("http://127.0.0.1:8787/v1").as_deref(),
1455 Some("http://127.0.0.1:8787/v1")
1456 );
1457 assert!(codewhale_api_base("http://localhost:8787/v1").is_some());
1458 for refused in [
1459 "",
1460 " ",
1461 "http://api.codewhale.net/v1",
1462 "https://user:pass@api.codewhale.net/v1",
1463 "ftp://api.codewhale.net/v1",
1464 "not a url",
1465 ] {
1466 assert!(codewhale_api_base(refused).is_none(), "{refused:?}");
1467 }
1468 }
1469
1470 #[test]
1471 fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() {
1472 use crate::catalog::reviewed::constants::{
1473 OPENCODE_ZEN_CHAT_MODELS, OPENCODE_ZEN_MESSAGES_MODELS, OPENCODE_ZEN_RESPONSES_MODELS,
1474 };
1475
1476 let resolver = RouteResolver::new();
1477 let groups = [
1478 (
1479 OPENCODE_ZEN_RESPONSES_MODELS,
1480 "responses",
1481 RequestProtocol::Responses,
1482 ),
1483 (
1484 OPENCODE_ZEN_MESSAGES_MODELS,
1485 "messages",
1486 RequestProtocol::AnthropicMessages,
1487 ),
1488 (
1489 OPENCODE_ZEN_CHAT_MODELS,
1490 "chat",
1491 RequestProtocol::ChatCompletions,
1492 ),
1493 ];
1494
1495 for (models, endpoint_key, protocol) in groups {
1496 for model in models {
1497 for requested in [model.to_string(), format!("opencode/{model}")] {
1498 let route = resolver
1499 .resolve(&req(Some(ProviderKind::OpencodeZen), Some(&requested)))
1500 .unwrap_or_else(|error| panic!("{requested} should resolve: {error}"));
1501 assert_eq!(route.provider_kind(), ProviderKind::OpencodeZen);
1502 assert_eq!(route.wire_model_id().as_str(), *model, "{requested}");
1503 assert_eq!(route.endpoint().endpoint_key, endpoint_key, "{requested}");
1504 assert_eq!(route.protocol(), protocol, "{requested}");
1505 }
1506 }
1507 }
1508
1509 let automatic = resolver
1510 .resolve(&req(Some(ProviderKind::OpencodeZen), Some("auto")))
1511 .expect("OpenCode Zen auto should resolve to its documented default");
1512 assert_eq!(automatic.wire_model_id().as_str(), "gpt-5.6-sol");
1513 assert_eq!(automatic.protocol(), RequestProtocol::Responses);
1514
1515 let logical_gpt_5_6 = resolver
1516 .resolve(&req(Some(ProviderKind::OpencodeZen), Some("gpt-5.6")))
1517 .expect("the logical GPT-5.6 picker row resolves to Zen's proven Sol wire route");
1518 assert_eq!(logical_gpt_5_6.wire_model_id().as_str(), "gpt-5.6-sol");
1519 assert_eq!(logical_gpt_5_6.protocol(), RequestProtocol::Responses);
1520 }
1521
1522 #[test]
1523 fn opencode_zen_resolver_fails_closed_for_unproven_protocols() {
1524 let resolver = RouteResolver::new();
1525
1526 for model in [
1527 "gemini-3.1-pro",
1528 "opencode/gemini-3.5-flash",
1529 "unknown-model",
1530 ] {
1531 for base_url_override in [
1532 None,
1533 Some("https://zen-gateway.example.test/v1".to_string()),
1534 ] {
1535 let request = RouteRequest {
1536 explicit_provider: Some(ProviderKind::OpencodeZen),
1537 model_selector: Some(LogicalModelRef::from(model)),
1538 saved_provider_model: None,
1539 base_url_override,
1540 limit_overrides: Vec::new(),
1541 };
1542 assert!(
1543 matches!(
1544 resolver.resolve(&request),
1545 Err(RouteError::UnsupportedModelProtocol { .. })
1546 ),
1547 "{model} must fail closed without a supported protocol mapping"
1548 );
1549 }
1550 }
1551 }
1552
1553 /// #6705: a Zen model newer than this build routes on the wire its Models.dev
1554 /// catalog row names; the catalog's Google and deprecated rows and ids it
1555 /// never lists still fail closed, with the endpoint named.
1556 #[test]
1557 fn opencode_zen_routes_models_dev_rows_on_their_declared_wire() {
1558 let raw = r#"{
1559 "providers": {
1560 "opencode": {
1561 "id": "opencode",
1562 "npm": "@ai-sdk/openai-compatible",
1563 "models": {
1564 "gpt-9-nova": { "id": "gpt-9-nova", "provider": { "npm": "@ai-sdk/openai" } },
1565 "claude-opus-9": { "id": "claude-opus-9", "provider": { "npm": "@ai-sdk/anthropic" } },
1566 "moon-rabbit-free": { "id": "moon-rabbit-free" },
1567 "gemini-9-flash": { "id": "gemini-9-flash", "provider": { "npm": "@ai-sdk/google" } },
1568 "odd-sdk-model": { "id": "odd-sdk-model", "provider": { "npm": "@ai-sdk/unknown" } },
1569 "claude-2-retired": {
1570 "id": "claude-2-retired",
1571 "status": "deprecated",
1572 "provider": { "npm": "@ai-sdk/anthropic" }
1573 }
1574 }
1575 }
1576 }
1577 }"#;
1578 let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
1579 let mut offerings: Vec<_> = crate::catalog::live_offerings_from_models_dev(&catalog, 1)
1580 .iter()
1581 .map(crate::catalog::CatalogOffering::to_offering)
1582 .collect();
1583 offerings.extend(super::bundled_offerings());
1584 let resolver = RouteResolver::from_offerings(offerings);
1585
1586 for (model, protocol) in [
1587 ("gpt-9-nova", RequestProtocol::Responses),
1588 ("opencode/claude-opus-9", RequestProtocol::AnthropicMessages),
1589 ("moon-rabbit-free", RequestProtocol::ChatCompletions),
1590 // A curated-only row still routes beside the catalog's rows.
1591 ("gpt-5.6-sol", RequestProtocol::Responses),
1592 ] {
1593 let route = resolver
1594 .resolve(&req(Some(ProviderKind::OpencodeZen), Some(model)))
1595 .unwrap_or_else(|error| panic!("{model} should resolve: {error}"));
1596 assert_eq!(route.protocol(), protocol, "{model}");
1597 }
1598
1599 for (model, endpoint) in [
1600 ("gemini-9-flash", "google"),
1601 ("odd-sdk-model", "unproven"),
1602 ("never-listed", "unproven"),
1603 // Deprecated rows stay in the catalog but are not routes.
1604 ("claude-2-retired", "deprecated"),
1605 ] {
1606 match resolver.resolve(&req(Some(ProviderKind::OpencodeZen), Some(model))) {
1607 Err(RouteError::UnsupportedModelProtocol { endpoint_key, .. }) => {
1608 assert_eq!(endpoint_key, endpoint, "{model}");
1609 }
1610 other => panic!("{model} must fail closed, got {other:?}"),
1611 }
1612 }
1613 }
1614
1615 #[test]
1616 fn resolver_deepseek_none_selector_uses_default_wire_id() {
1617 let r = RouteResolver::new();
1618 let out = r
1619 .resolve(&req(Some(ProviderKind::Deepseek), None))
1620 .expect("none selector should use provider default");
1621 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
1622 assert_eq!(out.wire_model_id().as_str(), "deepseek-flash");
1623 assert_eq!(out.protocol(), RequestProtocol::Responses);
1624 }
1625
1626 #[test]
1627 fn resolver_empty_string_selector_is_empty_model_error() {
1628 let r = RouteResolver::new();
1629 let out = r.resolve(&req(Some(ProviderKind::Deepseek), Some("")));
1630 assert!(matches!(out, Err(RouteError::EmptyModel)));
1631 }
1632
1633 #[test]
1634 fn resolver_rejects_retired_antigravity_before_minting_a_route() {
1635 let error = RouteResolver::new()
1636 .resolve(&req(Some(ProviderKind::Antigravity), Some("gemini-3-pro")))
1637 .expect_err("the legacy tombstone must never produce an executable route");
1638 let rendered = error.to_string();
1639 assert!(matches!(error, RouteError::InvalidProvider(_)));
1640 assert!(rendered.contains("non-runnable"), "{rendered}");
1641 assert!(
1642 rendered.contains("auth clear --provider antigravity"),
1643 "{rendered}"
1644 );
1645 assert!(rendered.contains("google"), "{rendered}");
1646 assert!(rendered.contains("GEMINI_API_KEY"), "{rendered}");
1647 }
1648
1649 #[test]
1650 fn resolver_empty_saved_provider_model_is_empty_model_error() {
1651 // An empty selector from the saved-model fallback must be rejected too, not
1652 // just an empty explicit selector (the guard covers every selector source).
1653 let r = RouteResolver::new();
1654 let request = RouteRequest {
1655 explicit_provider: Some(ProviderKind::Deepseek),
1656 model_selector: None,
1657 saved_provider_model: Some(WireModelId::from("")),
1658 base_url_override: None,
1659 limit_overrides: Vec::new(),
1660 };
1661 assert!(matches!(r.resolve(&request), Err(RouteError::EmptyModel)));
1662 }
1663
1664 #[test]
1665 fn resolver_passthrough_provider_preserves_custom_id_verbatim() {
1666 let r = RouteResolver::new();
1667 let out = r
1668 .resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1669 .expect("local passthrough should resolve");
1670 assert_eq!(out.provider_kind(), ProviderKind::Ollama);
1671 assert_eq!(out.wire_model_id().as_str(), "my-local:7b");
1672 assert_eq!(out.limits(), Default::default());
1673 assert!(out.validation().ok);
1674 }
1675
1676 #[test]
1677 fn resolved_candidate_serializes_secret_free() {
1678 let r = RouteResolver::new();
1679 // Cover a direct, an aggregator, and a local/passthrough route.
1680 let candidates = [
1681 r.resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1682 .expect("direct resolves"),
1683 r.resolve(&req(
1684 Some(ProviderKind::Together),
1685 Some("deepseek-ai/DeepSeek-V4-Pro"),
1686 ))
1687 .expect("aggregator resolves"),
1688 r.resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1689 .expect("local resolves"),
1690 ];
1691 for out in candidates {
1692 let json = serde_json::to_string(&out).expect("candidate serializes");
1693 // Carries provider/model/wire/protocol/auth-source class.
1694 assert!(json.contains("provider_id"), "{json}");
1695 assert!(json.contains("provider_kind"), "{json}");
1696 assert!(json.contains("wire_model_id"), "{json}");
1697 assert!(json.contains("protocol"), "{json}");
1698 assert!(json.contains("auth"), "{json}");
1699 // Never any secret/api-key material.
1700 let lower = json.to_lowercase();
1701 assert!(!lower.contains("api_key"), "leaked api_key: {json}");
1702 assert!(!lower.contains("apikey"), "leaked apikey: {json}");
1703 assert!(!lower.contains("secret_id"), "leaked secret_id: {json}");
1704 assert!(!lower.contains("password"), "leaked password: {json}");
1705 assert!(!lower.contains("bearer"), "leaked bearer: {json}");
1706 assert!(
1707 !lower.contains("authorization"),
1708 "leaked authorization: {json}"
1709 );
1710 }
1711 }
1712
1713 #[test]
1714 fn resolver_protocol_matches_descriptor_for_every_provider() {
1715 let r = RouteResolver::new();
1716 for kind in ProviderKind::ALL {
1717 // Use each provider's own default wire id as the selector so strict
1718 // direct providers do not reject; this exercises the resolver across
1719 // the whole provider set.
1720 let default_wire = ProviderDescriptor::for_kind(kind).default_wire_model();
1721 let request = req(Some(kind), Some(default_wire.as_str()));
1722 let out = r
1723 .resolve(&request)
1724 .unwrap_or_else(|e| panic!("{kind:?} should resolve its own default: {e}"));
1725 assert_eq!(
1726 out.protocol(),
1727 ProviderDescriptor::for_kind(kind)
1728 .protocol_for_endpoint(&out.endpoint().endpoint_key)
1729 .expect("resolved endpoint protocol"),
1730 "{kind:?} candidate protocol must match descriptor"
1731 );
1732 assert_eq!(
1733 out.endpoint().protocol,
1734 out.protocol(),
1735 "{kind:?} endpoint protocol"
1736 );
1737 }
1738 }
1739
1740 // ---------------------------------------------------------------------------
1741 // #3085: honest pricing on resolved candidates.
1742 // ---------------------------------------------------------------------------
1743
1744 /// A resolver whose single offering is a DeepSeek-priced catalog row, projected
1745 /// through the wired `CatalogOffering::to_offering` pricing seam.
1746 fn priced_deepseek_resolver() -> RouteResolver {
1747 use crate::catalog::{CatalogOffering, CatalogSource};
1748 use crate::models_dev::ModelsDevCost;
1749
1750 let priced = CatalogOffering {
1751 provider: "deepseek".into(),
1752 wire_model_id: "deepseek-v4-pro".into(),
1753 canonical_model: Some("deepseek-v4-pro".into()),
1754 endpoint_key: "chat".into(),
1755 default_for_provider: true,
1756 cost: Some(ModelsDevCost {
1757 input: Some(0.28),
1758 output: Some(0.42),
1759 cache_read: Some(0.028),
1760 cache_write: None,
1761 }),
1762 source: CatalogSource::Bundled,
1763 ..Default::default()
1764 };
1765 RouteResolver::from_offerings(vec![priced.to_offering()])
1766 }
1767
1768 #[test]
1769 fn resolver_carries_exact_offering_capabilities_without_protocol_inference() {
1770 use crate::catalog::{CatalogOffering, CatalogSource};
1771 use crate::route::CapabilityState;
1772
1773 let offering = CatalogOffering {
1774 provider: "deepseek".into(),
1775 wire_model_id: "deepseek-v4-pro".into(),
1776 canonical_model: Some("deepseek-v4-pro".into()),
1777 endpoint_key: "chat".into(),
1778 default_for_provider: true,
1779 attachment: Some(false),
1780 reasoning: Some(true),
1781 tool_call: Some(false),
1782 structured_output: Some(true),
1783 source: CatalogSource::Bundled,
1784 ..Default::default()
1785 };
1786 let resolver = RouteResolver::from_offerings(vec![offering.to_offering()]);
1787
1788 let candidate = resolver
1789 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1790 .expect("matching offering resolves");
1791 let capabilities = candidate.capabilities();
1792
1793 assert_eq!(capabilities.attachments, CapabilityState::Unsupported);
1794 assert_eq!(capabilities.reasoning, CapabilityState::Supported);
1795 assert_eq!(capabilities.native_tool_calls, CapabilityState::Unsupported);
1796 assert_eq!(capabilities.structured_output, CapabilityState::Supported);
1797 assert_eq!(capabilities.streaming, CapabilityState::Unknown);
1798 assert_eq!(
1799 capabilities.server_side_web_search,
1800 CapabilityState::Supported
1801 );
1802 }
1803
1804 #[test]
1805 fn provider_native_web_search_requires_exact_direct_endpoint_offering() {
1806 use crate::route::CapabilityState;
1807
1808 let resolver = RouteResolver::new();
1809 let automatic = resolver
1810 .resolve(&req(Some(ProviderKind::Xai), None))
1811 .expect("xAI default resolves");
1812 assert_eq!(automatic.wire_model_id().as_str(), "grok-4.6");
1813
1814 let direct = resolver
1815 .resolve(&req(Some(ProviderKind::Xai), Some("grok-4.6")))
1816 .expect("bundled direct xAI offering resolves");
1817 assert_eq!(direct.wire_model_id().as_str(), "grok-4.6");
1818 assert_eq!(
1819 direct.capabilities().attachments,
1820 CapabilityState::Supported
1821 );
1822 assert_eq!(
1823 direct.capabilities().image_input,
1824 CapabilityState::Supported
1825 );
1826 assert_eq!(direct.capabilities().reasoning, CapabilityState::Supported);
1827 assert_eq!(
1828 direct.capabilities().native_tool_calls,
1829 CapabilityState::Supported
1830 );
1831 assert_eq!(
1832 direct.capabilities().structured_output,
1833 CapabilityState::Supported
1834 );
1835 assert_eq!(
1836 direct.capabilities().server_side_web_search,
1837 CapabilityState::Supported
1838 );
1839
1840 let aggregator = resolver
1841 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("grok-4.5")))
1842 .expect("aggregator offering resolves independently");
1843 assert_eq!(
1844 aggregator.capabilities().server_side_web_search,
1845 CapabilityState::Unknown
1846 );
1847
1848 let custom_endpoint = resolver
1849 .resolve(&RouteRequest {
1850 explicit_provider: Some(ProviderKind::Xai),
1851 model_selector: Some(LogicalModelRef::from("grok-4.6")),
1852 saved_provider_model: None,
1853 base_url_override: Some("https://gateway.example.test/v1".to_string()),
1854 limit_overrides: Vec::new(),
1855 })
1856 .expect("custom compatible endpoint resolves");
1857 assert_eq!(
1858 custom_endpoint.capabilities().server_side_web_search,
1859 CapabilityState::Unknown
1860 );
1861 }
1862
1863 #[test]
1864 fn mimo_native_search_is_exact_to_documented_chat_models() {
1865 use crate::route::CapabilityState;
1866
1867 let resolver = RouteResolver::new();
1868 for model in ["mimo-v2.5-pro", "mimo-v2.5"] {
1869 let direct = resolver
1870 .resolve(&req(Some(ProviderKind::XiaomiMimo), Some(model)))
1871 .expect("official MiMo chat route resolves");
1872 assert_eq!(
1873 direct.capabilities().server_side_web_search,
1874 CapabilityState::Supported
1875 );
1876 }
1877
1878 let neighboring = resolver
1879 .resolve(&req(
1880 Some(ProviderKind::XiaomiMimo),
1881 Some("mimo-v2.5-pro-ultraspeed"),
1882 ))
1883 .expect("neighboring MiMo model resolves");
1884 assert_eq!(
1885 neighboring.capabilities().server_side_web_search,
1886 CapabilityState::Unknown
1887 );
1888
1889 let custom = resolver
1890 .resolve(&RouteRequest {
1891 explicit_provider: Some(ProviderKind::XiaomiMimo),
1892 model_selector: Some(LogicalModelRef::from("mimo-v2.5-pro")),
1893 saved_provider_model: None,
1894 base_url_override: Some("https://compatible.example.test/v1".to_string()),
1895 limit_overrides: Vec::new(),
1896 })
1897 .expect("custom MiMo-compatible route resolves");
1898 assert_eq!(
1899 custom.capabilities().server_side_web_search,
1900 CapabilityState::Unknown
1901 );
1902 }
1903
1904 #[test]
1905 fn zai_native_search_requires_exact_general_api_product() {
1906 use crate::route::CapabilityState;
1907
1908 let resolver = RouteResolver::new();
1909 for base_url in [
1910 "https://api.z.ai/api/paas/v4",
1911 "https://open.bigmodel.cn/api/paas/v4",
1912 ] {
1913 let direct = resolver
1914 .resolve(&RouteRequest {
1915 explicit_provider: Some(ProviderKind::Zai),
1916 model_selector: Some(LogicalModelRef::from("GLM-5.3")),
1917 saved_provider_model: None,
1918 base_url_override: Some(base_url.to_string()),
1919 limit_overrides: Vec::new(),
1920 })
1921 .expect("general API route resolves");
1922 assert_eq!(
1923 direct.capabilities().server_side_web_search,
1924 CapabilityState::Supported,
1925 "{base_url} should expose structured web search"
1926 );
1927 }
1928
1929 for base_url in [
1930 "https://api.z.ai/api/coding/paas/v4",
1931 "https://open.bigmodel.cn/api/paas/v4/preview",
1932 "https://compatible.example.test/v1",
1933 ] {
1934 let adjacent = resolver
1935 .resolve(&RouteRequest {
1936 explicit_provider: Some(ProviderKind::Zai),
1937 model_selector: Some(LogicalModelRef::from("GLM-5.3")),
1938 saved_provider_model: None,
1939 base_url_override: Some(base_url.to_string()),
1940 limit_overrides: Vec::new(),
1941 })
1942 .expect("adjacent route resolves");
1943 assert_eq!(
1944 adjacent.capabilities().server_side_web_search,
1945 CapabilityState::Unknown,
1946 "{base_url} must remain fail-closed"
1947 );
1948 }
1949 }
1950
1951 #[test]
1952 fn qwen_native_search_is_exact_to_token_plan_responses_routes() {
1953 use crate::route::CapabilityState;
1954
1955 let resolver = RouteResolver::new();
1956 let direct = resolver
1957 .resolve(&req(
1958 Some(ProviderKind::ModelstudioTokenPlan),
1959 Some("qwen3.8-max"),
1960 ))
1961 .expect("Token Plan Qwen route resolves");
1962 assert_eq!(
1963 direct.capabilities().server_side_web_search,
1964 CapabilityState::Supported
1965 );
1966
1967 let preview = resolver
1968 .resolve(&req(
1969 Some(ProviderKind::ModelstudioTokenPlan),
1970 Some("qwen3.8-max-preview"),
1971 ))
1972 .expect("neighboring preview route resolves");
1973 assert_eq!(
1974 preview.capabilities().server_side_web_search,
1975 CapabilityState::Unknown
1976 );
1977
1978 for base_url in [
1979 "https://coding-intl.dashscope.aliyuncs.com/v1",
1980 "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
1981 "https://compatible.example.test/v1",
1982 ] {
1983 let alternate = resolver
1984 .resolve(&RouteRequest {
1985 explicit_provider: Some(ProviderKind::ModelstudioTokenPlan),
1986 model_selector: Some(LogicalModelRef::from("qwen3.8-max")),
1987 saved_provider_model: None,
1988 base_url_override: Some(base_url.to_string()),
1989 limit_overrides: Vec::new(),
1990 })
1991 .expect("alternate product route resolves");
1992 assert_eq!(
1993 alternate.capabilities().server_side_web_search,
1994 CapabilityState::Unknown,
1995 "{base_url} must not inherit Token Plan Responses search"
1996 );
1997 }
1998 }
1999
2000 #[test]
2001 fn moonshot_native_search_requires_exact_product_model_pair() {
2002 use crate::route::CapabilityState;
2003
2004 let resolver = RouteResolver::new();
2005 for (model, base_url) in [
2006 ("kimi-k3", "https://api.moonshot.ai/v1"),
2007 ("kimi-k3", "https://api.moonshot.cn/v1"),
2008 ("kimi-k2.6", "https://api.moonshot.ai/v1"),
2009 ("k3", "https://api.kimi.com/coding/v1"),
2010 ("kimi-for-coding", "https://api.kimi.com/coding/v1"),
2011 ] {
2012 let direct = resolver
2013 .resolve(&RouteRequest {
2014 explicit_provider: Some(ProviderKind::Moonshot),
2015 model_selector: Some(LogicalModelRef::from(model)),
2016 saved_provider_model: None,
2017 base_url_override: Some(base_url.to_string()),
2018 limit_overrides: Vec::new(),
2019 })
2020 .expect("documented Moonshot/Kimi route resolves");
2021 assert_eq!(
2022 direct.capabilities().server_side_web_search,
2023 CapabilityState::Supported,
2024 "{base_url}/{model} should expose native search"
2025 );
2026 }
2027
2028 for (model, base_url) in [
2029 ("kimi-k3", "https://api.kimi.com/coding/v2"),
2030 ("kimi-k2.6", "https://api.kimi.com/coding/v1/preview"),
2031 ("k3", "https://api.moonshot.ai/v1"),
2032 ("kimi-k2.7-code", "https://api.moonshot.ai/v1"),
2033 ] {
2034 let adjacent = resolver
2035 .resolve(&RouteRequest {
2036 explicit_provider: Some(ProviderKind::Moonshot),
2037 model_selector: Some(LogicalModelRef::from(model)),
2038 saved_provider_model: None,
2039 base_url_override: Some(base_url.to_string()),
2040 limit_overrides: Vec::new(),
2041 })
2042 .expect("adjacent Moonshot/Kimi route resolves");
2043 assert_eq!(
2044 adjacent.capabilities().server_side_web_search,
2045 CapabilityState::Unknown,
2046 "{base_url}/{model} must remain fail-closed"
2047 );
2048 }
2049 }
2050
2051 #[test]
2052 fn priced_offering_yields_token_pricing_sku() {
2053 use super::candidate::PricingSku;
2054
2055 let r = priced_deepseek_resolver();
2056 let out = r
2057 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
2058 .expect("priced DeepSeek route should resolve");
2059
2060 match out.pricing() {
2061 Some(PricingSku::Token {
2062 input_per_mtok,
2063 output_per_mtok,
2064 }) => {
2065 assert_eq!(*input_per_mtok, Some(0.28));
2066 assert_eq!(*output_per_mtok, Some(0.42));
2067 }
2068 other => panic!("expected Some(Token), got {other:?}"),
2069 }
2070 }
2071
2072 #[test]
2073 fn custom_endpoint_does_not_inherit_first_party_pricing() {
2074 use super::candidate::PricingSku;
2075
2076 let out = priced_deepseek_resolver()
2077 .resolve(&RouteRequest {
2078 explicit_provider: Some(ProviderKind::Deepseek),
2079 model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")),
2080 saved_provider_model: None,
2081 base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()),
2082 limit_overrides: Vec::new(),
2083 })
2084 .expect("same-name custom proxy route resolves");
2085
2086 assert!(
2087 matches!(out.pricing(), Some(PricingSku::UnknownOrStale)),
2088 "a custom proxy cannot inherit first-party pricing by reusing the model id: {:?}",
2089 out.pricing()
2090 );
2091 }
2092
2093 #[test]
2094 fn exact_endpoint_catalog_may_carry_its_own_pricing_on_a_custom_base_url() {
2095 use super::candidate::PricingSku;
2096
2097 let request = RouteRequest {
2098 explicit_provider: Some(ProviderKind::Deepseek),
2099 model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")),
2100 saved_provider_model: None,
2101 base_url_override: Some("https://authenticated-catalog.example.test/v1".to_string()),
2102 limit_overrides: Vec::new(),
2103 };
2104 let out = priced_deepseek_resolver()
2105 .resolve_with_endpoint_catalog_authority(&request)
2106 .expect("exact endpoint-owned catalog route resolves");
2107
2108 match out.pricing() {
2109 Some(PricingSku::Token {
2110 input_per_mtok,
2111 output_per_mtok,
2112 }) => {
2113 assert_eq!(*input_per_mtok, Some(0.28));
2114 assert_eq!(*output_per_mtok, Some(0.42));
2115 }
2116 other => panic!("expected exact endpoint pricing, got {other:?}"),
2117 }
2118 }
2119
2120 #[test]
2121 fn unpriced_offering_stays_unknown() {
2122 use super::candidate::PricingSku;
2123
2124 // The bundled seam (`RouteResolver::new`) carries no sourced cost, so a
2125 // matched offering must surface honest UnknownOrStale, never a fabricated
2126 // zero price (#2608 / #3085 honesty rule).
2127 let r = RouteResolver::new();
2128 let out = r
2129 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
2130 .expect("bundled DeepSeek route should resolve");
2131 assert!(
2132 matches!(out.pricing(), Some(PricingSku::UnknownOrStale)),
2133 "bundled offering carries no price → UnknownOrStale, got {:?}",
2134 out.pricing()
2135 );
2136
2137 // A pass-through route with no matched offering is likewise unknown.
2138 let passthrough = r
2139 .resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
2140 .expect("local passthrough should resolve");
2141 assert!(matches!(
2142 passthrough.pricing(),
2143 Some(PricingSku::UnknownOrStale)
2144 ));
2145 }
2146
2147 // ---------------------------------------------------------------------------
2148 // #1519: advisory insecure-http warning, loopback-exempt.
2149 // ---------------------------------------------------------------------------
2150
2151 /// Build a request with an explicit base-URL override.
2152 fn req_with_base(provider: ProviderKind, model: &str, base_url: &str) -> RouteRequest {
2153 RouteRequest {
2154 explicit_provider: Some(provider),
2155 model_selector: Some(LogicalModelRef::from(model)),
2156 saved_provider_model: None,
2157 base_url_override: Some(base_url.to_string()),
2158 limit_overrides: Vec::new(),
2159 }
2160 }
2161
2162 #[test]
2163 fn http_custom_endpoint_emits_insecure_warning() {
2164 let r = RouteResolver::new();
2165 let out = r
2166 .resolve(&req_with_base(
2167 ProviderKind::Openai,
2168 "gpt-whatever",
2169 "http://example.com/v1",
2170 ))
2171 .expect("custom http endpoint should still resolve");
2172
2173 // Advisory only: the route stays usable.
2174 assert!(
2175 out.validation().ok,
2176 "insecure http is advisory, not a hard fail"
2177 );
2178 assert!(
2179 out.validation()
2180 .messages
2181 .iter()
2182 .any(|m| m.contains("insecure http")),
2183 "expected an insecure-http advisory, got {:?}",
2184 out.validation().messages
2185 );
2186 }
2187
2188 #[test]
2189 fn loopback_http_endpoint_does_not_warn() {
2190 let r = RouteResolver::new();
2191 // localhost, 127.0.0.1, and ::1 are all loopback and must stay clean.
2192 for base in [
2193 "http://localhost:11434/v1",
2194 "http://127.0.0.1:8000/v1",
2195 "http://[::1]:8080/v1",
2196 ] {
2197 let out = r
2198 .resolve(&req_with_base(ProviderKind::Ollama, "my-local:7b", base))
2199 .unwrap_or_else(|e| panic!("loopback route {base} should resolve: {e}"));
2200 assert!(out.validation().ok);
2201 assert!(
2202 out.validation().messages.is_empty(),
2203 "loopback {base} must not warn, got {:?}",
2204 out.validation().messages
2205 );
2206 }
2207 }
2208
2209 #[test]
2210 fn https_endpoint_has_no_warning() {
2211 let r = RouteResolver::new();
2212 let out = r
2213 .resolve(&req_with_base(
2214 ProviderKind::Openai,
2215 "gpt-whatever",
2216 "https://example.com/v1",
2217 ))
2218 .expect("https endpoint should resolve");
2219 assert!(out.validation().ok);
2220 assert!(
2221 out.validation().messages.is_empty(),
2222 "https must not warn, got {:?}",
2223 out.validation().messages
2224 );
2225 }
2226
2226 lines RUST