返回 CodeWhale
resolver.rs
根目录 / crates / config / src / route / resolver.rs
1 //! The sole producer of [`ReadyRouteCandidate`] (#3384).
2 //!
3 //! [`RouteResolver::resolve`] is the ONLY caller of
4 //! `ReadyRouteCandidate::new`. It resolves a [`RouteRequest`] into an
5 //! executable route using:
6 //!
7 //! 1. provider from `explicit_provider` ONLY (no base-URL / prefix sniffing);
8 //! when absent, the workspace default provider scope is used. The provider
9 //! is NEVER inferred from a model prefix.
10 //! 2. the model selector, interpreted STRICTLY within that provider's scope
11 //! against resolver-provided offerings plus the provider default. The default
12 //! resolver uses [`bundled_offerings`], while tests or snapshot loaders can
13 //! inject Models.dev-derived rows. Prefixed selectors are preserved verbatim
14 //! as the [`WireModelId`].
15 //! 3. `auto` => the [`LogicalModelRef::is_auto`] sentinel, never a literal
16 //! model.
17 //!
18 //! It encodes its OWN minimal direct/aggregator/local classification because
19 //! the tui helpers (`provider_passes_model_through` /
20 //! `accepts_custom_model_ids`) are not reachable from `crates/config`. The
21 //! classification here is deliberately NARROWER than tui's `validate_route`:
22 //! it only rejects [`RouteError::ForeignModelForDirectProvider`] for a small
23 //! set of strict direct providers given a clearly-foreign selector;
24 //! aggregators, local, and custom endpoints pass through `Ok` with
25 //! `validation.ok == true`.
26 //!
27 //! There is deliberately no prompt-text / freeform field on [`RouteRequest`],
28 //! which structurally bars prompt-content routing.
29
30 use super::candidate::{
31 LimitField, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint,
32 SourcedLimitOverride, ValidationReport,
33 };
34 use super::capabilities::{
35 RouteCapabilities, documented_deepseek_files_api_for_route,
36 documented_deepseek_image_input_for_route, documented_moonshot_web_search_for_route,
37 documented_zai_web_search_for_route,
38 };
39 use super::descriptor::ProviderDescriptor;
40 use super::errors::RouteError;
41 use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId};
42 use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings};
43 use crate::catalog::{CatalogOffering, bundled_catalog_offerings};
44 use crate::provider::WirePolicy;
45 use crate::{ProviderKind, opencode_go_model_id, provider_preserves_custom_base_url_model};
46
47 /// A request to resolve into an executable route.
48 ///
49 /// Note the absence of any prompt-text/freeform field: the resolver cannot see
50 /// prompt content, so it cannot silently route on it.
51 #[derive(Debug, Clone, Default)]
52 pub struct RouteRequest {
53 /// Explicit provider choice. The ONLY source of provider identity.
54 pub explicit_provider: Option<ProviderKind>,
55 /// The model the caller selected (may be `auto` or prefixed).
56 pub model_selector: Option<LogicalModelRef>,
57 /// A previously-saved provider wire model id, used as scope fallback.
58 pub saved_provider_model: Option<WireModelId>,
59 /// An explicit base URL override for the endpoint.
60 pub base_url_override: Option<String>,
61 /// Sourced limit overrides, applied in order BEFORE the candidate is
62 /// constructed and recorded on it as provenance. This is the ONLY channel
63 /// for adjusting a route's effective limits: the candidate itself is
64 /// immutable once minted.
65 pub limit_overrides: Vec<SourcedLimitOverride>,
66 }
67
68 /// Resolves [`RouteRequest`]s into [`ReadyRouteCandidate`]s.
69 #[derive(Debug, Clone)]
70 pub struct RouteResolver {
71 offerings: Vec<ProviderModelOffering>,
72 configured_offerings: Vec<(String, ProviderModelOffering)>,
73 }
74
75 /// Offering-owned facts selected within one provider scope before the final
76 /// executable route candidate is minted.
77 struct ResolvedOffering {
78 wire_model_id: WireModelId,
79 canonical_model: Option<ModelId>,
80 endpoint_key: String,
81 limits: RouteLimits,
82 capabilities: RouteCapabilities,
83 pricing: PricingSku,
84 }
85
86 impl ResolvedOffering {
87 fn unknown(wire_model_id: WireModelId) -> Self {
88 Self {
89 wire_model_id,
90 canonical_model: None,
91 endpoint_key: "chat".to_string(),
92 limits: RouteLimits::default(),
93 capabilities: RouteCapabilities::default(),
94 pricing: PricingSku::UnknownOrStale,
95 }
96 }
97
98 fn from_offering(offering: &ProviderModelOffering) -> Self {
99 Self {
100 wire_model_id: offering.wire_model_id.clone(),
101 canonical_model: offering.canonical_model.clone(),
102 endpoint_key: offering.endpoint_key.clone(),
103 limits: offering.limits,
104 capabilities: offering.capabilities,
105 pricing: offering.pricing.clone(),
106 }
107 }
108 }
109
110 impl Default for RouteResolver {
111 fn default() -> Self {
112 Self::new()
113 }
114 }
115
116 impl RouteResolver {
117 /// Construct a resolver with CodeWhale's bundled offline offerings.
118 ///
119 /// The default offerings are the committed Models.dev-shaped catalog asset
120 /// (`crate::catalog::bundled_catalog_offerings`, real context windows and
121 /// honest per-row `cost`) merged with the reviewed transport projection
122 /// ([`bundled_offerings`]). The reviewed projection is given precedence on a
123 /// `(provider, wire id)` collision: it encodes the curated canonical-model
124 /// joins the route invariants depend on (e.g. a DeepSeek-native row and the
125 /// aggregator rows that map a prefixed wire id back to `deepseek-v4-pro`),
126 /// which generated Models.dev JSON does not prove. Asset-only rows (GLM,
127 /// Kimi, MiniMax, Qwen, …) add the real provider/model facts the picker and
128 /// candidates were previously missing.
129 #[must_use]
130 pub fn new() -> Self {
131 Self::from_offerings(default_offerings())
132 }
133
134 /// Construct a resolver from a provider-scoped offering catalog.
135 ///
136 /// This is the bridge for Models.dev snapshots: callers parse a catalog,
137 /// emit provider offerings, then hand those rows to the resolver without
138 /// changing route-resolution semantics.
139 #[must_use]
140 pub fn from_offerings(offerings: Vec<ProviderModelOffering>) -> Self {
141 Self {
142 offerings,
143 configured_offerings: Vec::new(),
144 }
145 }
146
147 /// Add validated operator declarations for this exact route. This does
148 /// not grant provider-catalog authority or establish model availability.
149 pub fn with_configured_models(
150 mut self,
151 models: &[crate::catalog::configured::ConfiguredModel],
152 identity: &str,
153 provider: ProviderKind,
154 base_url: &str,
155 ) -> Self {
156 if crate::catalog::configured::validate_configured_models(models).is_err() {
157 return self;
158 }
159 for model in models
160 .iter()
161 .filter(|model| model.matches_route(identity, base_url))
162 {
163 let mut offering = model.to_catalog_offering().to_offering();
164 offering.provider = ProviderId::from(provider.as_str());
165 // Preserve an adapter's established protocol for an existing ID.
166 // The label/config declaration cannot choose a new wire dialect.
167 if let Some(existing) = self.offerings.iter().find(|row| {
168 row.provider == offering.provider && row.wire_model_id == offering.wire_model_id
169 }) {
170 offering.endpoint_key.clone_from(&existing.endpoint_key);
171 offering.default_for_provider = existing.default_for_provider;
172 } else if ProviderDescriptor::for_kind(provider).wire_policy() == WirePolicy::ModelAware
173 && provider != ProviderKind::Deepseek
174 {
175 continue;
176 }
177 // Positive declarations are not verified executable capabilities.
178 // Explicit negatives can still restrict a route conservatively.
179 let declared = offering.capabilities;
180 offering.capabilities = RouteCapabilities {
181 attachments: if model.attachment == Some(false) {
182 declared.attachments
183 } else {
184 Default::default()
185 },
186 image_input: if declared.image_input == super::CapabilityState::Unsupported {
187 declared.image_input
188 } else {
189 Default::default()
190 },
191 reasoning: if model.reasoning == Some(false) {
192 declared.reasoning
193 } else {
194 Default::default()
195 },
196 native_tool_calls: if model.tool_call == Some(false) {
197 declared.native_tool_calls
198 } else {
199 Default::default()
200 },
201 structured_output: if model.structured_output == Some(false) {
202 declared.structured_output
203 } else {
204 Default::default()
205 },
206 ..RouteCapabilities::default()
207 };
208 self.configured_offerings
209 .push((crate::catalog::base_url_fingerprint(base_url), offering));
210 }
211 self
212 }
213
214 /// Resolve a request into an executable route candidate.
215 ///
216 /// # Errors
217 /// Returns [`RouteError`] when the model is empty, the provider is invalid,
218 /// or a clearly-foreign model is requested for a strict direct provider.
219 pub fn resolve(&self, req: &RouteRequest) -> Result<ReadyRouteCandidate, RouteError> {
220 self.resolve_inner(req, false)
221 }
222
223 /// Resolve with catalog facts authenticated against this exact endpoint.
224 ///
225 /// The ordinary [`Self::resolve`] path strips capabilities and pricing when
226 /// a route uses a custom base URL, because a same-named first-party model is
227 /// not evidence about an arbitrary proxy. Callers may use this seam only
228 /// when the injected offering came from the selected provider identity's
229 /// own endpoint and its base-URL fingerprint matches the request endpoint.
230 /// All normal routing and protocol validation still applies.
231 pub fn resolve_with_endpoint_catalog_authority(
232 &self,
233 req: &RouteRequest,
234 ) -> Result<ReadyRouteCandidate, RouteError> {
235 self.resolve_inner(req, true)
236 }
237
238 fn resolve_inner(
239 &self,
240 req: &RouteRequest,
241 endpoint_catalog_authoritative: bool,
242 ) -> Result<ReadyRouteCandidate, RouteError> {
243 if self.configured_offerings.is_empty() {
244 return self.resolve_scoped(req, endpoint_catalog_authoritative);
245 }
246 let mut scoped = self.clone();
247 scoped.configured_offerings.clear();
248 let provider = req.explicit_provider.unwrap_or_default();
249 let base_url = req
250 .base_url_override
251 .as_deref()
252 .unwrap_or_else(|| ProviderDescriptor::for_kind(provider).default_base_url());
253 let fingerprint = crate::catalog::base_url_fingerprint(base_url);
254 let selected_id = req
255 .model_selector
256 .as_ref()
257 .map(LogicalModelRef::raw)
258 .or_else(|| req.saved_provider_model.as_ref().map(WireModelId::as_str));
259 for (endpoint, offering) in &self.configured_offerings {
260 if !base_url.contains(['@', '?', '#'])
261 && *endpoint == fingerprint
262 && offering.provider.as_str() == provider.as_str()
263 && selected_id == Some(offering.wire_model_id.as_str())
264 {
265 scoped.offerings.retain(|row| {
266 row.provider != offering.provider || row.wire_model_id != offering.wire_model_id
267 });
268 scoped.offerings.push(offering.clone());
269 scoped
270 .configured_offerings
271 .push((endpoint.clone(), offering.clone()));
272 }
273 }
274 scoped.resolve_scoped(req, endpoint_catalog_authoritative)
275 }
276
277 fn resolve_scoped(
278 &self,
279 req: &RouteRequest,
280 endpoint_catalog_authoritative: bool,
281 ) -> Result<ReadyRouteCandidate, RouteError> {
282 // 1. Provider scope from explicit choice only; default otherwise.
283 // The provider is NEVER inferred from a model prefix.
284 let provider_kind = req.explicit_provider.unwrap_or_default();
285 if provider_kind == ProviderKind::Antigravity {
286 return Err(RouteError::InvalidProvider(
287 crate::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string(),
288 ));
289 }
290 let descriptor = ProviderDescriptor::for_kind(provider_kind);
291 let provider_id = descriptor.id();
292 let default_offering = self.default_offering(&provider_id);
293
294 // 2. Determine the logical selector from explicit choice, then the
295 // saved-model fallback, then the provider default.
296 let logical_model = match &req.model_selector {
297 Some(selector) => selector.clone(),
298 None => {
299 // No selector: fall back to saved wire model, then provider
300 // default. Both stay in the resolved provider's scope.
301 let raw = req
302 .saved_provider_model
303 .as_ref()
304 .map(|w| w.as_str().to_string())
305 .unwrap_or_else(|| {
306 default_offering.map_or_else(
307 || descriptor.default_wire_model().as_str().to_string(),
308 |offering| offering.wire_model_id.as_str().to_string(),
309 )
310 });
311 LogicalModelRef::from(raw)
312 }
313 };
314
315 // Reject an empty selector from ANY source (explicit, saved, or a
316 // degenerate default), not just an empty explicit selector.
317 if logical_model.raw().is_empty() {
318 return Err(RouteError::EmptyModel);
319 }
320
321 // 3. `auto` is an opt-in sentinel: resolve to the provider default wire
322 // id without treating "auto" as a literal model name.
323 let is_auto = logical_model.is_auto();
324
325 // 4. Map the selector to a wire id within provider scope.
326 // Prefixed selectors are preserved VERBATIM as the wire id.
327 let custom_endpoint =
328 request_uses_custom_endpoint(&descriptor, req.base_url_override.as_deref());
329 let class = if custom_endpoint {
330 ProviderClass::LocalOrCustom
331 } else {
332 classify(provider_kind)
333 };
334 let model_aware = descriptor.wire_policy() == WirePolicy::ModelAware;
335 // A model-aware protocol row is an exact provider-endpoint fact.
336 // OpenCode Zen's published roster is closed; DeepSeek's direct route
337 // deliberately preserves its existing future-model pass-through and
338 // sends unknown bare ids over Chat until an exact Responses row exists.
339 // A custom DeepSeek-compatible endpoint retains that pass-through, but
340 // a custom URL must not weaken another model-aware provider's closed
341 // protocol roster.
342 let require_catalog_match = model_aware && provider_kind != ProviderKind::Deepseek;
343 let mut selected = if is_auto {
344 match default_offering {
345 None if require_catalog_match => {
346 return Err(RouteError::UnsupportedModelProtocol {
347 provider: provider_id.clone(),
348 model: descriptor.default_wire_model().as_str().to_string(),
349 endpoint_key: "unproven".to_string(),
350 });
351 }
352 None => ResolvedOffering::unknown(descriptor.default_wire_model()),
353 Some(offering) => ResolvedOffering::from_offering(offering),
354 }
355 } else {
356 self.scope_selector(
357 provider_kind,
358 &provider_id,
359 &logical_model,
360 class,
361 require_catalog_match,
362 )?
363 };
364 if provider_kind == ProviderKind::OpencodeGo {
365 selected.endpoint_key =
366 crate::opencode_go_endpoint_key(selected.wire_model_id.as_str())
367 .ok_or_else(|| RouteError::UnsupportedModelProtocol {
368 provider: provider_id.clone(),
369 model: selected.wire_model_id.as_str().to_string(),
370 endpoint_key: "unproven".to_string(),
371 })?
372 .to_string();
373 }
374 if provider_kind == ProviderKind::Deepseek && custom_endpoint {
375 selected.endpoint_key = "chat".to_string();
376 }
377 if custom_endpoint && !endpoint_catalog_authoritative {
378 // Capabilities and pricing belong to the exact provider endpoint
379 // offering that reported them. Reusing a provider enum and a
380 // first-party model id against a custom compatible endpoint does
381 // not prove that proxy serves the same canonical model, limits,
382 // modality, tool, reasoning, or billing contract. Keep the
383 // caller's wire model id, but clear every unowned offering fact at
384 // the authority boundary instead of presenting it as verified.
385 // The endpoint_key/protocol stays: it is the provider adapter's
386 // wire contract (a model-aware roster row or fixed policy), not an
387 // endpoint-catalog fact, and coercing it to Chat would silently
388 // change how a Responses- or Messages-bound route speaks.
389 // Deepseek's custom-endpoint Chat pass-through is handled above.
390 selected.canonical_model = None;
391 selected.limits = RouteLimits::default();
392 selected.capabilities = RouteCapabilities::default();
393 selected.pricing = PricingSku::UnknownOrStale;
394 }
395 let base_url = req
396 .base_url_override
397 .as_deref()
398 .unwrap_or_else(|| descriptor.default_base_url());
399 let mut declared_limit_overrides = Vec::new();
400 if let Some((_, offering)) =
401 self.configured_offerings
402 .iter()
403 .find(|(fingerprint, offering)| {
404 !base_url.contains(['@', '?', '#'])
405 && offering.provider == provider_id
406 && offering.wire_model_id == selected.wire_model_id
407 && req
408 .model_selector
409 .as_ref()
410 .map(LogicalModelRef::raw)
411 .or_else(|| req.saved_provider_model.as_ref().map(WireModelId::as_str))
412 == Some(offering.wire_model_id.as_str())
413 && *fingerprint == crate::catalog::base_url_fingerprint(base_url)
414 })
415 {
416 selected.canonical_model = None;
417 selected.limits = offering.limits;
418 selected.capabilities = offering.capabilities;
419 selected.pricing = offering.pricing.clone();
420 for (field, value) in [
421 (LimitField::ContextTokens, offering.limits.context_tokens),
422 (LimitField::InputTokens, offering.limits.input_tokens),
423 (LimitField::OutputTokens, offering.limits.output_tokens),
424 ] {
425 declared_limit_overrides.push(SourcedLimitOverride {
426 field,
427 value,
428 source: super::OverrideSource::UserModelMetadata,
429 });
430 }
431 }
432 if provider_kind == ProviderKind::Zai {
433 let effective_base_url = req
434 .base_url_override
435 .as_deref()
436 .unwrap_or_else(|| descriptor.default_base_url());
437 selected.capabilities.server_side_web_search = documented_zai_web_search_for_route(
438 provider_kind,
439 selected.wire_model_id.as_str(),
440 effective_base_url,
441 );
442 }
443 if provider_kind == ProviderKind::Moonshot {
444 let effective_base_url = req
445 .base_url_override
446 .as_deref()
447 .unwrap_or_else(|| descriptor.default_base_url());
448 selected.capabilities.server_side_web_search = documented_moonshot_web_search_for_route(
449 provider_kind,
450 selected.wire_model_id.as_str(),
451 effective_base_url,
452 );
453 }
454 if matches!(
455 provider_kind,
456 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
457 ) {
458 let effective_base_url = req
459 .base_url_override
460 .as_deref()
461 .unwrap_or_else(|| descriptor.default_base_url());
462 selected.capabilities.files_api = documented_deepseek_files_api_for_route(
463 provider_kind,
464 selected.wire_model_id.as_str(),
465 effective_base_url,
466 );
467 // Flash accepts images on every official DeepSeek dialect,
468 // including the Messages route the curated rows do not cover.
469 // Only ever widens: an official Pro row keeps its documented
470 // `Unsupported`, a custom host keeps its cleared `Unknown`.
471 if documented_deepseek_image_input_for_route(
472 provider_kind,
473 selected.wire_model_id.as_str(),
474 effective_base_url,
475 ) == super::CapabilityState::Supported
476 {
477 selected.capabilities.image_input = super::CapabilityState::Supported;
478 }
479 }
480
481 let protocol = descriptor
482 .protocol_for_endpoint(&selected.endpoint_key)
483 .ok_or_else(|| RouteError::UnsupportedModelProtocol {
484 provider: provider_id.clone(),
485 model: selected.wire_model_id.as_str().to_string(),
486 endpoint_key: selected.endpoint_key.clone(),
487 })?;
488 let endpoint = ResolvedEndpoint {
489 base_url: req
490 .base_url_override
491 .clone()
492 .unwrap_or_else(|| descriptor.default_base_url().to_string()),
493 endpoint_key: selected.endpoint_key,
494 protocol,
495 };
496
497 // Advisory validation (#1519): a non-loopback `http://` endpoint sends
498 // credentials in plaintext. This is advisory, not a hard fail, so
499 // `ok` stays true and local `http://localhost` runtimes (Ollama / vLLM /
500 // SGLang defaults) stay clean.
501 let mut messages = Vec::new();
502 if endpoint_uses_insecure_http(&endpoint.base_url) {
503 messages
504 .push("endpoint uses insecure http:// (credentials sent in plaintext)".to_string());
505 }
506 let validation = ValidationReport { ok: true, messages };
507
508 // Apply caller-requested limit overrides in order, BEFORE the candidate
509 // is minted. The candidate is immutable afterwards; the applied
510 // overrides are recorded on it as provenance.
511 let mut limits = selected.limits;
512 for limit_override in &req.limit_overrides {
513 match limit_override.field {
514 LimitField::ContextTokens => limits.context_tokens = limit_override.value,
515 LimitField::InputTokens => limits.input_tokens = limit_override.value,
516 LimitField::OutputTokens => limits.output_tokens = limit_override.value,
517 }
518 }
519
520 Ok(ReadyRouteCandidate::new(
521 provider_id,
522 provider_kind,
523 logical_model,
524 selected.canonical_model,
525 selected.wire_model_id,
526 endpoint,
527 // The resolver never inspects credentials: auth is honestly
528 // `Unresolved` at resolution time, not a claimed `Missing`.
529 ResolvedAuthSource::Unresolved,
530 protocol,
531 limits,
532 selected.capabilities,
533 // #3085: honest pricing projected from the matched offering (the
534 // catalog layer maps sourced cost → SKU); `UnknownOrStale` whenever
535 // no offering was matched or the offering carried no price.
536 Some(selected.pricing),
537 validation,
538 declared_limit_overrides
539 .into_iter()
540 .chain(req.limit_overrides.iter().cloned())
541 .collect(),
542 ))
543 }
544
545 /// Interpret a concrete (non-auto) selector strictly within provider scope.
546 fn scope_selector(
547 &self,
548 provider_kind: ProviderKind,
549 provider_id: &ProviderId,
550 logical_model: &LogicalModelRef,
551 class: ProviderClass,
552 require_catalog_match: bool,
553 ) -> Result<ResolvedOffering, RouteError> {
554 // Go's provider roster owns each model's protocol, including when a
555 // custom base URL is configured. Unknown IDs must not fall through.
556 let raw = if provider_kind == ProviderKind::OpencodeGo {
557 opencode_go_model_id(logical_model.raw()).ok_or_else(|| {
558 RouteError::UnsupportedModelProtocol {
559 provider: provider_id.clone(),
560 model: logical_model.raw().to_string(),
561 endpoint_key: "unproven".to_string(),
562 }
563 })?
564 } else if provider_kind == ProviderKind::OpencodeZen {
565 logical_model
566 .raw()
567 .strip_prefix("opencode/")
568 .or_else(|| logical_model.raw().strip_prefix("opencode-zen/"))
569 .unwrap_or_else(|| logical_model.raw())
570 } else if self.configured_offerings.iter().any(|(_, offering)| {
571 offering.provider == *provider_id
572 && offering.wire_model_id.as_str() == logical_model.raw()
573 }) {
574 // An explicitly declared wire ID is not a convenience selector.
575 logical_model.raw()
576 } else if provider_kind == ProviderKind::Concentrate {
577 // Concentrate's own namespace is not part of its wire ids.
578 // `concentrate/auto` is the explicit spelling for the gateway's
579 // `auto` router — Codewhale's bare `auto` is the resolver sentinel
580 // above, never a literal model id — and `concentrate/<id>` is
581 // `<id>`. Upstream prefixes (`openai/gpt-5.6-sol`) stay verbatim:
582 // they pin the upstream provider inside the gateway.
583 logical_model
584 .raw()
585 .strip_prefix("concentrate/")
586 .unwrap_or_else(|| logical_model.raw())
587 } else {
588 provider_scoped_wire_alias(provider_kind, logical_model.raw(), class)
589 };
590
591 // This list was scoped to the exact endpoint and raw selector in
592 // resolve_inner, after closed-protocol admission in the builder.
593 // Prefer that literal declaration over a bundled canonical alias.
594 // Keep this after provider protocol/allowlist normalization above;
595 // a declaration cannot preserve an alias those guards must rewrite.
596 if let Some((_, offering)) = self.configured_offerings.iter().find(|(_, offering)| {
597 offering.provider == *provider_id && offering.wire_model_id.as_str() == raw
598 }) {
599 return Ok(ResolvedOffering::from_offering(offering));
600 }
601
602 // Try to match a catalog offering owned by THIS provider, either by
603 // canonical model id or by exact wire id. This keeps interpretation
604 // inside provider scope; offerings from other providers are ignored.
605 // DeepSeek and Z.ai also publish marketing-cased wire ids while saved
606 // selectors can be lowercase. Defer that fallback until exact matching
607 // is exhausted, and only accept a unique provider-owned match so
608 // catalog order can never choose between case-distinct model ids.
609 let allow_casefold_wire_match = class == ProviderClass::StrictDirect
610 && matches!(provider_kind, ProviderKind::Deepseek | ProviderKind::Zai);
611 let mut casefold_match = None;
612 let mut casefold_ambiguous = false;
613 for offering in &self.offerings {
614 if offering.provider != *provider_id {
615 continue;
616 }
617 let matches_canonical = offering
618 .canonical_model
619 .as_ref()
620 .is_some_and(|m| m.as_str() == raw);
621 let matches_wire = offering.wire_model_id.as_str() == raw;
622 if matches_canonical || matches_wire {
623 return Ok(ResolvedOffering::from_offering(offering));
624 }
625 if allow_casefold_wire_match
626 && offering.wire_model_id.as_str().eq_ignore_ascii_case(raw)
627 {
628 if casefold_match.is_some() {
629 casefold_ambiguous = true;
630 } else {
631 casefold_match = Some(offering);
632 }
633 }
634 }
635 if !casefold_ambiguous && let Some(offering) = casefold_match {
636 return Ok(ResolvedOffering::from_offering(offering));
637 }
638
639 // No catalog match. Apply class-specific pass-through rules.
640 match class {
641 ProviderClass::StrictDirect => {
642 if self.selector_matches_other_provider_offering(provider_id, raw) {
643 return Err(RouteError::ForeignModelForDirectProvider {
644 provider: provider_id.clone(),
645 model: raw.to_string(),
646 });
647 }
648 // A clearly-foreign selector for a strict direct provider is
649 // rejected. "Clearly foreign" = it carries an aggregator/org
650 // namespace prefix, which a direct provider never expects.
651 if logical_model.namespace_hint().is_some() {
652 return Err(RouteError::ForeignModelForDirectProvider {
653 provider: provider_id.clone(),
654 model: raw.to_string(),
655 });
656 }
657 if require_catalog_match {
658 return Err(RouteError::UnsupportedModelProtocol {
659 provider: provider_id.clone(),
660 model: raw.to_string(),
661 endpoint_key: "unproven".to_string(),
662 });
663 }
664 // A bare, unknown model on a strict direct provider is passed
665 // through verbatim (the provider validates it server-side). No
666 // offering matched, so pricing is honestly unknown (#3085).
667 Ok(ResolvedOffering::unknown(WireModelId::from(raw)))
668 }
669 // Aggregators, local runtimes, and custom OpenAI-compatible
670 // endpoints legitimately accept arbitrary / prefixed ids verbatim.
671 ProviderClass::Aggregator | ProviderClass::LocalOrCustom => {
672 if require_catalog_match {
673 // The Codewhale API's protocol roster is the *account's*
674 // live catalog, not a compiled list: a customer who
675 // connects a new provider gets new `provider/model` rows
676 // without a Codewhale release. Failing closed on a row the
677 // local catalog has not seen yet would make the account's
678 // own connected provider unreachable, so infer the
679 // protocol from the namespace the account API itself uses
680 // (`anthropic/` is the Messages passthrough; everything
681 // else is Chat Completions).
682 if provider_kind == ProviderKind::Codewhale {
683 return Ok(ResolvedOffering {
684 wire_model_id: WireModelId::from(raw),
685 canonical_model: None,
686 endpoint_key: super::codewhale_endpoint_key_for_model(raw).to_string(),
687 limits: RouteLimits::default(),
688 capabilities: RouteCapabilities::default(),
689 pricing: PricingSku::UnknownOrStale,
690 });
691 }
692 // Opencode Zen serves Muse Spark exclusively over Responses.
693 // Handle any future muse-spark variant (e.g. -free suffix)
694 // even when no exact bundled offering exists — fail open to
695 // responses rather than failing closed to "unproven".
696 if provider_kind == ProviderKind::OpencodeZen
697 && raw.to_ascii_lowercase().contains("muse-spark")
698 {
699 return Ok(ResolvedOffering {
700 wire_model_id: WireModelId::from(raw),
701 canonical_model: None,
702 endpoint_key: "responses".to_string(),
703 limits: RouteLimits::default(),
704 capabilities: RouteCapabilities::default(),
705 pricing: PricingSku::UnknownOrStale,
706 });
707 }
708 return Err(RouteError::UnsupportedModelProtocol {
709 provider: provider_id.clone(),
710 model: raw.to_string(),
711 endpoint_key: "unproven".to_string(),
712 });
713 }
714 // No offering matched: pricing is honestly unknown (#3085).
715 Ok(ResolvedOffering::unknown(WireModelId::from(raw)))
716 }
717 }
718 }
719
720 fn default_offering(&self, provider_id: &ProviderId) -> Option<&ProviderModelOffering> {
721 self.offerings
722 .iter()
723 .find(|offering| offering.provider == *provider_id && offering.default_for_provider)
724 }
725
726 /// True when `raw` names an offering that lives on a *different* provider.
727 ///
728 /// The `wire_model_id` arm catches the common case (a bare id another
729 /// provider serves). The `canonical_model` arm covers catalog rows whose
730 /// canonical id is slash-free: Models.dev canonical ids normally contain a
731 /// namespace (`zhipuai/glm-5.2`) and are already caught by the
732 /// `namespace_hint()` guard at the call site, but a bare canonical id (or a
733 /// hand-authored offering) would slip through wire-id matching alone. It is
734 /// kept deliberately so a bare canonical selector cannot masquerade as a
735 /// pass-through model on the wrong provider.
736 fn selector_matches_other_provider_offering(
737 &self,
738 provider_id: &ProviderId,
739 raw: &str,
740 ) -> bool {
741 self.offerings.iter().any(|offering| {
742 offering.provider != *provider_id
743 && (offering.wire_model_id.as_str() == raw
744 || offering
745 .canonical_model
746 .as_ref()
747 .is_some_and(|model| model.as_str() == raw))
748 })
749 }
750 }
751
752 /// Normalize aliases whose provider wire identity is publicly documented but
753 /// intentionally absent from the offline offering catalog. Keeping this seam
754 /// provider-scoped avoids claiming unverified limits or pricing while ensuring
755 /// receipts and HTTP requests carry the exact upstream model id.
756 fn provider_scoped_wire_alias(
757 provider_kind: ProviderKind,
758 raw: &str,
759 class: ProviderClass,
760 ) -> &str {
761 if class != ProviderClass::LocalOrCustom {
762 if provider_kind == ProviderKind::Together
763 && (raw.eq_ignore_ascii_case("inkling") || raw.eq_ignore_ascii_case("together-inkling"))
764 {
765 return "thinkingmachines/inkling";
766 }
767 if provider_kind == ProviderKind::Openrouter
768 && (raw.eq_ignore_ascii_case("qwen3.7-plus")
769 || raw.eq_ignore_ascii_case("qwen-3.7-plus"))
770 {
771 return "qwen/qwen3.7-plus";
772 }
773 }
774 raw
775 }
776
777 /// Build the default resolver offerings from the bundled Models.dev asset.
778 ///
779 /// Curated transport rows win a `(provider, wire id)` collision over the asset;
780 /// all other offerings continue to come from Models.dev.
781 fn default_offerings() -> Vec<ProviderModelOffering> {
782 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
783 let mut out = Vec::new();
784 let asset_rows = bundled_catalog_offerings()
785 .iter()
786 .map(CatalogOffering::to_offering)
787 .collect::<Vec<_>>();
788 // Seam first so it wins identity collisions, then asset-only rows follow.
789 let go_metadata: std::collections::HashMap<_, _> = asset_rows
790 .iter()
791 .filter(|row| row.provider.as_str() == "opencode-go")
792 .map(|row| (row.wire_model_id.as_str(), row))
793 .collect();
794 for mut offering in bundled_offerings()
795 .into_iter()
796 .chain(asset_rows.iter().cloned())
797 {
798 // The transport seam must not erase already sourced Go limits/prices.
799 if offering.provider.as_str() == "opencode-go"
800 && let Some(metadata) = go_metadata.get(offering.wire_model_id.as_str())
801 {
802 offering
803 .canonical_model
804 .clone_from(&metadata.canonical_model);
805 offering.limits = metadata.limits;
806 offering.capabilities = metadata.capabilities;
807 offering.pricing.clone_from(&metadata.pricing);
808 }
809 let key = (
810 offering.provider.as_str().to_string(),
811 offering.wire_model_id.as_str().to_string(),
812 );
813 if seen.insert(key) {
814 out.push(offering);
815 }
816 }
817 out
818 }
819
820 /// The resolver's minimal route classification.
821 ///
822 /// Intentionally narrower than tui's `validate_route`.
823 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
824 enum ProviderClass {
825 /// Strict direct provider: rejects clearly-foreign (prefixed) selectors.
826 StrictDirect,
827 /// Aggregator: serves many catalogs under prefixed wire ids.
828 Aggregator,
829 /// Local runtime or custom OpenAI-compatible endpoint: pass-through.
830 LocalOrCustom,
831 }
832
833 /// Classify a provider kind for resolver pass-through rules.
834 ///
835 /// Only a SMALL set of providers are strict-direct. Everything else passes
836 /// through, so the resolver stays permissive by default.
837 fn classify(kind: ProviderKind) -> ProviderClass {
838 match kind {
839 // Strict first-party direct providers.
840 ProviderKind::Deepseek | ProviderKind::Zai => ProviderClass::StrictDirect,
841 // Local runtimes / custom OpenAI-compatible endpoints.
842 ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang | ProviderKind::Openai => {
843 ProviderClass::LocalOrCustom
844 }
845 // Everything else is treated as an aggregator-style pass-through.
846 _ => ProviderClass::Aggregator,
847 }
848 }
849
850 fn request_uses_custom_endpoint(
851 descriptor: &ProviderDescriptor,
852 base_url_override: Option<&str>,
853 ) -> bool {
854 base_url_override
855 .is_some_and(|base_url| provider_preserves_custom_base_url_model(descriptor.kind, base_url))
856 }
857
858 /// True when `base_url` is an `http://` endpoint whose host is NOT loopback
859 /// (#1519). Such an endpoint sends credentials in plaintext over the network;
860 /// loopback (`localhost` / `127.0.0.1` / `::1`) is exempt because local
861 /// runtimes (Ollama / vLLM / SGLang) default to plain `http://localhost`.
862 fn endpoint_uses_insecure_http(base_url: &str) -> bool {
863 let trimmed = base_url.trim();
864 // Scheme match is case-insensitive but must be `http`, not `https`.
865 let Some(rest) = strip_http_scheme(trimmed) else {
866 return false;
867 };
868 !is_loopback_host(host_of_authority(rest))
869 }
870
871 /// Strip a leading case-insensitive `http://` scheme, returning the remainder.
872 /// Returns `None` for any other scheme (including `https://`) or no scheme.
873 fn strip_http_scheme(base_url: &str) -> Option<&str> {
874 let idx = base_url.find("://")?;
875 let (scheme, rest) = base_url.split_at(idx);
876 if scheme.eq_ignore_ascii_case("http") {
877 Some(&rest[3..])
878 } else {
879 None
880 }
881 }
882
883 /// Extract the bare host from an authority+path string: take the authority up
884 /// to the first `/`, drop any `user@` userinfo and `:port` suffix, and unwrap
885 /// `[..]` IPv6 brackets.
886 fn host_of_authority(rest: &str) -> &str {
887 let authority = rest.split('/').next().unwrap_or(rest);
888 // Drop userinfo (`user:pass@host`) if present.
889 let authority = authority.rsplit('@').next().unwrap_or(authority);
890 if let Some(inner) = authority.strip_prefix('[') {
891 // Bracketed IPv6 literal: host is everything up to the closing bracket.
892 return inner.split(']').next().unwrap_or(inner);
893 }
894 // Otherwise strip a trailing `:port`.
895 authority.split(':').next().unwrap_or(authority)
896 }
897
898 /// Whether `host` is an IPv4/IPv6/name loopback address.
899 fn is_loopback_host(host: &str) -> bool {
900 let host = host.trim().trim_matches(|c| c == '[' || c == ']');
901 if host.eq_ignore_ascii_case("localhost") {
902 return true;
903 }
904 // Parse real addresses rather than pattern-matching a `127.` prefix: the
905 // old `strip_prefix("127.") && 4 dot-parts` check classified
906 // `127.evil.example.com` as loopback (2026-08-04 review), which would let
907 // a hostile hostname inherit local-trust routing. `Ipv4Addr::is_loopback`
908 // is exactly the 127.0.0.0/8 block; `Ipv6Addr::is_loopback` is `::1`.
909 if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
910 return v4.is_loopback();
911 }
912 if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
913 return v6.is_loopback();
914 }
915 false
916 }
917
918 #[cfg(test)]
919 mod loopback_tests {
920 use super::{endpoint_uses_insecure_http, is_loopback_host};
921
922 #[test]
923 fn loopback_matches_only_real_loopback_addresses() {
924 assert!(is_loopback_host("localhost"));
925 assert!(is_loopback_host("LocalHost"));
926 assert!(is_loopback_host("127.0.0.1"));
927 assert!(is_loopback_host("127.1.2.3")); // all of 127.0.0.0/8
928 assert!(is_loopback_host("::1"));
929 assert!(is_loopback_host("[::1]"));
930
931 // The 2026-08-04 regression: a hostile hostname that merely starts
932 // with `127.` and has four dot-parts must NOT be trusted as local.
933 assert!(!is_loopback_host("127.evil.example.com"));
934 assert!(!is_loopback_host("127.0.0.1.evil.com"));
935 assert!(!is_loopback_host("notlocalhost"));
936 assert!(!is_loopback_host("10.0.0.1"));
937 assert!(!is_loopback_host("localhost.evil.com"));
938 }
939
940 #[test]
941 fn insecure_http_flags_a_hostile_127_lookalike() {
942 // loopback stays exempt (local runtimes use plain http)
943 assert!(!endpoint_uses_insecure_http("http://127.0.0.1:11434/v1"));
944 assert!(!endpoint_uses_insecure_http("http://localhost:8000/v1"));
945 // a real remote host dressed up as 127.* is insecure http
946 assert!(endpoint_uses_insecure_http(
947 "http://127.evil.example.com/v1"
948 ));
949 }
950 }
951
951 lines RUST