返回 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_custom_deepseek_same_name_capabilities_unverified() {
586 let route = RouteResolver::new()
587 .resolve(&RouteRequest {
588 explicit_provider: Some(ProviderKind::Deepseek),
589 model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")),
590 saved_provider_model: None,
591 base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()),
592 limit_overrides: Vec::new(),
593 })
594 .expect("same-name custom proxy route resolves");
595
596 assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
597 assert_eq!(route.endpoint().endpoint_key, "chat");
598 assert_eq!(
599 route.wire_model_id().as_str(),
600 "deepseek-v4-flash-vision-exp"
601 );
602 assert_eq!(
603 route.capabilities(),
604 RouteCapabilities::default(),
605 "a custom proxy cannot inherit first-party capability facts by reusing the model id"
606 );
607 }
608
609 #[test]
610 fn resolver_aggregator_preserves_prefixed_wire_id_without_inferring_deepseek() {
611 let r = RouteResolver::new();
612 let out = r
613 .resolve(&req(
614 Some(ProviderKind::Together),
615 Some("deepseek-ai/DeepSeek-V4-Pro"),
616 ))
617 .expect("aggregator should resolve");
618 // Provider stays Together, NOT Deepseek, despite the deepseek-ai/ prefix.
619 assert_eq!(out.provider_kind(), ProviderKind::Together);
620 assert_ne!(out.provider_kind(), ProviderKind::Deepseek);
621 // Wire id preserved verbatim.
622 assert_eq!(out.wire_model_id().as_str(), "deepseek-ai/DeepSeek-V4-Pro");
623 }
624
625 #[test]
626 fn resolver_openrouter_keeps_provider_for_every_namespace_prefix() {
627 let r = RouteResolver::new();
628 let prefixes = [
629 "deepseek-ai/DeepSeek-V4-Pro",
630 "deepseek/deepseek-v4-pro",
631 "anthropic/claude-foo",
632 "openai/gpt-foo",
633 "qwen/qwen-foo",
634 ];
635 for raw in prefixes {
636 let selector = LogicalModelRef::from(raw);
637 // The selector DOES carry a namespace hint...
638 assert!(
639 selector.namespace_hint().is_some(),
640 "{raw} should have a namespace hint"
641 );
642 let out = r
643 .resolve(&req(Some(ProviderKind::Openrouter), Some(raw)))
644 .unwrap_or_else(|e| panic!("{raw} should resolve on openrouter: {e}"));
645 // ...but the provider stays Openrouter regardless.
646 assert_eq!(
647 out.provider_kind(),
648 ProviderKind::Openrouter,
649 "{raw} must not change provider"
650 );
651 assert_eq!(out.wire_model_id().as_str(), raw, "{raw} wire id verbatim");
652 }
653 }
654
655 #[test]
656 fn resolver_no_explicit_provider_does_not_infer_deepseek_from_prefix() {
657 let r = RouteResolver::new();
658 // explicit_provider=None => default scope (Deepseek). A prefixed selector
659 // is foreign for the strict-direct default, so it ERRORS rather than being
660 // silently accepted as a deepseek model: the prefix never *selects* it.
661 let out = r.resolve(&req(None, Some("deepseek/deepseek-v4-pro")));
662 match out {
663 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
664 assert_eq!(provider.as_str(), "deepseek");
665 assert_eq!(model, "deepseek/deepseek-v4-pro");
666 }
667 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
668 }
669 }
670
671 #[test]
672 fn resolver_auto_is_sentinel_not_literal_model() {
673 let r = RouteResolver::new();
674 let out = r
675 .resolve(&req(Some(ProviderKind::Deepseek), Some("auto")))
676 .expect("auto should resolve");
677 // The logical selector is the auto sentinel...
678 assert!(out.logical_model().is_auto());
679 // ...and "auto" is NOT put on the wire as a literal model.
680 assert_ne!(out.wire_model_id().as_str(), "auto");
681 assert_eq!(out.wire_model_id().as_str(), "deepseek-flash");
682 assert_eq!(out.protocol(), RequestProtocol::Responses);
683 }
684
685 #[test]
686 fn resolver_can_use_models_dev_offering_for_provider_scoped_route() {
687 let r = models_dev_route_resolver();
688 let out = r
689 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
690 .expect("Models.dev-backed Z.ai route should resolve");
691
692 assert_eq!(out.provider_kind(), ProviderKind::Zai);
693 assert_eq!(out.provider_id().as_str(), "zai");
694 assert_eq!(out.wire_model_id().as_str(), "glm-5.2");
695 assert_eq!(
696 out.canonical_model().map(ModelId::as_str),
697 Some("zhipuai/glm-5.2")
698 );
699 }
700
701 #[test]
702 fn resolver_auto_uses_models_dev_default_offering_when_available() {
703 let r = models_dev_route_resolver();
704 let out = r
705 .resolve(&req(Some(ProviderKind::Zai), Some("auto")))
706 .expect("auto should resolve through catalog default");
707
708 assert!(out.logical_model().is_auto());
709 assert_eq!(
710 out.wire_model_id().as_str(),
711 "glm-5.2",
712 "catalog default should win over the built-in Z.ai spelling"
713 );
714 assert_eq!(
715 out.canonical_model().map(ModelId::as_str),
716 Some("zhipuai/glm-5.2")
717 );
718 }
719
720 #[test]
721 fn resolver_auto_falls_back_to_descriptor_default_without_catalog_default() {
722 // Z.ai offerings exist in the catalog snapshot but none is marked
723 // `default: true`. `auto` must then fall back to the provider descriptor's
724 // built-in default wire model rather than picking an arbitrary catalog row.
725 let raw = r#"{
726 "providers": {
727 "zai": {
728 "models": {
729 "glm-5-turbo": {
730 "id": "glm-5-turbo",
731 "modalities": { "input": ["text"], "output": ["text"] }
732 }
733 }
734 }
735 }
736 }"#;
737 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
738 let offerings = catalog
739 .provider_offerings("zai")
740 .expect("zai provider offerings");
741 let r = RouteResolver::from_offerings(offerings);
742
743 let out = r
744 .resolve(&req(Some(ProviderKind::Zai), Some("auto")))
745 .expect("auto should resolve to the descriptor default");
746
747 assert!(out.logical_model().is_auto());
748 assert_eq!(
749 out.wire_model_id().as_str(),
750 "GLM-5.3",
751 "no catalog default → descriptor built-in default wins"
752 );
753 assert_eq!(
754 out.canonical_model(),
755 None,
756 "descriptor fallback carries no catalog canonical link"
757 );
758 }
759
760 #[test]
761 fn resolver_models_dev_prefixed_wire_id_stays_inside_provider_scope() {
762 let r = models_dev_route_resolver();
763 let out = r
764 .resolve(&req(Some(ProviderKind::Openrouter), Some("z-ai/glm-5.2")))
765 .expect("OpenRouter Models.dev row should resolve");
766
767 assert_eq!(out.provider_kind(), ProviderKind::Openrouter);
768 assert_ne!(out.provider_kind(), ProviderKind::Zai);
769 assert_eq!(out.wire_model_id().as_str(), "z-ai/glm-5.2");
770 assert_eq!(
771 out.canonical_model().map(ModelId::as_str),
772 Some("zhipuai/glm-5.2")
773 );
774 }
775
776 #[test]
777 fn resolver_carries_models_dev_limits_into_ready_candidate() {
778 let r = models_dev_route_resolver();
779 let out = r
780 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
781 .expect("Z.AI Models.dev row should resolve");
782
783 assert_eq!(out.limits().context_tokens, Some(1_000_000));
784 assert_eq!(out.limits().input_tokens, Some(900_000));
785 assert_eq!(out.limits().output_tokens, Some(131_072));
786 assert!(out.limits().has_known_limit());
787 }
788
789 #[test]
790 fn minimax_anthropic_routes_use_catalog_limits_and_messages_protocol() {
791 let resolver = RouteResolver::new();
792
793 for (model, context) in [("MiniMax-M3", 1_000_000), ("MiniMax-M2.7", 204_800)] {
794 let route = resolver
795 .resolve(&req(Some(ProviderKind::MinimaxAnthropic), Some(model)))
796 .expect("MiniMax Messages route should resolve");
797
798 assert_eq!(route.provider_kind(), ProviderKind::MinimaxAnthropic);
799 assert_eq!(route.wire_model_id().as_str(), model);
800 assert_eq!(route.protocol(), RequestProtocol::AnthropicMessages);
801 assert_eq!(
802 route.endpoint().protocol,
803 RequestProtocol::AnthropicMessages
804 );
805 assert_eq!(
806 route.endpoint().base_url,
807 "https://api.minimax.io/anthropic"
808 );
809 assert_eq!(route.limits().context_tokens, Some(context));
810 }
811 }
812
813 #[test]
814 fn resolver_keeps_limits_provider_scoped_for_same_canonical_model() {
815 let r = models_dev_route_resolver();
816 let direct = r
817 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
818 .expect("direct Z.AI route should resolve");
819 let hosted = r
820 .resolve(&req(Some(ProviderKind::Openrouter), Some("z-ai/glm-5.2")))
821 .expect("hosted OpenRouter route should resolve");
822
823 assert_eq!(
824 direct.canonical_model().map(ModelId::as_str),
825 hosted.canonical_model().map(ModelId::as_str)
826 );
827 assert_eq!(direct.limits().context_tokens, Some(1_000_000));
828 assert_eq!(hosted.limits().context_tokens, Some(128_000));
829 assert_eq!(hosted.limits().output_tokens, Some(32_768));
830 }
831
832 #[test]
833 fn resolver_strict_direct_rejects_clearly_foreign_selector() {
834 let r = RouteResolver::new();
835 let out = r.resolve(&req(Some(ProviderKind::Zai), Some("anthropic/claude-foo")));
836 match out {
837 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
838 assert_eq!(provider.as_str(), "zai");
839 assert_eq!(model, "anthropic/claude-foo");
840 }
841 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
842 }
843 }
844
845 #[test]
846 fn resolver_direct_owned_row_match_survives_casing_mismatch() {
847 let r = RouteResolver::new();
848 let out = r
849 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
850 .expect("lowercase selector on the owning Z.ai row must resolve");
851 assert_eq!(out.provider_kind(), ProviderKind::Zai);
852 assert_eq!(out.wire_model_id().as_str(), "GLM-5.2");
853 assert!(out.limits().has_known_limit());
854
855 let saved = RouteRequest {
856 saved_provider_model: Some(WireModelId::from("glm-5.2")),
857 ..req(Some(ProviderKind::Zai), None)
858 };
859 let out = r
860 .resolve(&saved)
861 .expect("saved lowercase Z.ai model must resolve");
862 assert_eq!(out.wire_model_id().as_str(), "GLM-5.2");
863
864 let out = r
865 .resolve(&req(Some(ProviderKind::Deepseek), Some("Deepseek-V4-Pro")))
866 .expect("DeepSeek's own casing variant must resolve");
867 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
868 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
869
870 let custom = RouteRequest {
871 explicit_provider: Some(ProviderKind::Zai),
872 model_selector: Some(LogicalModelRef::from("glm-5.2")),
873 saved_provider_model: None,
874 base_url_override: Some("https://compatible.example.test/v1".to_string()),
875 limit_overrides: Vec::new(),
876 };
877 let out = r
878 .resolve(&custom)
879 .expect("custom endpoint keeps model pass-through semantics");
880 assert_eq!(out.wire_model_id().as_str(), "glm-5.2");
881 assert!(!out.limits().has_known_limit());
882 }
883
884 #[test]
885 fn resolver_direct_casefold_match_requires_one_owned_row() {
886 let raw = r#"{
887 "providers": {
888 "zai": {
889 "models": {
890 "CaseModel": {
891 "id": "CaseModel",
892 "modalities": { "input": ["text"], "output": ["text"] },
893 "limit": { "context": 1000 }
894 },
895 "casemodel": {
896 "id": "casemodel",
897 "modalities": { "input": ["text"], "output": ["text"] },
898 "limit": { "context": 2000 }
899 }
900 }
901 }
902 }
903 }"#;
904 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
905 let offerings = catalog
906 .provider_offerings("zai")
907 .expect("Z.ai provider offerings");
908 let r = RouteResolver::from_offerings(offerings);
909
910 let out = r
911 .resolve(&req(Some(ProviderKind::Zai), Some("CASEMODEL")))
912 .expect("ambiguous casefold stays an unknown direct model");
913 assert_eq!(out.wire_model_id().as_str(), "CASEMODEL");
914 assert!(
915 !out.limits().has_known_limit(),
916 "ambiguous rows must not lend arbitrary catalog metadata"
917 );
918 }
919
920 #[test]
921 fn resolver_strict_direct_rejects_other_provider_known_bare_offering() {
922 let r = RouteResolver::new();
923 let out = r.resolve(&req(Some(ProviderKind::Zai), Some("deepseek-v4-pro")));
924 match out {
925 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
926 assert_eq!(provider.as_str(), "zai");
927 assert_eq!(model, "deepseek-v4-pro");
928 }
929 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
930 }
931 }
932
933 #[test]
934 fn resolver_custom_endpoint_allows_namespaced_selector_for_strict_provider() {
935 let r = RouteResolver::new();
936 let request = RouteRequest {
937 explicit_provider: Some(ProviderKind::Deepseek),
938 model_selector: Some(LogicalModelRef::from("vendor/custom-coder")),
939 saved_provider_model: None,
940 base_url_override: Some("https://example.local/v1".to_string()),
941 limit_overrides: Vec::new(),
942 };
943 let out = r
944 .resolve(&request)
945 .expect("custom endpoint should defer model validation upstream");
946 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
947 assert_eq!(out.wire_model_id().as_str(), "vendor/custom-coder");
948 assert_eq!(out.endpoint().base_url, "https://example.local/v1");
949 }
950
951 #[test]
952 fn resolver_treats_every_official_deepseek_endpoint_as_strict_direct() {
953 let resolver = RouteResolver::new();
954 for base_url in [
955 "https://api.deepseek.com",
956 "https://api.deepseek.com/v1/",
957 "https://api.deepseek.com/beta",
958 ] {
959 let request = RouteRequest {
960 explicit_provider: Some(ProviderKind::Deepseek),
961 model_selector: Some(LogicalModelRef::from("anthropic/claude-foo")),
962 saved_provider_model: None,
963 base_url_override: Some(base_url.to_string()),
964 limit_overrides: Vec::new(),
965 };
966 assert!(
967 matches!(
968 resolver.resolve(&request),
969 Err(RouteError::ForeignModelForDirectProvider { .. })
970 ),
971 "official endpoint {base_url} must retain DeepSeek's strict namespace"
972 );
973 }
974 }
975
976 #[test]
977 fn resolver_does_not_trust_deepseek_hostname_substrings() {
978 let resolver = RouteResolver::new();
979 let request = RouteRequest {
980 explicit_provider: Some(ProviderKind::Deepseek),
981 model_selector: Some(LogicalModelRef::from("vendor/custom-coder")),
982 saved_provider_model: None,
983 base_url_override: Some("https://api.deepseek.com.evil.example/v1".to_string()),
984 limit_overrides: Vec::new(),
985 };
986 let route = resolver
987 .resolve(&request)
988 .expect("lookalike host must be treated as a custom endpoint");
989 assert_eq!(route.wire_model_id().as_str(), "vendor/custom-coder");
990 }
991
992 #[test]
993 fn resolver_explicit_custom_with_base_url_override_passes_model_through_verbatim() {
994 // #1519: an explicit `Custom` provider with a base_url override resolves via
995 // the LocalOrCustom pass-through, preserving even a namespaced selector as
996 // the verbatim wire id and binding the override endpoint + Chat Completions.
997 let r = RouteResolver::new();
998 let request = RouteRequest {
999 explicit_provider: Some(ProviderKind::Custom),
1000 model_selector: Some(LogicalModelRef::from("vendor/custom-model-v1")),
1001 saved_provider_model: None,
1002 base_url_override: Some("https://api.example.com/v1".to_string()),
1003 limit_overrides: Vec::new(),
1004 };
1005 let out = r
1006 .resolve(&request)
1007 .expect("custom provider should resolve via pass-through");
1008 assert_eq!(out.provider_kind(), ProviderKind::Custom);
1009 assert_eq!(out.provider_id().as_str(), "custom");
1010 assert_eq!(out.wire_model_id().as_str(), "vendor/custom-model-v1");
1011 assert_eq!(out.endpoint().base_url, "https://api.example.com/v1");
1012 assert_eq!(
1013 out.protocol(),
1014 crate::route::RequestProtocol::ChatCompletions
1015 );
1016 assert!(out.validation().ok);
1017 assert!(out.validation().messages.is_empty());
1018 }
1019
1020 #[test]
1021 fn resolver_strict_direct_rejects_models_dev_offering_from_another_provider() {
1022 let r = models_dev_route_resolver();
1023 let out = r.resolve(&req(Some(ProviderKind::Deepseek), Some("glm-5.2")));
1024 match out {
1025 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
1026 assert_eq!(provider.as_str(), "deepseek");
1027 assert_eq!(model, "glm-5.2");
1028 }
1029 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
1030 }
1031 }
1032
1033 // ---------------------------------------------------------------------------
1034 // #3385: the DEFAULT resolver now sources the bundled Models.dev catalog asset,
1035 // so real provider/model facts (context windows) reach candidates.
1036 // ---------------------------------------------------------------------------
1037
1038 #[test]
1039 fn default_resolver_yields_real_facts_from_bundled_catalog() {
1040 let r = RouteResolver::new();
1041
1042 // A GLM row (Z.ai) resolves to a real, non-default context window — proof
1043 // the bundled asset feeds the default resolver rather than the old 4-row
1044 // seam, which only knew deepseek/together/openrouter and left everything
1045 // else at `RouteLimits::default()` (unknown).
1046 let glm = r
1047 .resolve(&req(Some(ProviderKind::Zai), Some("GLM-5.2")))
1048 .expect("Z.ai GLM-5.2 should resolve from the bundled catalog");
1049 assert_eq!(glm.provider_kind(), ProviderKind::Zai);
1050 assert_eq!(glm.wire_model_id().as_str(), "GLM-5.2");
1051 assert_eq!(
1052 glm.limits().context_tokens,
1053 Some(1_000_000),
1054 "GLM-5.2 must carry its real context window, not the unknown default"
1055 );
1056 assert_eq!(glm.limits().output_tokens, Some(131_072));
1057 assert!(glm.limits().has_known_limit());
1058
1059 // A Kimi row (Moonshot) likewise resolves with its real window — a model
1060 // the 4-row seam never knew about at all.
1061 let kimi_k27 = r
1062 .resolve(&req(Some(ProviderKind::Moonshot), Some("kimi-k2.7-code")))
1063 .expect("Moonshot kimi-k2.7-code should resolve from the bundled catalog");
1064 assert_eq!(kimi_k27.limits().context_tokens, Some(262_144));
1065 assert_eq!(kimi_k27.limits().output_tokens, Some(262_144));
1066
1067 let kimi_k3 = r
1068 .resolve(&req(Some(ProviderKind::Moonshot), Some("kimi-k3")))
1069 .expect("Moonshot kimi-k3 should resolve from the bundled catalog");
1070 assert_eq!(kimi_k3.limits().context_tokens, Some(1_048_576));
1071 assert_eq!(kimi_k3.limits().output_tokens, Some(131_072));
1072
1073 // With the #3085 pricing keystone present on the release branch, the asset's
1074 // provider-scoped `cost` now projects onto the candidate via
1075 // `route_pricing_sku`, so a priced Z.ai row carries a real per-token meter
1076 // rather than `UnknownOrStale` — the "lighting up" that #3385 + #3085 deliver
1077 // together.
1078 let glm51 = r
1079 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.1")))
1080 .expect("Z.ai glm-5.1 should resolve from the bundled catalog");
1081 assert_eq!(glm51.limits().context_tokens, Some(202_752));
1082 assert!(matches!(
1083 glm51.pricing(),
1084 Some(super::candidate::PricingSku::Token { .. })
1085 ));
1086 }
1087
1088 #[test]
1089 fn default_resolver_preserves_seam_canonical_joins() {
1090 // The bundled asset is merged UNDER the hand seam, so the seam's curated
1091 // canonical-model joins still win: a DeepSeek-native selector keeps its
1092 // canonical id, and an aggregator-prefixed wire id still maps back to the
1093 // canonical DeepSeek model. (This is what keeps the existing route
1094 // invariants green after the asset was wired in.)
1095 let r = RouteResolver::new();
1096
1097 let direct = r
1098 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1099 .expect("deepseek-v4-pro resolves");
1100 assert_eq!(
1101 direct.canonical_model().map(ModelId::as_str),
1102 Some("deepseek-v4-pro")
1103 );
1104
1105 let hosted = r
1106 .resolve(&req(
1107 Some(ProviderKind::Together),
1108 Some("deepseek-ai/DeepSeek-V4-Pro"),
1109 ))
1110 .expect("together hosted deepseek resolves");
1111 assert_eq!(
1112 hosted.canonical_model().map(ModelId::as_str),
1113 Some("deepseek-v4-pro"),
1114 "seam canonical join must survive the asset merge"
1115 );
1116 assert_eq!(
1117 hosted.wire_model_id().as_str(),
1118 "deepseek-ai/DeepSeek-V4-Pro"
1119 );
1120 }
1121
1122 #[test]
1123 fn together_inkling_aliases_use_the_exact_wire_identity_without_invented_metadata() {
1124 let resolver = RouteResolver::new();
1125
1126 for requested in ["inkling", "together-inkling", "thinkingmachines/inkling"] {
1127 let route = resolver
1128 .resolve(&req(Some(ProviderKind::Together), Some(requested)))
1129 .expect("Together Inkling route should resolve");
1130 assert_eq!(route.provider_kind(), ProviderKind::Together, "{requested}");
1131 assert_eq!(
1132 route.wire_model_id().as_str(),
1133 "thinkingmachines/inkling",
1134 "{requested}"
1135 );
1136 assert!(route.canonical_model().is_none(), "{requested}");
1137 assert!(!route.limits().has_known_limit(), "{requested}");
1138 assert!(matches!(
1139 route.pricing(),
1140 Some(super::candidate::PricingSku::UnknownOrStale)
1141 ));
1142 }
1143 }
1144
1145 #[test]
1146 fn together_custom_endpoint_preserves_its_explicit_model_id() {
1147 let resolver = RouteResolver::new();
1148 let route = resolver
1149 .resolve(&RouteRequest {
1150 explicit_provider: Some(ProviderKind::Together),
1151 model_selector: Some(LogicalModelRef::from("inkling")),
1152 saved_provider_model: None,
1153 base_url_override: Some("http://127.0.0.1:8000/v1".to_string()),
1154 limit_overrides: Vec::new(),
1155 })
1156 .expect("custom Together-compatible endpoint should resolve");
1157
1158 assert_eq!(route.wire_model_id().as_str(), "inkling");
1159 }
1160
1161 #[test]
1162 fn openrouter_qwen37_plus_aliases_use_exact_catalog_wire_identity() {
1163 let resolver = RouteResolver::new();
1164
1165 for requested in ["qwen3.7-plus", "qwen-3.7-plus", "qwen/qwen3.7-plus"] {
1166 let route = resolver
1167 .resolve(&req(Some(ProviderKind::Openrouter), Some(requested)))
1168 .expect("OpenRouter Qwen 3.7 Plus route should resolve");
1169 assert_eq!(route.wire_model_id().as_str(), "qwen/qwen3.7-plus");
1170 assert!(!route.limits().has_known_limit());
1171 assert!(matches!(
1172 route.pricing(),
1173 Some(super::candidate::PricingSku::Token {
1174 input_per_mtok: Some(_),
1175 output_per_mtok: Some(_)
1176 })
1177 ));
1178 }
1179 }
1180
1181 #[test]
1182 fn openrouter_custom_endpoint_preserves_qwen37_alias() {
1183 let route = RouteResolver::new()
1184 .resolve(&RouteRequest {
1185 explicit_provider: Some(ProviderKind::Openrouter),
1186 model_selector: Some(LogicalModelRef::from("qwen3.7-plus")),
1187 saved_provider_model: None,
1188 base_url_override: Some("https://gateway.example.test/v1".to_string()),
1189 limit_overrides: Vec::new(),
1190 })
1191 .expect("custom OpenRouter-compatible endpoint should resolve");
1192
1193 assert_eq!(route.wire_model_id().as_str(), "qwen3.7-plus");
1194 }
1195
1196 #[test]
1197 fn opencode_go_resolver_preserves_documented_chat_routes() {
1198 let resolver = RouteResolver::new();
1199 // Literal review fixture: the 2026-09-08 endpoint table plus previously
1200 // accepted IDs retained for compatibility. Do not derive from the contract.
1201 let chat_models = [
1202 "deepseek-v4-pro",
1203 "grok-4.5",
1204 "glm-5.2",
1205 "glm-5.1",
1206 "kimi-k3",
1207 "kimi-k2.7-code",
1208 "kimi-k2.6",
1209 "deepseek-v4-flash",
1210 "mimo-v2.5",
1211 "mimo-v2.5-pro",
1212 "glm-5.3-flash",
1213 "glm-5.3",
1214 "longcat-2.0",
1215 "deepseek-v4-flash-vision-exp",
1216 "hy4-preview",
1217 "hy3",
1218 "omen-alpha",
1219 ];
1220 assert!(
1221 chat_models
1222 .iter()
1223 .all(|id| crate::opencode_go_models().contains(id))
1224 );
1225
1226 for model in chat_models {
1227 for requested in [model.to_string(), format!("opencode-go/{model}")] {
1228 let route = resolver
1229 .resolve(&req(Some(ProviderKind::OpencodeGo), Some(&requested)))
1230 .unwrap_or_else(|error| panic!("{requested} should resolve: {error}"));
1231 assert_eq!(
1232 route.provider_kind(),
1233 ProviderKind::OpencodeGo,
1234 "{requested}"
1235 );
1236 assert_eq!(route.wire_model_id().as_str(), model, "{requested}");
1237 assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
1238 }
1239 }
1240
1241 let automatic = resolver
1242 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("auto")))
1243 .expect("OpenCode Go auto should resolve to its Chat default");
1244 assert_eq!(automatic.wire_model_id().as_str(), "deepseek-v4-pro");
1245 }
1246
1247 #[test]
1248 fn opencode_go_resolver_uses_documented_protocols_and_refuses_unknown_models() {
1249 let resolver = RouteResolver::new();
1250 let fixtures = [
1251 ("deepseek-v4.1-flash", RequestProtocol::ChatCompletions),
1252 ("grok-4.6", RequestProtocol::Responses),
1253 ("gpt-5.6-luna", RequestProtocol::Responses),
1254 ("muse-spark-1.3-contributor", RequestProtocol::Responses),
1255 ("muse-spark-1.2-contributor", RequestProtocol::Responses),
1256 ("minimax-m3", RequestProtocol::AnthropicMessages),
1257 ("minimax-m2.7", RequestProtocol::AnthropicMessages),
1258 ("minimax-m2.5", RequestProtocol::AnthropicMessages),
1259 ("qwen3.8-max", RequestProtocol::AnthropicMessages),
1260 ("qwen3.8-flash", RequestProtocol::AnthropicMessages),
1261 ("qwen3.7-max", RequestProtocol::AnthropicMessages),
1262 ("qwen3.7-plus", RequestProtocol::AnthropicMessages),
1263 ("qwen3.6-plus", RequestProtocol::AnthropicMessages),
1264 ];
1265 for (model, protocol) in fixtures {
1266 for requested in [model.to_string(), format!("opencode-go/{model}")] {
1267 for base_url_override in [None, Some("https://go-gateway.example.test/v1".to_string())]
1268 {
1269 let request = RouteRequest {
1270 explicit_provider: Some(ProviderKind::OpencodeGo),
1271 model_selector: Some(LogicalModelRef::from(requested.as_str())),
1272 base_url_override,
1273 ..RouteRequest::default()
1274 };
1275 let route = resolver.resolve(&request).expect("documented Go route");
1276 assert_eq!(route.wire_model_id().as_str(), model);
1277 assert_eq!(route.protocol(), protocol, "{requested}");
1278 if request.base_url_override.is_some() {
1279 assert!(
1280 !route.limits().has_known_limit(),
1281 "custom endpoint has no sourced limits"
1282 );
1283 }
1284 }
1285 }
1286 }
1287 for model in [
1288 "claude-unproven",
1289 "opencode-go/unknown",
1290 "openai/gpt-5.6-luna",
1291 ] {
1292 for base_url_override in [None, Some("https://go-gateway.example.test/v1".into())] {
1293 assert!(
1294 resolver
1295 .resolve(&RouteRequest {
1296 explicit_provider: Some(ProviderKind::OpencodeGo),
1297 model_selector: Some(LogicalModelRef::from(model)),
1298 base_url_override,
1299 ..RouteRequest::default()
1300 })
1301 .is_err(),
1302 "unknown {model} must not select a default or another wire"
1303 );
1304 }
1305 }
1306 }
1307
1308 #[test]
1309 fn opencode_go_protocol_cannot_be_downgraded_by_stale_catalog_metadata() {
1310 let mut offerings = super::offering::bundled_offerings();
1311 let row = offerings
1312 .iter_mut()
1313 .find(|row| {
1314 row.provider.as_str() == "opencode-go" && row.wire_model_id.as_str() == "minimax-m3"
1315 })
1316 .unwrap();
1317 row.endpoint_key = "chat".into();
1318 let route = RouteResolver::from_offerings(offerings)
1319 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("minimax-m3")))
1320 .unwrap();
1321 assert_eq!(route.protocol(), RequestProtocol::AnthropicMessages);
1322 }
1323
1324 #[test]
1325 fn codewhale_route_picks_its_wire_per_model_and_stays_open_to_the_account_catalog() {
1326 use super::CODEWHALE_FALLBACK_MODELS;
1327
1328 let resolver = RouteResolver::new();
1329 for model in CODEWHALE_FALLBACK_MODELS {
1330 let route = resolver
1331 .resolve(&req(Some(ProviderKind::Codewhale), Some(model)))
1332 .unwrap_or_else(|error| panic!("{model} should resolve: {error}"));
1333 assert_eq!(route.provider_kind(), ProviderKind::Codewhale);
1334 // Ids pass through exactly as the account catalog returns them.
1335 assert_eq!(route.wire_model_id().as_str(), *model);
1336 let expected = if model.starts_with("anthropic/") {
1337 RequestProtocol::AnthropicMessages
1338 } else {
1339 RequestProtocol::ChatCompletions
1340 };
1341 assert_eq!(route.protocol(), expected, "{model}");
1342 }
1343
1344 let automatic = resolver
1345 .resolve(&req(Some(ProviderKind::Codewhale), Some("auto")))
1346 .expect("the Codewhale route has a bootstrap default");
1347 assert_eq!(
1348 automatic.wire_model_id().as_str(),
1349 "deepseek/deepseek-v4-pro"
1350 );
1351 assert_eq!(automatic.protocol(), RequestProtocol::ChatCompletions);
1352
1353 // A provider the account connected after this build shipped must still
1354 // resolve: the account catalog, not a compiled roster, owns the list.
1355 let unseen = resolver
1356 .resolve(&req(Some(ProviderKind::Codewhale), Some("xai/grok-4.6")))
1357 .expect("an account-connected provider this build never heard of");
1358 assert_eq!(unseen.wire_model_id().as_str(), "xai/grok-4.6");
1359 assert_eq!(unseen.protocol(), RequestProtocol::ChatCompletions);
1360 let unseen_anthropic = resolver
1361 .resolve(&req(
1362 Some(ProviderKind::Codewhale),
1363 Some("anthropic/claude-opus-4-8"),
1364 ))
1365 .expect("the Anthropic namespace routes to the Messages passthrough");
1366 assert_eq!(
1367 unseen_anthropic.protocol(),
1368 RequestProtocol::AnthropicMessages
1369 );
1370 }
1371
1372 #[test]
1373 fn codewhale_api_base_env_refuses_cleartext_off_loopback() {
1374 use crate::provider::codewhale_api_base;
1375
1376 assert_eq!(
1377 codewhale_api_base("https://api.codewhale.net/v1/").as_deref(),
1378 Some("https://api.codewhale.net/v1")
1379 );
1380 assert_eq!(
1381 codewhale_api_base("http://127.0.0.1:8787/v1").as_deref(),
1382 Some("http://127.0.0.1:8787/v1")
1383 );
1384 assert!(codewhale_api_base("http://localhost:8787/v1").is_some());
1385 for refused in [
1386 "",
1387 " ",
1388 "http://api.codewhale.net/v1",
1389 "https://user:pass@api.codewhale.net/v1",
1390 "ftp://api.codewhale.net/v1",
1391 "not a url",
1392 ] {
1393 assert!(codewhale_api_base(refused).is_none(), "{refused:?}");
1394 }
1395 }
1396
1397 #[test]
1398 fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() {
1399 use super::offering::{
1400 OPENCODE_ZEN_CHAT_MODELS, OPENCODE_ZEN_MESSAGES_MODELS, OPENCODE_ZEN_RESPONSES_MODELS,
1401 };
1402
1403 let resolver = RouteResolver::new();
1404 let groups = [
1405 (
1406 OPENCODE_ZEN_RESPONSES_MODELS,
1407 "responses",
1408 RequestProtocol::Responses,
1409 ),
1410 (
1411 OPENCODE_ZEN_MESSAGES_MODELS,
1412 "messages",
1413 RequestProtocol::AnthropicMessages,
1414 ),
1415 (
1416 OPENCODE_ZEN_CHAT_MODELS,
1417 "chat",
1418 RequestProtocol::ChatCompletions,
1419 ),
1420 ];
1421
1422 for (models, endpoint_key, protocol) in groups {
1423 for model in models {
1424 for requested in [model.to_string(), format!("opencode/{model}")] {
1425 let route = resolver
1426 .resolve(&req(Some(ProviderKind::OpencodeZen), Some(&requested)))
1427 .unwrap_or_else(|error| panic!("{requested} should resolve: {error}"));
1428 assert_eq!(route.provider_kind(), ProviderKind::OpencodeZen);
1429 assert_eq!(route.wire_model_id().as_str(), *model, "{requested}");
1430 assert_eq!(route.endpoint().endpoint_key, endpoint_key, "{requested}");
1431 assert_eq!(route.protocol(), protocol, "{requested}");
1432 }
1433 }
1434 }
1435
1436 let automatic = resolver
1437 .resolve(&req(Some(ProviderKind::OpencodeZen), Some("auto")))
1438 .expect("OpenCode Zen auto should resolve to its documented default");
1439 assert_eq!(automatic.wire_model_id().as_str(), "gpt-5.6-sol");
1440 assert_eq!(automatic.protocol(), RequestProtocol::Responses);
1441
1442 let logical_gpt_5_6 = resolver
1443 .resolve(&req(Some(ProviderKind::OpencodeZen), Some("gpt-5.6")))
1444 .expect("the logical GPT-5.6 picker row resolves to Zen's proven Sol wire route");
1445 assert_eq!(logical_gpt_5_6.wire_model_id().as_str(), "gpt-5.6-sol");
1446 assert_eq!(logical_gpt_5_6.protocol(), RequestProtocol::Responses);
1447 }
1448
1449 #[test]
1450 fn opencode_zen_resolver_fails_closed_for_unproven_protocols() {
1451 let resolver = RouteResolver::new();
1452
1453 for model in [
1454 "gemini-3.1-pro",
1455 "opencode/gemini-3.5-flash",
1456 "unknown-model",
1457 ] {
1458 for base_url_override in [
1459 None,
1460 Some("https://zen-gateway.example.test/v1".to_string()),
1461 ] {
1462 let request = RouteRequest {
1463 explicit_provider: Some(ProviderKind::OpencodeZen),
1464 model_selector: Some(LogicalModelRef::from(model)),
1465 saved_provider_model: None,
1466 base_url_override,
1467 limit_overrides: Vec::new(),
1468 };
1469 assert!(
1470 matches!(
1471 resolver.resolve(&request),
1472 Err(RouteError::UnsupportedModelProtocol { .. })
1473 ),
1474 "{model} must fail closed without a supported protocol mapping"
1475 );
1476 }
1477 }
1478 }
1479
1480 #[test]
1481 fn resolver_deepseek_none_selector_uses_default_wire_id() {
1482 let r = RouteResolver::new();
1483 let out = r
1484 .resolve(&req(Some(ProviderKind::Deepseek), None))
1485 .expect("none selector should use provider default");
1486 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
1487 assert_eq!(out.wire_model_id().as_str(), "deepseek-flash");
1488 assert_eq!(out.protocol(), RequestProtocol::Responses);
1489 }
1490
1491 #[test]
1492 fn resolver_empty_string_selector_is_empty_model_error() {
1493 let r = RouteResolver::new();
1494 let out = r.resolve(&req(Some(ProviderKind::Deepseek), Some("")));
1495 assert!(matches!(out, Err(RouteError::EmptyModel)));
1496 }
1497
1498 #[test]
1499 fn resolver_rejects_retired_antigravity_before_minting_a_route() {
1500 let error = RouteResolver::new()
1501 .resolve(&req(Some(ProviderKind::Antigravity), Some("gemini-3-pro")))
1502 .expect_err("the legacy tombstone must never produce an executable route");
1503 let rendered = error.to_string();
1504 assert!(matches!(error, RouteError::InvalidProvider(_)));
1505 assert!(rendered.contains("non-runnable"), "{rendered}");
1506 assert!(
1507 rendered.contains("auth clear --provider antigravity"),
1508 "{rendered}"
1509 );
1510 assert!(rendered.contains("google"), "{rendered}");
1511 assert!(rendered.contains("GEMINI_API_KEY"), "{rendered}");
1512 }
1513
1514 #[test]
1515 fn resolver_empty_saved_provider_model_is_empty_model_error() {
1516 // An empty selector from the saved-model fallback must be rejected too, not
1517 // just an empty explicit selector (the guard covers every selector source).
1518 let r = RouteResolver::new();
1519 let request = RouteRequest {
1520 explicit_provider: Some(ProviderKind::Deepseek),
1521 model_selector: None,
1522 saved_provider_model: Some(WireModelId::from("")),
1523 base_url_override: None,
1524 limit_overrides: Vec::new(),
1525 };
1526 assert!(matches!(r.resolve(&request), Err(RouteError::EmptyModel)));
1527 }
1528
1529 #[test]
1530 fn resolver_passthrough_provider_preserves_custom_id_verbatim() {
1531 let r = RouteResolver::new();
1532 let out = r
1533 .resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1534 .expect("local passthrough should resolve");
1535 assert_eq!(out.provider_kind(), ProviderKind::Ollama);
1536 assert_eq!(out.wire_model_id().as_str(), "my-local:7b");
1537 assert_eq!(out.limits(), Default::default());
1538 assert!(out.validation().ok);
1539 }
1540
1541 #[test]
1542 fn resolved_candidate_serializes_secret_free() {
1543 let r = RouteResolver::new();
1544 // Cover a direct, an aggregator, and a local/passthrough route.
1545 let candidates = [
1546 r.resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1547 .expect("direct resolves"),
1548 r.resolve(&req(
1549 Some(ProviderKind::Together),
1550 Some("deepseek-ai/DeepSeek-V4-Pro"),
1551 ))
1552 .expect("aggregator resolves"),
1553 r.resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1554 .expect("local resolves"),
1555 ];
1556 for out in candidates {
1557 let json = serde_json::to_string(&out).expect("candidate serializes");
1558 // Carries provider/model/wire/protocol/auth-source class.
1559 assert!(json.contains("provider_id"), "{json}");
1560 assert!(json.contains("provider_kind"), "{json}");
1561 assert!(json.contains("wire_model_id"), "{json}");
1562 assert!(json.contains("protocol"), "{json}");
1563 assert!(json.contains("auth"), "{json}");
1564 // Never any secret/api-key material.
1565 let lower = json.to_lowercase();
1566 assert!(!lower.contains("api_key"), "leaked api_key: {json}");
1567 assert!(!lower.contains("apikey"), "leaked apikey: {json}");
1568 assert!(!lower.contains("secret_id"), "leaked secret_id: {json}");
1569 assert!(!lower.contains("password"), "leaked password: {json}");
1570 assert!(!lower.contains("bearer"), "leaked bearer: {json}");
1571 assert!(
1572 !lower.contains("authorization"),
1573 "leaked authorization: {json}"
1574 );
1575 }
1576 }
1577
1578 #[test]
1579 fn resolver_protocol_matches_descriptor_for_every_provider() {
1580 let r = RouteResolver::new();
1581 for kind in ProviderKind::ALL {
1582 // Use each provider's own default wire id as the selector so strict
1583 // direct providers do not reject; this exercises the resolver across
1584 // the whole provider set.
1585 let default_wire = ProviderDescriptor::for_kind(kind).default_wire_model();
1586 let request = req(Some(kind), Some(default_wire.as_str()));
1587 let out = r
1588 .resolve(&request)
1589 .unwrap_or_else(|e| panic!("{kind:?} should resolve its own default: {e}"));
1590 assert_eq!(
1591 out.protocol(),
1592 ProviderDescriptor::for_kind(kind)
1593 .protocol_for_endpoint(&out.endpoint().endpoint_key)
1594 .expect("resolved endpoint protocol"),
1595 "{kind:?} candidate protocol must match descriptor"
1596 );
1597 assert_eq!(
1598 out.endpoint().protocol,
1599 out.protocol(),
1600 "{kind:?} endpoint protocol"
1601 );
1602 }
1603 }
1604
1605 // ---------------------------------------------------------------------------
1606 // #3085: honest pricing on resolved candidates.
1607 // ---------------------------------------------------------------------------
1608
1609 /// A resolver whose single offering is a DeepSeek-priced catalog row, projected
1610 /// through the wired `CatalogOffering::to_offering` pricing seam.
1611 fn priced_deepseek_resolver() -> RouteResolver {
1612 use crate::catalog::{CatalogOffering, CatalogSource};
1613 use crate::models_dev::ModelsDevCost;
1614
1615 let priced = CatalogOffering {
1616 provider: "deepseek".into(),
1617 wire_model_id: "deepseek-v4-pro".into(),
1618 canonical_model: Some("deepseek-v4-pro".into()),
1619 endpoint_key: "chat".into(),
1620 default_for_provider: true,
1621 cost: Some(ModelsDevCost {
1622 input: Some(0.28),
1623 output: Some(0.42),
1624 cache_read: Some(0.028),
1625 cache_write: None,
1626 }),
1627 source: CatalogSource::Bundled,
1628 ..Default::default()
1629 };
1630 RouteResolver::from_offerings(vec![priced.to_offering()])
1631 }
1632
1633 #[test]
1634 fn resolver_carries_exact_offering_capabilities_without_protocol_inference() {
1635 use crate::catalog::{CatalogOffering, CatalogSource};
1636 use crate::route::CapabilityState;
1637
1638 let offering = CatalogOffering {
1639 provider: "deepseek".into(),
1640 wire_model_id: "deepseek-v4-pro".into(),
1641 canonical_model: Some("deepseek-v4-pro".into()),
1642 endpoint_key: "chat".into(),
1643 default_for_provider: true,
1644 attachment: Some(false),
1645 reasoning: Some(true),
1646 tool_call: Some(false),
1647 structured_output: Some(true),
1648 source: CatalogSource::Bundled,
1649 ..Default::default()
1650 };
1651 let resolver = RouteResolver::from_offerings(vec![offering.to_offering()]);
1652
1653 let candidate = resolver
1654 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1655 .expect("matching offering resolves");
1656 let capabilities = candidate.capabilities();
1657
1658 assert_eq!(capabilities.attachments, CapabilityState::Unsupported);
1659 assert_eq!(capabilities.reasoning, CapabilityState::Supported);
1660 assert_eq!(capabilities.native_tool_calls, CapabilityState::Unsupported);
1661 assert_eq!(capabilities.structured_output, CapabilityState::Supported);
1662 assert_eq!(capabilities.streaming, CapabilityState::Unknown);
1663 assert_eq!(
1664 capabilities.server_side_web_search,
1665 CapabilityState::Supported
1666 );
1667 }
1668
1669 #[test]
1670 fn provider_native_web_search_requires_exact_direct_endpoint_offering() {
1671 use crate::route::CapabilityState;
1672
1673 let resolver = RouteResolver::new();
1674 let automatic = resolver
1675 .resolve(&req(Some(ProviderKind::Xai), None))
1676 .expect("xAI default resolves");
1677 assert_eq!(automatic.wire_model_id().as_str(), "grok-4.6");
1678
1679 let direct = resolver
1680 .resolve(&req(Some(ProviderKind::Xai), Some("grok-4.6")))
1681 .expect("bundled direct xAI offering resolves");
1682 assert_eq!(direct.wire_model_id().as_str(), "grok-4.6");
1683 assert_eq!(
1684 direct.capabilities().attachments,
1685 CapabilityState::Supported
1686 );
1687 assert_eq!(
1688 direct.capabilities().image_input,
1689 CapabilityState::Supported
1690 );
1691 assert_eq!(direct.capabilities().reasoning, CapabilityState::Supported);
1692 assert_eq!(
1693 direct.capabilities().native_tool_calls,
1694 CapabilityState::Supported
1695 );
1696 assert_eq!(
1697 direct.capabilities().structured_output,
1698 CapabilityState::Supported
1699 );
1700 assert_eq!(
1701 direct.capabilities().server_side_web_search,
1702 CapabilityState::Supported
1703 );
1704
1705 let aggregator = resolver
1706 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("grok-4.5")))
1707 .expect("aggregator offering resolves independently");
1708 assert_eq!(
1709 aggregator.capabilities().server_side_web_search,
1710 CapabilityState::Unknown
1711 );
1712
1713 let custom_endpoint = resolver
1714 .resolve(&RouteRequest {
1715 explicit_provider: Some(ProviderKind::Xai),
1716 model_selector: Some(LogicalModelRef::from("grok-4.6")),
1717 saved_provider_model: None,
1718 base_url_override: Some("https://gateway.example.test/v1".to_string()),
1719 limit_overrides: Vec::new(),
1720 })
1721 .expect("custom compatible endpoint resolves");
1722 assert_eq!(
1723 custom_endpoint.capabilities().server_side_web_search,
1724 CapabilityState::Unknown
1725 );
1726 }
1727
1728 #[test]
1729 fn mimo_native_search_is_exact_to_documented_chat_models() {
1730 use crate::route::CapabilityState;
1731
1732 let resolver = RouteResolver::new();
1733 for model in ["mimo-v2.5-pro", "mimo-v2.5"] {
1734 let direct = resolver
1735 .resolve(&req(Some(ProviderKind::XiaomiMimo), Some(model)))
1736 .expect("official MiMo chat route resolves");
1737 assert_eq!(
1738 direct.capabilities().server_side_web_search,
1739 CapabilityState::Supported
1740 );
1741 }
1742
1743 let neighboring = resolver
1744 .resolve(&req(
1745 Some(ProviderKind::XiaomiMimo),
1746 Some("mimo-v2.5-pro-ultraspeed"),
1747 ))
1748 .expect("neighboring MiMo model resolves");
1749 assert_eq!(
1750 neighboring.capabilities().server_side_web_search,
1751 CapabilityState::Unknown
1752 );
1753
1754 let custom = resolver
1755 .resolve(&RouteRequest {
1756 explicit_provider: Some(ProviderKind::XiaomiMimo),
1757 model_selector: Some(LogicalModelRef::from("mimo-v2.5-pro")),
1758 saved_provider_model: None,
1759 base_url_override: Some("https://compatible.example.test/v1".to_string()),
1760 limit_overrides: Vec::new(),
1761 })
1762 .expect("custom MiMo-compatible route resolves");
1763 assert_eq!(
1764 custom.capabilities().server_side_web_search,
1765 CapabilityState::Unknown
1766 );
1767 }
1768
1769 #[test]
1770 fn zai_native_search_requires_exact_general_api_product() {
1771 use crate::route::CapabilityState;
1772
1773 let resolver = RouteResolver::new();
1774 for base_url in [
1775 "https://api.z.ai/api/paas/v4",
1776 "https://open.bigmodel.cn/api/paas/v4",
1777 ] {
1778 let direct = resolver
1779 .resolve(&RouteRequest {
1780 explicit_provider: Some(ProviderKind::Zai),
1781 model_selector: Some(LogicalModelRef::from("GLM-5.3")),
1782 saved_provider_model: None,
1783 base_url_override: Some(base_url.to_string()),
1784 limit_overrides: Vec::new(),
1785 })
1786 .expect("general API route resolves");
1787 assert_eq!(
1788 direct.capabilities().server_side_web_search,
1789 CapabilityState::Supported,
1790 "{base_url} should expose structured web search"
1791 );
1792 }
1793
1794 for base_url in [
1795 "https://api.z.ai/api/coding/paas/v4",
1796 "https://open.bigmodel.cn/api/paas/v4/preview",
1797 "https://compatible.example.test/v1",
1798 ] {
1799 let adjacent = resolver
1800 .resolve(&RouteRequest {
1801 explicit_provider: Some(ProviderKind::Zai),
1802 model_selector: Some(LogicalModelRef::from("GLM-5.3")),
1803 saved_provider_model: None,
1804 base_url_override: Some(base_url.to_string()),
1805 limit_overrides: Vec::new(),
1806 })
1807 .expect("adjacent route resolves");
1808 assert_eq!(
1809 adjacent.capabilities().server_side_web_search,
1810 CapabilityState::Unknown,
1811 "{base_url} must remain fail-closed"
1812 );
1813 }
1814 }
1815
1816 #[test]
1817 fn qwen_native_search_is_exact_to_token_plan_responses_routes() {
1818 use crate::route::CapabilityState;
1819
1820 let resolver = RouteResolver::new();
1821 let direct = resolver
1822 .resolve(&req(
1823 Some(ProviderKind::ModelstudioTokenPlan),
1824 Some("qwen3.8-max"),
1825 ))
1826 .expect("Token Plan Qwen route resolves");
1827 assert_eq!(
1828 direct.capabilities().server_side_web_search,
1829 CapabilityState::Supported
1830 );
1831
1832 let preview = resolver
1833 .resolve(&req(
1834 Some(ProviderKind::ModelstudioTokenPlan),
1835 Some("qwen3.8-max-preview"),
1836 ))
1837 .expect("neighboring preview route resolves");
1838 assert_eq!(
1839 preview.capabilities().server_side_web_search,
1840 CapabilityState::Unknown
1841 );
1842
1843 for base_url in [
1844 "https://coding-intl.dashscope.aliyuncs.com/v1",
1845 "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
1846 "https://compatible.example.test/v1",
1847 ] {
1848 let alternate = resolver
1849 .resolve(&RouteRequest {
1850 explicit_provider: Some(ProviderKind::ModelstudioTokenPlan),
1851 model_selector: Some(LogicalModelRef::from("qwen3.8-max")),
1852 saved_provider_model: None,
1853 base_url_override: Some(base_url.to_string()),
1854 limit_overrides: Vec::new(),
1855 })
1856 .expect("alternate product route resolves");
1857 assert_eq!(
1858 alternate.capabilities().server_side_web_search,
1859 CapabilityState::Unknown,
1860 "{base_url} must not inherit Token Plan Responses search"
1861 );
1862 }
1863 }
1864
1865 #[test]
1866 fn moonshot_native_search_requires_exact_product_model_pair() {
1867 use crate::route::CapabilityState;
1868
1869 let resolver = RouteResolver::new();
1870 for (model, base_url) in [
1871 ("kimi-k3", "https://api.moonshot.ai/v1"),
1872 ("kimi-k3", "https://api.moonshot.cn/v1"),
1873 ("kimi-k2.6", "https://api.moonshot.ai/v1"),
1874 ("k3", "https://api.kimi.com/coding/v1"),
1875 ("kimi-for-coding", "https://api.kimi.com/coding/v1"),
1876 ] {
1877 let direct = resolver
1878 .resolve(&RouteRequest {
1879 explicit_provider: Some(ProviderKind::Moonshot),
1880 model_selector: Some(LogicalModelRef::from(model)),
1881 saved_provider_model: None,
1882 base_url_override: Some(base_url.to_string()),
1883 limit_overrides: Vec::new(),
1884 })
1885 .expect("documented Moonshot/Kimi route resolves");
1886 assert_eq!(
1887 direct.capabilities().server_side_web_search,
1888 CapabilityState::Supported,
1889 "{base_url}/{model} should expose native search"
1890 );
1891 }
1892
1893 for (model, base_url) in [
1894 ("kimi-k3", "https://api.kimi.com/coding/v2"),
1895 ("kimi-k2.6", "https://api.kimi.com/coding/v1/preview"),
1896 ("k3", "https://api.moonshot.ai/v1"),
1897 ("kimi-k2.7-code", "https://api.moonshot.ai/v1"),
1898 ] {
1899 let adjacent = resolver
1900 .resolve(&RouteRequest {
1901 explicit_provider: Some(ProviderKind::Moonshot),
1902 model_selector: Some(LogicalModelRef::from(model)),
1903 saved_provider_model: None,
1904 base_url_override: Some(base_url.to_string()),
1905 limit_overrides: Vec::new(),
1906 })
1907 .expect("adjacent Moonshot/Kimi route resolves");
1908 assert_eq!(
1909 adjacent.capabilities().server_side_web_search,
1910 CapabilityState::Unknown,
1911 "{base_url}/{model} must remain fail-closed"
1912 );
1913 }
1914 }
1915
1916 #[test]
1917 fn priced_offering_yields_token_pricing_sku() {
1918 use super::candidate::PricingSku;
1919
1920 let r = priced_deepseek_resolver();
1921 let out = r
1922 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1923 .expect("priced DeepSeek route should resolve");
1924
1925 match out.pricing() {
1926 Some(PricingSku::Token {
1927 input_per_mtok,
1928 output_per_mtok,
1929 }) => {
1930 assert_eq!(*input_per_mtok, Some(0.28));
1931 assert_eq!(*output_per_mtok, Some(0.42));
1932 }
1933 other => panic!("expected Some(Token), got {other:?}"),
1934 }
1935 }
1936
1937 #[test]
1938 fn custom_endpoint_does_not_inherit_first_party_pricing() {
1939 use super::candidate::PricingSku;
1940
1941 let out = priced_deepseek_resolver()
1942 .resolve(&RouteRequest {
1943 explicit_provider: Some(ProviderKind::Deepseek),
1944 model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")),
1945 saved_provider_model: None,
1946 base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()),
1947 limit_overrides: Vec::new(),
1948 })
1949 .expect("same-name custom proxy route resolves");
1950
1951 assert!(
1952 matches!(out.pricing(), Some(PricingSku::UnknownOrStale)),
1953 "a custom proxy cannot inherit first-party pricing by reusing the model id: {:?}",
1954 out.pricing()
1955 );
1956 }
1957
1958 #[test]
1959 fn exact_endpoint_catalog_may_carry_its_own_pricing_on_a_custom_base_url() {
1960 use super::candidate::PricingSku;
1961
1962 let request = RouteRequest {
1963 explicit_provider: Some(ProviderKind::Deepseek),
1964 model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")),
1965 saved_provider_model: None,
1966 base_url_override: Some("https://authenticated-catalog.example.test/v1".to_string()),
1967 limit_overrides: Vec::new(),
1968 };
1969 let out = priced_deepseek_resolver()
1970 .resolve_with_endpoint_catalog_authority(&request)
1971 .expect("exact endpoint-owned catalog route resolves");
1972
1973 match out.pricing() {
1974 Some(PricingSku::Token {
1975 input_per_mtok,
1976 output_per_mtok,
1977 }) => {
1978 assert_eq!(*input_per_mtok, Some(0.28));
1979 assert_eq!(*output_per_mtok, Some(0.42));
1980 }
1981 other => panic!("expected exact endpoint pricing, got {other:?}"),
1982 }
1983 }
1984
1985 #[test]
1986 fn unpriced_offering_stays_unknown() {
1987 use super::candidate::PricingSku;
1988
1989 // The bundled seam (`RouteResolver::new`) carries no sourced cost, so a
1990 // matched offering must surface honest UnknownOrStale, never a fabricated
1991 // zero price (#2608 / #3085 honesty rule).
1992 let r = RouteResolver::new();
1993 let out = r
1994 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1995 .expect("bundled DeepSeek route should resolve");
1996 assert!(
1997 matches!(out.pricing(), Some(PricingSku::UnknownOrStale)),
1998 "bundled offering carries no price → UnknownOrStale, got {:?}",
1999 out.pricing()
2000 );
2001
2002 // A pass-through route with no matched offering is likewise unknown.
2003 let passthrough = r
2004 .resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
2005 .expect("local passthrough should resolve");
2006 assert!(matches!(
2007 passthrough.pricing(),
2008 Some(PricingSku::UnknownOrStale)
2009 ));
2010 }
2011
2012 // ---------------------------------------------------------------------------
2013 // #1519: advisory insecure-http warning, loopback-exempt.
2014 // ---------------------------------------------------------------------------
2015
2016 /// Build a request with an explicit base-URL override.
2017 fn req_with_base(provider: ProviderKind, model: &str, base_url: &str) -> RouteRequest {
2018 RouteRequest {
2019 explicit_provider: Some(provider),
2020 model_selector: Some(LogicalModelRef::from(model)),
2021 saved_provider_model: None,
2022 base_url_override: Some(base_url.to_string()),
2023 limit_overrides: Vec::new(),
2024 }
2025 }
2026
2027 #[test]
2028 fn http_custom_endpoint_emits_insecure_warning() {
2029 let r = RouteResolver::new();
2030 let out = r
2031 .resolve(&req_with_base(
2032 ProviderKind::Openai,
2033 "gpt-whatever",
2034 "http://example.com/v1",
2035 ))
2036 .expect("custom http endpoint should still resolve");
2037
2038 // Advisory only: the route stays usable.
2039 assert!(
2040 out.validation().ok,
2041 "insecure http is advisory, not a hard fail"
2042 );
2043 assert!(
2044 out.validation()
2045 .messages
2046 .iter()
2047 .any(|m| m.contains("insecure http")),
2048 "expected an insecure-http advisory, got {:?}",
2049 out.validation().messages
2050 );
2051 }
2052
2053 #[test]
2054 fn loopback_http_endpoint_does_not_warn() {
2055 let r = RouteResolver::new();
2056 // localhost, 127.0.0.1, and ::1 are all loopback and must stay clean.
2057 for base in [
2058 "http://localhost:11434/v1",
2059 "http://127.0.0.1:8000/v1",
2060 "http://[::1]:8080/v1",
2061 ] {
2062 let out = r
2063 .resolve(&req_with_base(ProviderKind::Ollama, "my-local:7b", base))
2064 .unwrap_or_else(|e| panic!("loopback route {base} should resolve: {e}"));
2065 assert!(out.validation().ok);
2066 assert!(
2067 out.validation().messages.is_empty(),
2068 "loopback {base} must not warn, got {:?}",
2069 out.validation().messages
2070 );
2071 }
2072 }
2073
2074 #[test]
2075 fn https_endpoint_has_no_warning() {
2076 let r = RouteResolver::new();
2077 let out = r
2078 .resolve(&req_with_base(
2079 ProviderKind::Openai,
2080 "gpt-whatever",
2081 "https://example.com/v1",
2082 ))
2083 .expect("https endpoint should resolve");
2084 assert!(out.validation().ok);
2085 assert!(
2086 out.validation().messages.is_empty(),
2087 "https must not warn, got {:?}",
2088 out.validation().messages
2089 );
2090 }
2091
2091 lines RUST