返回 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 LimitField, OverrideSource, RequestProtocol, ResolvedAuthSource, SourcedLimitOverride,
9 };
10 use crate::ProviderKind;
11 use crate::models_dev::ModelsDevCatalog;
12
13 /// Build a request with only an explicit provider + a model selector string.
14 fn req(provider: Option<ProviderKind>, model: Option<&str>) -> RouteRequest {
15 RouteRequest {
16 explicit_provider: provider,
17 model_selector: model.map(LogicalModelRef::from),
18 saved_provider_model: None,
19 base_url_override: None,
20 limit_overrides: Vec::new(),
21 }
22 }
23
24 fn models_dev_route_resolver() -> RouteResolver {
25 let raw = r#"{
26 "providers": {
27 "zai": {
28 "models": {
29 "glm-5.2": {
30 "id": "glm-5.2",
31 "base_model": "zhipuai/glm-5.2",
32 "default": true,
33 "modalities": { "input": ["text"], "output": ["text"] },
34 "limit": { "context": 1000000, "input": 900000, "output": 131072 }
35 }
36 }
37 },
38 "openrouter": {
39 "models": {
40 "z-ai/glm-5.2": {
41 "id": "z-ai/glm-5.2",
42 "base_model": "zhipuai/glm-5.2",
43 "modalities": { "input": ["text"], "output": ["text"] },
44 "limit": { "context": 128000, "output": 32768 }
45 }
46 }
47 }
48 }
49 }"#;
50 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
51 let mut offerings = catalog
52 .provider_offerings("zai")
53 .expect("zai provider offerings");
54 offerings.extend(
55 catalog
56 .provider_offerings("openrouter")
57 .expect("openrouter provider offerings"),
58 );
59 RouteResolver::from_offerings(offerings)
60 }
61
62 #[test]
63 fn provider_id_from_kind_uses_canonical_id() {
64 assert_eq!(
65 ProviderId::from_kind(ProviderKind::Deepseek).as_str(),
66 "deepseek"
67 );
68 assert_eq!(
69 ProviderId::from_kind(ProviderKind::Openrouter).as_str(),
70 "openrouter"
71 );
72 }
73
74 #[test]
75 fn model_id_and_wire_model_id_are_distinct_types() {
76 // This test asserts the values; the *type* distinction is enforced by the
77 // compiler: a function taking `WireModelId` rejects a `ModelId` argument.
78 let canonical = ModelId::from("deepseek-v4-pro");
79 let wire = WireModelId::from("deepseek-ai/DeepSeek-V4-Pro");
80 assert_eq!(canonical.as_str(), "deepseek-v4-pro");
81 assert_eq!(wire.as_str(), "deepseek-ai/DeepSeek-V4-Pro");
82 }
83
84 #[test]
85 fn logical_model_ref_auto_is_sentinel() {
86 assert!(LogicalModelRef::from("auto").is_auto());
87 assert!(!LogicalModelRef::from("deepseek-v4-pro").is_auto());
88 }
89
90 #[test]
91 fn logical_model_ref_namespace_hint_parses_curated_prefixes() {
92 let cases = [
93 ("deepseek-ai/DeepSeek-V4-Pro", NamespaceHint::DeepseekAi),
94 ("deepseek/deepseek-v4-pro", NamespaceHint::Deepseek),
95 ("anthropic/claude-foo", NamespaceHint::Anthropic),
96 ("openai/gpt-foo", NamespaceHint::Openai),
97 ("qwen/qwen-foo", NamespaceHint::Qwen),
98 ];
99 for (raw, expected) in cases {
100 assert_eq!(
101 LogicalModelRef::from(raw).namespace_hint(),
102 Some(expected),
103 "{raw} should parse to {expected:?}"
104 );
105 }
106 assert_eq!(LogicalModelRef::from("plain-model").namespace_hint(), None);
107 assert_eq!(LogicalModelRef::from("auto").namespace_hint(), None);
108 }
109
110 /// By construction there is NO path from a namespace prefix to a provider.
111 ///
112 /// This is enforced by the *absence* of any `From<NamespaceHint>` /
113 /// `From<LogicalModelRef>` for `ProviderId`. The following lines are the
114 /// canonical way to mint a `ProviderId` and demonstrate the only supported
115 /// source is an explicit `ProviderKind`, never a parsed prefix.
116 #[test]
117 fn no_namespace_hint_or_logical_ref_to_provider_id_conversion() {
118 let hint = LogicalModelRef::from("deepseek-ai/DeepSeek-V4-Pro").namespace_hint();
119 assert_eq!(hint, Some(NamespaceHint::DeepseekAi));
120 // A ProviderId may ONLY be built from an explicit ProviderKind or string,
121 // never derived from the hint above. (If a `From<NamespaceHint>` for
122 // `ProviderId` were ever added, this seam would silently break #2608.)
123 let provider = ProviderId::from_kind(ProviderKind::Together);
124 assert_eq!(provider.as_str(), "together");
125 }
126
127 #[test]
128 fn newtypes_serialize_transparently() {
129 let id = ProviderId::from("deepseek");
130 assert_eq!(serde_json::to_string(&id).unwrap(), "\"deepseek\"");
131 let wire = WireModelId::from("deepseek-ai/DeepSeek-V4-Pro");
132 assert_eq!(
133 serde_json::to_string(&wire).unwrap(),
134 "\"deepseek-ai/DeepSeek-V4-Pro\""
135 );
136 }
137
138 #[test]
139 fn resolver_reports_auth_as_unresolved_until_preflight() {
140 let candidate = RouteResolver::new()
141 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
142 .expect("route resolves");
143
144 assert_eq!(candidate.auth(), &ResolvedAuthSource::Unresolved);
145 assert_eq!(
146 serde_json::to_value(&candidate).expect("candidate serializes")["auth"],
147 "unresolved"
148 );
149 }
150
151 #[test]
152 fn resolver_applies_every_limit_override_field_before_construction() {
153 let mut request = req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro"));
154 request.limit_overrides = vec![
155 SourcedLimitOverride {
156 field: LimitField::ContextTokens,
157 value: Some(111),
158 source: OverrideSource::UserContextWindow,
159 },
160 SourcedLimitOverride {
161 field: LimitField::InputTokens,
162 value: Some(222),
163 source: OverrideSource::CodexPublicApiLimitStrip,
164 },
165 SourcedLimitOverride {
166 field: LimitField::OutputTokens,
167 value: Some(333),
168 source: OverrideSource::CodexRosterCorrection,
169 },
170 ];
171
172 let candidate = RouteResolver::new()
173 .resolve(&request)
174 .expect("route with overrides resolves");
175
176 assert_eq!(candidate.limits().context_tokens, Some(111));
177 assert_eq!(candidate.limits().input_tokens, Some(222));
178 assert_eq!(candidate.limits().output_tokens, Some(333));
179 }
180
181 #[test]
182 fn resolver_applies_repeated_limit_overrides_in_request_order() {
183 let mut request = req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro"));
184 request.limit_overrides = vec![
185 SourcedLimitOverride {
186 field: LimitField::ContextTokens,
187 value: Some(111),
188 source: OverrideSource::CodexRosterCorrection,
189 },
190 SourcedLimitOverride {
191 field: LimitField::ContextTokens,
192 value: None,
193 source: OverrideSource::CodexPublicApiLimitStrip,
194 },
195 SourcedLimitOverride {
196 field: LimitField::ContextTokens,
197 value: Some(333),
198 source: OverrideSource::UserContextWindow,
199 },
200 ];
201
202 let candidate = RouteResolver::new()
203 .resolve(&request)
204 .expect("route with ordered overrides resolves");
205
206 assert_eq!(candidate.limits().context_tokens, Some(333));
207 }
208
209 #[test]
210 fn resolver_records_exact_limit_override_provenance() {
211 let overrides = vec![
212 SourcedLimitOverride {
213 field: LimitField::InputTokens,
214 value: None,
215 source: OverrideSource::CodexPublicApiLimitStrip,
216 },
217 SourcedLimitOverride {
218 field: LimitField::ContextTokens,
219 value: Some(128_000),
220 source: OverrideSource::CodexRosterCorrection,
221 },
222 ];
223 let mut request = req(Some(ProviderKind::OpenaiCodex), None);
224 request.limit_overrides = overrides.clone();
225
226 let candidate = RouteResolver::new()
227 .resolve(&request)
228 .expect("Codex route with sourced overrides resolves");
229
230 assert_eq!(candidate.applied_limit_overrides(), overrides.as_slice());
231 }
232
233 #[test]
234 fn resolved_auth_source_existing_variants_keep_wire_compatibility() {
235 let cases = [
236 (ResolvedAuthSource::Cli, "cli"),
237 (ResolvedAuthSource::ConfigFile, "config_file"),
238 (ResolvedAuthSource::Keyring, "keyring"),
239 (ResolvedAuthSource::Env, "env"),
240 (ResolvedAuthSource::Command, "command"),
241 (ResolvedAuthSource::Secret, "secret"),
242 (ResolvedAuthSource::Missing, "missing"),
243 ];
244
245 for (source, wire) in cases {
246 let encoded = serde_json::to_string(&source).expect("auth source serializes");
247 assert_eq!(encoded, format!("\"{wire}\""));
248 let decoded: ResolvedAuthSource =
249 serde_json::from_str(&encoded).expect("auth source deserializes");
250 assert_eq!(decoded, source);
251 }
252 }
253
254 #[test]
255 fn descriptor_for_every_kind_has_nonempty_transport_facts() {
256 for kind in ProviderKind::ALL {
257 let d = ProviderDescriptor::for_kind(kind);
258 assert!(!d.id().as_str().is_empty(), "{kind:?} id empty");
259 assert!(
260 !d.default_base_url().is_empty(),
261 "{kind:?} default_base_url empty"
262 );
263 assert!(
264 !d.default_wire_model().as_str().is_empty(),
265 "{kind:?} default_wire_model empty"
266 );
267 if d.wire_policy().fixed().is_some() {
268 let _: RequestProtocol = d
269 .protocol_for_endpoint("chat")
270 .expect("fixed provider protocol");
271 }
272 }
273 }
274
275 #[test]
276 fn descriptor_protocol_matches_provider_wire() {
277 for kind in ProviderKind::ALL {
278 let d = ProviderDescriptor::for_kind(kind);
279 if matches!(kind, ProviderKind::Deepseek | ProviderKind::OpencodeZen) {
280 assert_eq!(d.wire_policy(), crate::provider::WirePolicy::ModelAware);
281 assert_eq!(
282 d.protocol_for_endpoint("chat"),
283 Some(RequestProtocol::ChatCompletions)
284 );
285 assert_eq!(
286 d.protocol_for_endpoint("responses"),
287 Some(RequestProtocol::Responses)
288 );
289 if kind == ProviderKind::Deepseek {
290 assert_eq!(
291 d.protocol_for_endpoint("messages"),
292 Some(RequestProtocol::AnthropicMessages)
293 );
294 assert_eq!(d.protocol_for_endpoint("models/gemini"), None);
295 continue;
296 }
297 assert_eq!(
298 d.protocol_for_endpoint("messages"),
299 Some(RequestProtocol::AnthropicMessages)
300 );
301 assert_eq!(d.protocol_for_endpoint("models/gemini"), None);
302 continue;
303 }
304 assert_eq!(
305 d.protocol_for_endpoint("chat"),
306 kind.provider().wire_policy().fixed(),
307 "{kind:?} protocol must equal the provider wire policy"
308 );
309 let expected = match kind {
310 ProviderKind::OpenaiCodex => Some(RequestProtocol::Responses),
311 ProviderKind::DeepseekAnthropic
312 | ProviderKind::Anthropic
313 | ProviderKind::MinimaxAnthropic
314 | ProviderKind::Openmodel
315 | ProviderKind::ModelstudioTokenPlanAnthropic
316 | ProviderKind::ModelstudioCodingPlanAnthropic => {
317 Some(RequestProtocol::AnthropicMessages)
318 }
319 _ => Some(RequestProtocol::ChatCompletions),
320 };
321 assert_eq!(
322 d.protocol_for_endpoint("chat"),
323 expected,
324 "{kind:?} protocol mismatch"
325 );
326 }
327 }
328
329 #[test]
330 fn resolver_explicit_provider_scoped_model_maps_to_wire_id() {
331 let r = RouteResolver::new();
332 let out = r
333 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
334 .expect("should resolve");
335 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
336 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
337 assert_eq!(
338 out.canonical_model().map(ModelId::as_str),
339 Some("deepseek-v4-pro")
340 );
341 }
342
343 #[test]
344 fn resolver_routes_only_official_deepseek_flash_over_responses() {
345 let resolver = RouteResolver::new();
346
347 let flash = resolver
348 .resolve(&req(
349 Some(ProviderKind::Deepseek),
350 Some("deepseek-v4-flash"),
351 ))
352 .expect("official Flash route resolves");
353 assert_eq!(flash.protocol(), RequestProtocol::Responses);
354 assert_eq!(flash.endpoint().endpoint_key, "responses");
355
356 let pro = resolver
357 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
358 .expect("official Pro route resolves");
359 assert_eq!(pro.protocol(), RequestProtocol::ChatCompletions);
360 assert_eq!(pro.endpoint().endpoint_key, "chat");
361
362 let future = resolver
363 .resolve(&req(
364 Some(ProviderKind::Deepseek),
365 Some("deepseek-v5-future"),
366 ))
367 .expect("unknown future model preserves direct-provider pass-through");
368 assert_eq!(future.protocol(), RequestProtocol::Responses);
369 assert_eq!(future.endpoint().endpoint_key, "responses");
370
371 let legacy_unknown = resolver
372 .resolve(&req(
373 Some(ProviderKind::Deepseek),
374 Some("deepseek-coder-future"),
375 ))
376 .expect("unknown unversioned model preserves historical Chat pass-through");
377 assert_eq!(legacy_unknown.protocol(), RequestProtocol::ChatCompletions);
378 assert_eq!(legacy_unknown.endpoint().endpoint_key, "chat");
379
380 let custom = resolver
381 .resolve(&RouteRequest {
382 explicit_provider: Some(ProviderKind::Deepseek),
383 model_selector: Some(LogicalModelRef::from("deepseek-v4-flash")),
384 saved_provider_model: None,
385 base_url_override: Some("https://compatible.example/v1".to_string()),
386 limit_overrides: Vec::new(),
387 })
388 .expect("custom compatible Flash route remains pass-through");
389 assert_eq!(custom.protocol(), RequestProtocol::ChatCompletions);
390 assert_eq!(custom.endpoint().endpoint_key, "chat");
391 }
392
393 #[test]
394 fn resolver_aggregator_preserves_prefixed_wire_id_without_inferring_deepseek() {
395 let r = RouteResolver::new();
396 let out = r
397 .resolve(&req(
398 Some(ProviderKind::Together),
399 Some("deepseek-ai/DeepSeek-V4-Pro"),
400 ))
401 .expect("aggregator should resolve");
402 // Provider stays Together, NOT Deepseek, despite the deepseek-ai/ prefix.
403 assert_eq!(out.provider_kind(), ProviderKind::Together);
404 assert_ne!(out.provider_kind(), ProviderKind::Deepseek);
405 // Wire id preserved verbatim.
406 assert_eq!(out.wire_model_id().as_str(), "deepseek-ai/DeepSeek-V4-Pro");
407 }
408
409 #[test]
410 fn resolver_openrouter_keeps_provider_for_every_namespace_prefix() {
411 let r = RouteResolver::new();
412 let prefixes = [
413 "deepseek-ai/DeepSeek-V4-Pro",
414 "deepseek/deepseek-v4-pro",
415 "anthropic/claude-foo",
416 "openai/gpt-foo",
417 "qwen/qwen-foo",
418 ];
419 for raw in prefixes {
420 let selector = LogicalModelRef::from(raw);
421 // The selector DOES carry a namespace hint...
422 assert!(
423 selector.namespace_hint().is_some(),
424 "{raw} should have a namespace hint"
425 );
426 let out = r
427 .resolve(&req(Some(ProviderKind::Openrouter), Some(raw)))
428 .unwrap_or_else(|e| panic!("{raw} should resolve on openrouter: {e}"));
429 // ...but the provider stays Openrouter regardless.
430 assert_eq!(
431 out.provider_kind(),
432 ProviderKind::Openrouter,
433 "{raw} must not change provider"
434 );
435 assert_eq!(out.wire_model_id().as_str(), raw, "{raw} wire id verbatim");
436 }
437 }
438
439 #[test]
440 fn resolver_no_explicit_provider_does_not_infer_deepseek_from_prefix() {
441 let r = RouteResolver::new();
442 // explicit_provider=None => default scope (Deepseek). A prefixed selector
443 // is foreign for the strict-direct default, so it ERRORS rather than being
444 // silently accepted as a deepseek model: the prefix never *selects* it.
445 let out = r.resolve(&req(None, Some("deepseek/deepseek-v4-pro")));
446 match out {
447 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
448 assert_eq!(provider.as_str(), "deepseek");
449 assert_eq!(model, "deepseek/deepseek-v4-pro");
450 }
451 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
452 }
453 }
454
455 #[test]
456 fn resolver_auto_is_sentinel_not_literal_model() {
457 let r = RouteResolver::new();
458 let out = r
459 .resolve(&req(Some(ProviderKind::Deepseek), Some("auto")))
460 .expect("auto should resolve");
461 // The logical selector is the auto sentinel...
462 assert!(out.logical_model().is_auto());
463 // ...and "auto" is NOT put on the wire as a literal model.
464 assert_ne!(out.wire_model_id().as_str(), "auto");
465 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
466 }
467
468 #[test]
469 fn resolver_can_use_models_dev_offering_for_provider_scoped_route() {
470 let r = models_dev_route_resolver();
471 let out = r
472 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
473 .expect("Models.dev-backed Z.ai route should resolve");
474
475 assert_eq!(out.provider_kind(), ProviderKind::Zai);
476 assert_eq!(out.provider_id().as_str(), "zai");
477 assert_eq!(out.wire_model_id().as_str(), "glm-5.2");
478 assert_eq!(
479 out.canonical_model().map(ModelId::as_str),
480 Some("zhipuai/glm-5.2")
481 );
482 }
483
484 #[test]
485 fn resolver_auto_uses_models_dev_default_offering_when_available() {
486 let r = models_dev_route_resolver();
487 let out = r
488 .resolve(&req(Some(ProviderKind::Zai), Some("auto")))
489 .expect("auto should resolve through catalog default");
490
491 assert!(out.logical_model().is_auto());
492 assert_eq!(
493 out.wire_model_id().as_str(),
494 "glm-5.2",
495 "catalog default should win over the built-in Z.ai spelling"
496 );
497 assert_eq!(
498 out.canonical_model().map(ModelId::as_str),
499 Some("zhipuai/glm-5.2")
500 );
501 }
502
503 #[test]
504 fn resolver_auto_falls_back_to_descriptor_default_without_catalog_default() {
505 // Z.ai offerings exist in the catalog snapshot but none is marked
506 // `default: true`. `auto` must then fall back to the provider descriptor's
507 // built-in default wire model rather than picking an arbitrary catalog row.
508 let raw = r#"{
509 "providers": {
510 "zai": {
511 "models": {
512 "glm-5-turbo": {
513 "id": "glm-5-turbo",
514 "modalities": { "input": ["text"], "output": ["text"] }
515 }
516 }
517 }
518 }
519 }"#;
520 let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
521 let offerings = catalog
522 .provider_offerings("zai")
523 .expect("zai provider offerings");
524 let r = RouteResolver::from_offerings(offerings);
525
526 let out = r
527 .resolve(&req(Some(ProviderKind::Zai), Some("auto")))
528 .expect("auto should resolve to the descriptor default");
529
530 assert!(out.logical_model().is_auto());
531 assert_eq!(
532 out.wire_model_id().as_str(),
533 "GLM-5.2",
534 "no catalog default → descriptor built-in default wins"
535 );
536 assert_eq!(
537 out.canonical_model(),
538 None,
539 "descriptor fallback carries no catalog canonical link"
540 );
541 }
542
543 #[test]
544 fn resolver_models_dev_prefixed_wire_id_stays_inside_provider_scope() {
545 let r = models_dev_route_resolver();
546 let out = r
547 .resolve(&req(Some(ProviderKind::Openrouter), Some("z-ai/glm-5.2")))
548 .expect("OpenRouter Models.dev row should resolve");
549
550 assert_eq!(out.provider_kind(), ProviderKind::Openrouter);
551 assert_ne!(out.provider_kind(), ProviderKind::Zai);
552 assert_eq!(out.wire_model_id().as_str(), "z-ai/glm-5.2");
553 assert_eq!(
554 out.canonical_model().map(ModelId::as_str),
555 Some("zhipuai/glm-5.2")
556 );
557 }
558
559 #[test]
560 fn resolver_carries_models_dev_limits_into_ready_candidate() {
561 let r = models_dev_route_resolver();
562 let out = r
563 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
564 .expect("Z.AI Models.dev row should resolve");
565
566 assert_eq!(out.limits().context_tokens, Some(1_000_000));
567 assert_eq!(out.limits().input_tokens, Some(900_000));
568 assert_eq!(out.limits().output_tokens, Some(131_072));
569 assert!(out.limits().has_known_limit());
570 }
571
572 #[test]
573 fn minimax_anthropic_routes_use_catalog_limits_and_messages_protocol() {
574 let resolver = RouteResolver::new();
575
576 for (model, context) in [("MiniMax-M3", 1_000_000), ("MiniMax-M2.7", 204_800)] {
577 let route = resolver
578 .resolve(&req(Some(ProviderKind::MinimaxAnthropic), Some(model)))
579 .expect("MiniMax Messages route should resolve");
580
581 assert_eq!(route.provider_kind(), ProviderKind::MinimaxAnthropic);
582 assert_eq!(route.wire_model_id().as_str(), model);
583 assert_eq!(route.protocol(), RequestProtocol::AnthropicMessages);
584 assert_eq!(
585 route.endpoint().protocol,
586 RequestProtocol::AnthropicMessages
587 );
588 assert_eq!(
589 route.endpoint().base_url,
590 "https://api.minimax.io/anthropic"
591 );
592 assert_eq!(route.limits().context_tokens, Some(context));
593 }
594 }
595
596 #[test]
597 fn resolver_keeps_limits_provider_scoped_for_same_canonical_model() {
598 let r = models_dev_route_resolver();
599 let direct = r
600 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
601 .expect("direct Z.AI route should resolve");
602 let hosted = r
603 .resolve(&req(Some(ProviderKind::Openrouter), Some("z-ai/glm-5.2")))
604 .expect("hosted OpenRouter route should resolve");
605
606 assert_eq!(
607 direct.canonical_model().map(ModelId::as_str),
608 hosted.canonical_model().map(ModelId::as_str)
609 );
610 assert_eq!(direct.limits().context_tokens, Some(1_000_000));
611 assert_eq!(hosted.limits().context_tokens, Some(128_000));
612 assert_eq!(hosted.limits().output_tokens, Some(32_768));
613 }
614
615 #[test]
616 fn resolver_strict_direct_rejects_clearly_foreign_selector() {
617 let r = RouteResolver::new();
618 let out = r.resolve(&req(Some(ProviderKind::Zai), Some("anthropic/claude-foo")));
619 match out {
620 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
621 assert_eq!(provider.as_str(), "zai");
622 assert_eq!(model, "anthropic/claude-foo");
623 }
624 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
625 }
626 }
627
628 #[test]
629 fn resolver_strict_direct_rejects_other_provider_known_bare_offering() {
630 let r = RouteResolver::new();
631 let out = r.resolve(&req(Some(ProviderKind::Zai), Some("deepseek-v4-pro")));
632 match out {
633 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
634 assert_eq!(provider.as_str(), "zai");
635 assert_eq!(model, "deepseek-v4-pro");
636 }
637 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
638 }
639 }
640
641 #[test]
642 fn resolver_custom_endpoint_allows_namespaced_selector_for_strict_provider() {
643 let r = RouteResolver::new();
644 let request = RouteRequest {
645 explicit_provider: Some(ProviderKind::Deepseek),
646 model_selector: Some(LogicalModelRef::from("vendor/custom-coder")),
647 saved_provider_model: None,
648 base_url_override: Some("https://example.local/v1".to_string()),
649 limit_overrides: Vec::new(),
650 };
651 let out = r
652 .resolve(&request)
653 .expect("custom endpoint should defer model validation upstream");
654 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
655 assert_eq!(out.wire_model_id().as_str(), "vendor/custom-coder");
656 assert_eq!(out.endpoint().base_url, "https://example.local/v1");
657 }
658
659 #[test]
660 fn resolver_treats_every_official_deepseek_endpoint_as_strict_direct() {
661 let resolver = RouteResolver::new();
662 for base_url in [
663 "https://api.deepseek.com",
664 "https://api.deepseek.com/v1/",
665 "https://api.deepseek.com/beta",
666 ] {
667 let request = RouteRequest {
668 explicit_provider: Some(ProviderKind::Deepseek),
669 model_selector: Some(LogicalModelRef::from("anthropic/claude-foo")),
670 saved_provider_model: None,
671 base_url_override: Some(base_url.to_string()),
672 limit_overrides: Vec::new(),
673 };
674 assert!(
675 matches!(
676 resolver.resolve(&request),
677 Err(RouteError::ForeignModelForDirectProvider { .. })
678 ),
679 "official endpoint {base_url} must retain DeepSeek's strict namespace"
680 );
681 }
682 }
683
684 #[test]
685 fn resolver_does_not_trust_deepseek_hostname_substrings() {
686 let resolver = RouteResolver::new();
687 let request = RouteRequest {
688 explicit_provider: Some(ProviderKind::Deepseek),
689 model_selector: Some(LogicalModelRef::from("vendor/custom-coder")),
690 saved_provider_model: None,
691 base_url_override: Some("https://api.deepseek.com.evil.example/v1".to_string()),
692 limit_overrides: Vec::new(),
693 };
694 let route = resolver
695 .resolve(&request)
696 .expect("lookalike host must be treated as a custom endpoint");
697 assert_eq!(route.wire_model_id().as_str(), "vendor/custom-coder");
698 }
699
700 #[test]
701 fn resolver_explicit_custom_with_base_url_override_passes_model_through_verbatim() {
702 // #1519: an explicit `Custom` provider with a base_url override resolves via
703 // the LocalOrCustom pass-through, preserving even a namespaced selector as
704 // the verbatim wire id and binding the override endpoint + Chat Completions.
705 let r = RouteResolver::new();
706 let request = RouteRequest {
707 explicit_provider: Some(ProviderKind::Custom),
708 model_selector: Some(LogicalModelRef::from("vendor/custom-model-v1")),
709 saved_provider_model: None,
710 base_url_override: Some("https://api.example.com/v1".to_string()),
711 limit_overrides: Vec::new(),
712 };
713 let out = r
714 .resolve(&request)
715 .expect("custom provider should resolve via pass-through");
716 assert_eq!(out.provider_kind(), ProviderKind::Custom);
717 assert_eq!(out.provider_id().as_str(), "custom");
718 assert_eq!(out.wire_model_id().as_str(), "vendor/custom-model-v1");
719 assert_eq!(out.endpoint().base_url, "https://api.example.com/v1");
720 assert_eq!(
721 out.protocol(),
722 crate::route::RequestProtocol::ChatCompletions
723 );
724 assert!(out.validation().ok);
725 assert!(out.validation().messages.is_empty());
726 }
727
728 #[test]
729 fn resolver_strict_direct_rejects_models_dev_offering_from_another_provider() {
730 let r = models_dev_route_resolver();
731 let out = r.resolve(&req(Some(ProviderKind::Deepseek), Some("glm-5.2")));
732 match out {
733 Err(RouteError::ForeignModelForDirectProvider { provider, model }) => {
734 assert_eq!(provider.as_str(), "deepseek");
735 assert_eq!(model, "glm-5.2");
736 }
737 other => panic!("expected ForeignModelForDirectProvider, got {other:?}"),
738 }
739 }
740
741 // ---------------------------------------------------------------------------
742 // #3385: the DEFAULT resolver now sources the bundled Models.dev catalog asset,
743 // so real provider/model facts (context windows) reach candidates.
744 // ---------------------------------------------------------------------------
745
746 #[test]
747 fn default_resolver_yields_real_facts_from_bundled_catalog() {
748 let r = RouteResolver::new();
749
750 // A GLM row (Z.ai) resolves to a real, non-default context window — proof
751 // the bundled asset feeds the default resolver rather than the old 4-row
752 // seam, which only knew deepseek/together/openrouter and left everything
753 // else at `RouteLimits::default()` (unknown).
754 let glm = r
755 .resolve(&req(Some(ProviderKind::Zai), Some("GLM-5.2")))
756 .expect("Z.ai GLM-5.2 should resolve from the bundled catalog");
757 assert_eq!(glm.provider_kind(), ProviderKind::Zai);
758 assert_eq!(glm.wire_model_id().as_str(), "GLM-5.2");
759 assert_eq!(
760 glm.limits().context_tokens,
761 Some(1_000_000),
762 "GLM-5.2 must carry its real context window, not the unknown default"
763 );
764 assert_eq!(glm.limits().output_tokens, Some(131_072));
765 assert!(glm.limits().has_known_limit());
766
767 // A Kimi row (Moonshot) likewise resolves with its real window — a model
768 // the 4-row seam never knew about at all.
769 let kimi_k27 = r
770 .resolve(&req(Some(ProviderKind::Moonshot), Some("kimi-k2.7-code")))
771 .expect("Moonshot kimi-k2.7-code should resolve from the bundled catalog");
772 assert_eq!(kimi_k27.limits().context_tokens, Some(262_144));
773 assert_eq!(kimi_k27.limits().output_tokens, Some(262_144));
774
775 let kimi_k3 = r
776 .resolve(&req(Some(ProviderKind::Moonshot), Some("kimi-k3")))
777 .expect("Moonshot kimi-k3 should resolve from the bundled catalog");
778 assert_eq!(kimi_k3.limits().context_tokens, Some(1_048_576));
779 assert_eq!(kimi_k3.limits().output_tokens, Some(131_072));
780
781 // With the #3085 pricing keystone present on the release branch, the asset's
782 // provider-scoped `cost` now projects onto the candidate via
783 // `route_pricing_sku`, so a priced Z.ai row carries a real per-token meter
784 // rather than `UnknownOrStale` — the "lighting up" that #3385 + #3085 deliver
785 // together.
786 let glm51 = r
787 .resolve(&req(Some(ProviderKind::Zai), Some("glm-5.1")))
788 .expect("Z.ai glm-5.1 should resolve from the bundled catalog");
789 assert_eq!(glm51.limits().context_tokens, Some(202_752));
790 assert!(matches!(
791 glm51.pricing(),
792 Some(super::candidate::PricingSku::Token { .. })
793 ));
794 }
795
796 #[test]
797 fn default_resolver_preserves_seam_canonical_joins() {
798 // The bundled asset is merged UNDER the hand seam, so the seam's curated
799 // canonical-model joins still win: a DeepSeek-native selector keeps its
800 // canonical id, and an aggregator-prefixed wire id still maps back to the
801 // canonical DeepSeek model. (This is what keeps the existing route
802 // invariants green after the asset was wired in.)
803 let r = RouteResolver::new();
804
805 let direct = r
806 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
807 .expect("deepseek-v4-pro resolves");
808 assert_eq!(
809 direct.canonical_model().map(ModelId::as_str),
810 Some("deepseek-v4-pro")
811 );
812
813 let hosted = r
814 .resolve(&req(
815 Some(ProviderKind::Together),
816 Some("deepseek-ai/DeepSeek-V4-Pro"),
817 ))
818 .expect("together hosted deepseek resolves");
819 assert_eq!(
820 hosted.canonical_model().map(ModelId::as_str),
821 Some("deepseek-v4-pro"),
822 "seam canonical join must survive the asset merge"
823 );
824 assert_eq!(
825 hosted.wire_model_id().as_str(),
826 "deepseek-ai/DeepSeek-V4-Pro"
827 );
828 }
829
830 #[test]
831 fn together_inkling_aliases_use_the_exact_wire_identity_without_invented_metadata() {
832 let resolver = RouteResolver::new();
833
834 for requested in ["inkling", "together-inkling", "thinkingmachines/inkling"] {
835 let route = resolver
836 .resolve(&req(Some(ProviderKind::Together), Some(requested)))
837 .expect("Together Inkling route should resolve");
838 assert_eq!(route.provider_kind(), ProviderKind::Together, "{requested}");
839 assert_eq!(
840 route.wire_model_id().as_str(),
841 "thinkingmachines/inkling",
842 "{requested}"
843 );
844 assert!(route.canonical_model().is_none(), "{requested}");
845 assert!(!route.limits().has_known_limit(), "{requested}");
846 assert!(matches!(
847 route.pricing(),
848 Some(super::candidate::PricingSku::UnknownOrStale)
849 ));
850 }
851 }
852
853 #[test]
854 fn together_custom_endpoint_preserves_its_explicit_model_id() {
855 let resolver = RouteResolver::new();
856 let route = resolver
857 .resolve(&RouteRequest {
858 explicit_provider: Some(ProviderKind::Together),
859 model_selector: Some(LogicalModelRef::from("inkling")),
860 saved_provider_model: None,
861 base_url_override: Some("http://127.0.0.1:8000/v1".to_string()),
862 limit_overrides: Vec::new(),
863 })
864 .expect("custom Together-compatible endpoint should resolve");
865
866 assert_eq!(route.wire_model_id().as_str(), "inkling");
867 }
868
869 #[test]
870 fn openrouter_qwen37_plus_aliases_use_exact_catalog_wire_identity() {
871 let resolver = RouteResolver::new();
872
873 for requested in ["qwen3.7-plus", "qwen-3.7-plus", "qwen/qwen3.7-plus"] {
874 let route = resolver
875 .resolve(&req(Some(ProviderKind::Openrouter), Some(requested)))
876 .expect("OpenRouter Qwen 3.7 Plus route should resolve");
877 assert_eq!(route.wire_model_id().as_str(), "qwen/qwen3.7-plus");
878 assert!(!route.limits().has_known_limit());
879 assert!(matches!(
880 route.pricing(),
881 Some(super::candidate::PricingSku::Token {
882 input_per_mtok: Some(_),
883 output_per_mtok: Some(_)
884 })
885 ));
886 }
887 }
888
889 #[test]
890 fn openrouter_custom_endpoint_preserves_qwen37_alias() {
891 let route = RouteResolver::new()
892 .resolve(&RouteRequest {
893 explicit_provider: Some(ProviderKind::Openrouter),
894 model_selector: Some(LogicalModelRef::from("qwen3.7-plus")),
895 saved_provider_model: None,
896 base_url_override: Some("https://gateway.example.test/v1".to_string()),
897 limit_overrides: Vec::new(),
898 })
899 .expect("custom OpenRouter-compatible endpoint should resolve");
900
901 assert_eq!(route.wire_model_id().as_str(), "qwen3.7-plus");
902 }
903
904 #[test]
905 fn opencode_go_resolver_accepts_only_chat_completions_models() {
906 let resolver = RouteResolver::new();
907 let chat_models = crate::OPENCODE_GO_CHAT_MODELS;
908 assert!(chat_models.contains(&"grok-4.5"));
909 assert!(chat_models.contains(&"kimi-k3"));
910
911 for &model in chat_models {
912 for requested in [model.to_string(), format!("opencode-go/{model}")] {
913 let route = resolver
914 .resolve(&req(Some(ProviderKind::OpencodeGo), Some(&requested)))
915 .unwrap_or_else(|error| panic!("{requested} should resolve: {error}"));
916 assert_eq!(
917 route.provider_kind(),
918 ProviderKind::OpencodeGo,
919 "{requested}"
920 );
921 assert_eq!(route.wire_model_id().as_str(), model, "{requested}");
922 }
923 }
924
925 let automatic = resolver
926 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("auto")))
927 .expect("OpenCode Go auto should resolve to its Chat default");
928 assert_eq!(automatic.wire_model_id().as_str(), "deepseek-v4-pro");
929 }
930
931 #[test]
932 fn opencode_go_resolver_rejects_messages_models_even_on_custom_base_urls() {
933 let resolver = RouteResolver::new();
934 let messages_models = [
935 "minimax-m3",
936 "minimax-m2.7",
937 "minimax-m2.5",
938 "qwen3.7-max",
939 "qwen3.7-plus",
940 "qwen3.6-plus",
941 ];
942
943 for model in messages_models {
944 for requested in [model.to_string(), format!("opencode-go/{model}")] {
945 for base_url_override in [None, Some("https://go-gateway.example.test/v1".to_string())]
946 {
947 let request = RouteRequest {
948 explicit_provider: Some(ProviderKind::OpencodeGo),
949 model_selector: Some(LogicalModelRef::from(requested.as_str())),
950 saved_provider_model: None,
951 base_url_override,
952 limit_overrides: Vec::new(),
953 };
954 assert!(
955 matches!(
956 resolver.resolve(&request),
957 Err(RouteError::ForeignModelForDirectProvider { .. })
958 ),
959 "{requested} must not reach OpenCode Go Chat Completions"
960 );
961 }
962 }
963 }
964 }
965
966 #[test]
967 fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() {
968 use super::offering::{
969 OPENCODE_ZEN_CHAT_MODELS, OPENCODE_ZEN_MESSAGES_MODELS, OPENCODE_ZEN_RESPONSES_MODELS,
970 };
971
972 let resolver = RouteResolver::new();
973 let groups = [
974 (
975 OPENCODE_ZEN_RESPONSES_MODELS,
976 "responses",
977 RequestProtocol::Responses,
978 ),
979 (
980 OPENCODE_ZEN_MESSAGES_MODELS,
981 "messages",
982 RequestProtocol::AnthropicMessages,
983 ),
984 (
985 OPENCODE_ZEN_CHAT_MODELS,
986 "chat",
987 RequestProtocol::ChatCompletions,
988 ),
989 ];
990
991 for (models, endpoint_key, protocol) in groups {
992 for model in models {
993 for requested in [model.to_string(), format!("opencode/{model}")] {
994 let route = resolver
995 .resolve(&req(Some(ProviderKind::OpencodeZen), Some(&requested)))
996 .unwrap_or_else(|error| panic!("{requested} should resolve: {error}"));
997 assert_eq!(route.provider_kind(), ProviderKind::OpencodeZen);
998 assert_eq!(route.wire_model_id().as_str(), *model, "{requested}");
999 assert_eq!(route.endpoint().endpoint_key, endpoint_key, "{requested}");
1000 assert_eq!(route.protocol(), protocol, "{requested}");
1001 }
1002 }
1003 }
1004
1005 let automatic = resolver
1006 .resolve(&req(Some(ProviderKind::OpencodeZen), Some("auto")))
1007 .expect("OpenCode Zen auto should resolve to its documented default");
1008 assert_eq!(automatic.wire_model_id().as_str(), "gpt-5.6-sol");
1009 assert_eq!(automatic.protocol(), RequestProtocol::Responses);
1010
1011 let logical_gpt_5_6 = resolver
1012 .resolve(&req(Some(ProviderKind::OpencodeZen), Some("gpt-5.6")))
1013 .expect("the logical GPT-5.6 picker row resolves to Zen's proven Sol wire route");
1014 assert_eq!(logical_gpt_5_6.wire_model_id().as_str(), "gpt-5.6-sol");
1015 assert_eq!(logical_gpt_5_6.protocol(), RequestProtocol::Responses);
1016 }
1017
1018 #[test]
1019 fn opencode_zen_resolver_fails_closed_for_unproven_protocols() {
1020 let resolver = RouteResolver::new();
1021
1022 for model in [
1023 "gemini-3.1-pro",
1024 "opencode/gemini-3.5-flash",
1025 "unknown-model",
1026 ] {
1027 for base_url_override in [
1028 None,
1029 Some("https://zen-gateway.example.test/v1".to_string()),
1030 ] {
1031 let request = RouteRequest {
1032 explicit_provider: Some(ProviderKind::OpencodeZen),
1033 model_selector: Some(LogicalModelRef::from(model)),
1034 saved_provider_model: None,
1035 base_url_override,
1036 limit_overrides: Vec::new(),
1037 };
1038 assert!(
1039 matches!(
1040 resolver.resolve(&request),
1041 Err(RouteError::UnsupportedModelProtocol { .. })
1042 ),
1043 "{model} must fail closed without a supported protocol mapping"
1044 );
1045 }
1046 }
1047 }
1048
1049 #[test]
1050 fn resolver_deepseek_none_selector_uses_default_wire_id() {
1051 let r = RouteResolver::new();
1052 let out = r
1053 .resolve(&req(Some(ProviderKind::Deepseek), None))
1054 .expect("none selector should use provider default");
1055 assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
1056 assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
1057 }
1058
1059 #[test]
1060 fn resolver_empty_string_selector_is_empty_model_error() {
1061 let r = RouteResolver::new();
1062 let out = r.resolve(&req(Some(ProviderKind::Deepseek), Some("")));
1063 assert!(matches!(out, Err(RouteError::EmptyModel)));
1064 }
1065
1066 #[test]
1067 fn resolver_empty_saved_provider_model_is_empty_model_error() {
1068 // An empty selector from the saved-model fallback must be rejected too, not
1069 // just an empty explicit selector (the guard covers every selector source).
1070 let r = RouteResolver::new();
1071 let request = RouteRequest {
1072 explicit_provider: Some(ProviderKind::Deepseek),
1073 model_selector: None,
1074 saved_provider_model: Some(WireModelId::from("")),
1075 base_url_override: None,
1076 limit_overrides: Vec::new(),
1077 };
1078 assert!(matches!(r.resolve(&request), Err(RouteError::EmptyModel)));
1079 }
1080
1081 #[test]
1082 fn resolver_passthrough_provider_preserves_custom_id_verbatim() {
1083 let r = RouteResolver::new();
1084 let out = r
1085 .resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1086 .expect("local passthrough should resolve");
1087 assert_eq!(out.provider_kind(), ProviderKind::Ollama);
1088 assert_eq!(out.wire_model_id().as_str(), "my-local:7b");
1089 assert_eq!(out.limits(), Default::default());
1090 assert!(out.validation().ok);
1091 }
1092
1093 #[test]
1094 fn resolved_candidate_serializes_secret_free() {
1095 let r = RouteResolver::new();
1096 // Cover a direct, an aggregator, and a local/passthrough route.
1097 let candidates = [
1098 r.resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1099 .expect("direct resolves"),
1100 r.resolve(&req(
1101 Some(ProviderKind::Together),
1102 Some("deepseek-ai/DeepSeek-V4-Pro"),
1103 ))
1104 .expect("aggregator resolves"),
1105 r.resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1106 .expect("local resolves"),
1107 ];
1108 for out in candidates {
1109 let json = serde_json::to_string(&out).expect("candidate serializes");
1110 // Carries provider/model/wire/protocol/auth-source class.
1111 assert!(json.contains("provider_id"), "{json}");
1112 assert!(json.contains("provider_kind"), "{json}");
1113 assert!(json.contains("wire_model_id"), "{json}");
1114 assert!(json.contains("protocol"), "{json}");
1115 assert!(json.contains("auth"), "{json}");
1116 // Never any secret/api-key material.
1117 let lower = json.to_lowercase();
1118 assert!(!lower.contains("api_key"), "leaked api_key: {json}");
1119 assert!(!lower.contains("apikey"), "leaked apikey: {json}");
1120 assert!(!lower.contains("secret_id"), "leaked secret_id: {json}");
1121 assert!(!lower.contains("password"), "leaked password: {json}");
1122 assert!(!lower.contains("bearer"), "leaked bearer: {json}");
1123 assert!(
1124 !lower.contains("authorization"),
1125 "leaked authorization: {json}"
1126 );
1127 }
1128 }
1129
1130 #[test]
1131 fn resolver_protocol_matches_descriptor_for_every_provider() {
1132 let r = RouteResolver::new();
1133 for kind in ProviderKind::ALL {
1134 // Use each provider's own default wire id as the selector so strict
1135 // direct providers do not reject; this exercises the resolver across
1136 // the whole provider set.
1137 let default_wire = ProviderDescriptor::for_kind(kind).default_wire_model();
1138 let request = req(Some(kind), Some(default_wire.as_str()));
1139 let out = r
1140 .resolve(&request)
1141 .unwrap_or_else(|e| panic!("{kind:?} should resolve its own default: {e}"));
1142 assert_eq!(
1143 out.protocol(),
1144 ProviderDescriptor::for_kind(kind)
1145 .protocol_for_endpoint(&out.endpoint().endpoint_key)
1146 .expect("resolved endpoint protocol"),
1147 "{kind:?} candidate protocol must match descriptor"
1148 );
1149 assert_eq!(
1150 out.endpoint().protocol,
1151 out.protocol(),
1152 "{kind:?} endpoint protocol"
1153 );
1154 }
1155 }
1156
1157 // ---------------------------------------------------------------------------
1158 // #3085: honest pricing on resolved candidates.
1159 // ---------------------------------------------------------------------------
1160
1161 /// A resolver whose single offering is a DeepSeek-priced catalog row, projected
1162 /// through the wired `CatalogOffering::to_offering` pricing seam.
1163 fn priced_deepseek_resolver() -> RouteResolver {
1164 use crate::catalog::{CatalogOffering, CatalogSource};
1165 use crate::models_dev::ModelsDevCost;
1166
1167 let priced = CatalogOffering {
1168 provider: "deepseek".into(),
1169 wire_model_id: "deepseek-v4-pro".into(),
1170 canonical_model: Some("deepseek-v4-pro".into()),
1171 endpoint_key: "chat".into(),
1172 default_for_provider: true,
1173 cost: Some(ModelsDevCost {
1174 input: Some(0.28),
1175 output: Some(0.42),
1176 cache_read: Some(0.028),
1177 cache_write: None,
1178 }),
1179 source: CatalogSource::Bundled,
1180 ..Default::default()
1181 };
1182 RouteResolver::from_offerings(vec![priced.to_offering()])
1183 }
1184
1185 #[test]
1186 fn resolver_carries_exact_offering_capabilities_without_protocol_inference() {
1187 use crate::catalog::{CatalogOffering, CatalogSource};
1188 use crate::route::CapabilityState;
1189
1190 let offering = CatalogOffering {
1191 provider: "deepseek".into(),
1192 wire_model_id: "deepseek-v4-pro".into(),
1193 canonical_model: Some("deepseek-v4-pro".into()),
1194 endpoint_key: "chat".into(),
1195 default_for_provider: true,
1196 attachment: Some(false),
1197 reasoning: Some(true),
1198 tool_call: Some(false),
1199 structured_output: Some(true),
1200 source: CatalogSource::Bundled,
1201 ..Default::default()
1202 };
1203 let resolver = RouteResolver::from_offerings(vec![offering.to_offering()]);
1204
1205 let candidate = resolver
1206 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1207 .expect("matching offering resolves");
1208 let capabilities = candidate.capabilities();
1209
1210 assert_eq!(capabilities.attachments, CapabilityState::Unsupported);
1211 assert_eq!(capabilities.reasoning, CapabilityState::Supported);
1212 assert_eq!(capabilities.native_tool_calls, CapabilityState::Unsupported);
1213 assert_eq!(capabilities.structured_output, CapabilityState::Supported);
1214 assert_eq!(capabilities.streaming, CapabilityState::Unknown);
1215 assert_eq!(
1216 capabilities.server_side_web_search,
1217 CapabilityState::Unknown
1218 );
1219 }
1220
1221 #[test]
1222 fn provider_native_web_search_requires_exact_direct_endpoint_offering() {
1223 use crate::route::CapabilityState;
1224
1225 let resolver = RouteResolver::new();
1226 let direct = resolver
1227 .resolve(&req(Some(ProviderKind::Xai), Some("grok-4.5")))
1228 .expect("bundled direct xAI offering resolves");
1229 assert_eq!(
1230 direct.capabilities().server_side_web_search,
1231 CapabilityState::Supported
1232 );
1233
1234 let aggregator = resolver
1235 .resolve(&req(Some(ProviderKind::OpencodeGo), Some("grok-4.5")))
1236 .expect("aggregator offering resolves independently");
1237 assert_eq!(
1238 aggregator.capabilities().server_side_web_search,
1239 CapabilityState::Unknown
1240 );
1241
1242 let custom_endpoint = resolver
1243 .resolve(&RouteRequest {
1244 explicit_provider: Some(ProviderKind::Xai),
1245 model_selector: Some(LogicalModelRef::from("grok-4.5")),
1246 saved_provider_model: None,
1247 base_url_override: Some("https://gateway.example.test/v1".to_string()),
1248 limit_overrides: Vec::new(),
1249 })
1250 .expect("custom compatible endpoint resolves");
1251 assert_eq!(
1252 custom_endpoint.capabilities().server_side_web_search,
1253 CapabilityState::Unknown
1254 );
1255 }
1256
1257 #[test]
1258 fn priced_offering_yields_token_pricing_sku() {
1259 use super::candidate::PricingSku;
1260
1261 let r = priced_deepseek_resolver();
1262 let out = r
1263 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1264 .expect("priced DeepSeek route should resolve");
1265
1266 match out.pricing() {
1267 Some(PricingSku::Token {
1268 input_per_mtok,
1269 output_per_mtok,
1270 }) => {
1271 assert_eq!(*input_per_mtok, Some(0.28));
1272 assert_eq!(*output_per_mtok, Some(0.42));
1273 }
1274 other => panic!("expected Some(Token), got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn unpriced_offering_stays_unknown() {
1280 use super::candidate::PricingSku;
1281
1282 // The bundled seam (`RouteResolver::new`) carries no sourced cost, so a
1283 // matched offering must surface honest UnknownOrStale, never a fabricated
1284 // zero price (#2608 / #3085 honesty rule).
1285 let r = RouteResolver::new();
1286 let out = r
1287 .resolve(&req(Some(ProviderKind::Deepseek), Some("deepseek-v4-pro")))
1288 .expect("bundled DeepSeek route should resolve");
1289 assert!(
1290 matches!(out.pricing(), Some(PricingSku::UnknownOrStale)),
1291 "bundled offering carries no price → UnknownOrStale, got {:?}",
1292 out.pricing()
1293 );
1294
1295 // A pass-through route with no matched offering is likewise unknown.
1296 let passthrough = r
1297 .resolve(&req(Some(ProviderKind::Ollama), Some("my-local:7b")))
1298 .expect("local passthrough should resolve");
1299 assert!(matches!(
1300 passthrough.pricing(),
1301 Some(PricingSku::UnknownOrStale)
1302 ));
1303 }
1304
1305 // ---------------------------------------------------------------------------
1306 // #1519: advisory insecure-http warning, loopback-exempt.
1307 // ---------------------------------------------------------------------------
1308
1309 /// Build a request with an explicit base-URL override.
1310 fn req_with_base(provider: ProviderKind, model: &str, base_url: &str) -> RouteRequest {
1311 RouteRequest {
1312 explicit_provider: Some(provider),
1313 model_selector: Some(LogicalModelRef::from(model)),
1314 saved_provider_model: None,
1315 base_url_override: Some(base_url.to_string()),
1316 limit_overrides: Vec::new(),
1317 }
1318 }
1319
1320 #[test]
1321 fn http_custom_endpoint_emits_insecure_warning() {
1322 let r = RouteResolver::new();
1323 let out = r
1324 .resolve(&req_with_base(
1325 ProviderKind::Openai,
1326 "gpt-whatever",
1327 "http://example.com/v1",
1328 ))
1329 .expect("custom http endpoint should still resolve");
1330
1331 // Advisory only: the route stays usable.
1332 assert!(
1333 out.validation().ok,
1334 "insecure http is advisory, not a hard fail"
1335 );
1336 assert!(
1337 out.validation()
1338 .messages
1339 .iter()
1340 .any(|m| m.contains("insecure http")),
1341 "expected an insecure-http advisory, got {:?}",
1342 out.validation().messages
1343 );
1344 }
1345
1346 #[test]
1347 fn loopback_http_endpoint_does_not_warn() {
1348 let r = RouteResolver::new();
1349 // localhost, 127.0.0.1, and ::1 are all loopback and must stay clean.
1350 for base in [
1351 "http://localhost:11434/v1",
1352 "http://127.0.0.1:8000/v1",
1353 "http://[::1]:8080/v1",
1354 ] {
1355 let out = r
1356 .resolve(&req_with_base(ProviderKind::Ollama, "my-local:7b", base))
1357 .unwrap_or_else(|e| panic!("loopback route {base} should resolve: {e}"));
1358 assert!(out.validation().ok);
1359 assert!(
1360 out.validation().messages.is_empty(),
1361 "loopback {base} must not warn, got {:?}",
1362 out.validation().messages
1363 );
1364 }
1365 }
1366
1367 #[test]
1368 fn https_endpoint_has_no_warning() {
1369 let r = RouteResolver::new();
1370 let out = r
1371 .resolve(&req_with_base(
1372 ProviderKind::Openai,
1373 "gpt-whatever",
1374 "https://example.com/v1",
1375 ))
1376 .expect("https endpoint should resolve");
1377 assert!(out.validation().ok);
1378 assert!(
1379 out.validation().messages.is_empty(),
1380 "https must not warn, got {:?}",
1381 out.validation().messages
1382 );
1383 }
1384
1384 lines RUST