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