| 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 |