codewhale_config/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
30use super::candidate::{
31 PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint, ValidationReport,
32};
33use super::descriptor::ProviderDescriptor;
34use super::errors::RouteError;
35use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId};
36use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings};
37use crate::catalog::{CatalogOffering, bundled_catalog_offerings};
38use crate::{ProviderKind, provider_preserves_custom_base_url_model};
39
40/// A request to resolve into an executable route.
41///
42/// Note the absence of any prompt-text/freeform field: the resolver cannot see
43/// prompt content, so it cannot silently route on it.
44#[derive(Debug, Clone, Default)]
45pub struct RouteRequest {
46 /// Explicit provider choice. The ONLY source of provider identity.
47 pub explicit_provider: Option<ProviderKind>,
48 /// The model the caller selected (may be `auto` or prefixed).
49 pub model_selector: Option<LogicalModelRef>,
50 /// A previously-saved provider wire model id, used as scope fallback.
51 pub saved_provider_model: Option<WireModelId>,
52 /// An explicit base URL override for the endpoint.
53 pub base_url_override: Option<String>,
54}
55
56/// Resolves [`RouteRequest`]s into [`ReadyRouteCandidate`]s.
57#[derive(Debug, Clone)]
58pub struct RouteResolver {
59 offerings: Vec<ProviderModelOffering>,
60}
61
62impl Default for RouteResolver {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl RouteResolver {
69 /// Construct a resolver with CodeWhale's bundled offline offerings.
70 ///
71 /// The default offerings are the committed Models.dev-shaped catalog asset
72 /// (`crate::catalog::bundled_catalog_offerings`, real context windows and
73 /// honest per-row `cost`) merged with the tiny hand seam
74 /// ([`bundled_offerings`]). The hand seam is kept and given precedence on a
75 /// `(provider, wire id)` collision: it encodes the curated canonical-model
76 /// joins the route invariants depend on (e.g. a DeepSeek-native row and the
77 /// aggregator rows that map a prefixed wire id back to `deepseek-v4-pro`),
78 /// which generated Models.dev JSON does not prove. Asset-only rows (GLM,
79 /// Kimi, MiniMax, Qwen, …) add the real provider/model facts the picker and
80 /// candidates were previously missing.
81 #[must_use]
82 pub fn new() -> Self {
83 Self::from_offerings(default_offerings())
84 }
85
86 /// Construct a resolver from a provider-scoped offering catalog.
87 ///
88 /// This is the bridge for Models.dev snapshots: callers parse a catalog,
89 /// emit provider offerings, then hand those rows to the resolver without
90 /// changing route-resolution semantics.
91 #[must_use]
92 pub fn from_offerings(offerings: Vec<ProviderModelOffering>) -> Self {
93 Self { offerings }
94 }
95
96 /// Resolve a request into an executable route candidate.
97 ///
98 /// # Errors
99 /// Returns [`RouteError`] when the model is empty, the provider is invalid,
100 /// or a clearly-foreign model is requested for a strict direct provider.
101 pub fn resolve(&self, req: &RouteRequest) -> Result<ReadyRouteCandidate, RouteError> {
102 // 1. Provider scope from explicit choice only; default otherwise.
103 // The provider is NEVER inferred from a model prefix.
104 let provider_kind = req.explicit_provider.unwrap_or_default();
105 let descriptor = ProviderDescriptor::for_kind(provider_kind);
106 let provider_id = descriptor.id();
107 let default_offering = self.default_offering(&provider_id);
108
109 // 2. Determine the logical selector from explicit choice, then the
110 // saved-model fallback, then the provider default.
111 let logical_model = match &req.model_selector {
112 Some(selector) => selector.clone(),
113 None => {
114 // No selector: fall back to saved wire model, then provider
115 // default. Both stay in the resolved provider's scope.
116 let raw = req
117 .saved_provider_model
118 .as_ref()
119 .map(|w| w.as_str().to_string())
120 .unwrap_or_else(|| {
121 default_offering.map_or_else(
122 || descriptor.default_wire_model().as_str().to_string(),
123 |offering| offering.wire_model_id.as_str().to_string(),
124 )
125 });
126 LogicalModelRef::from(raw)
127 }
128 };
129
130 // Reject an empty selector from ANY source (explicit, saved, or a
131 // degenerate default), not just an empty explicit selector.
132 if logical_model.raw().is_empty() {
133 return Err(RouteError::EmptyModel);
134 }
135
136 // 3. `auto` is an opt-in sentinel: resolve to the provider default wire
137 // id without treating "auto" as a literal model name.
138 let is_auto = logical_model.is_auto();
139
140 // 4. Map the selector to a wire id within provider scope.
141 // Prefixed selectors are preserved VERBATIM as the wire id.
142 let class = if request_uses_custom_endpoint(&descriptor, req.base_url_override.as_deref()) {
143 ProviderClass::LocalOrCustom
144 } else {
145 classify(provider_kind)
146 };
147 let (wire_model_id, canonical_model, endpoint_key, limits, pricing) = if is_auto {
148 default_offering.map_or_else(
149 || {
150 (
151 descriptor.default_wire_model(),
152 None,
153 "chat".to_string(),
154 RouteLimits::default(),
155 // No offering in hand on the default branch: pricing is
156 // honestly unknown (#3085), never a fabricated zero.
157 PricingSku::UnknownOrStale,
158 )
159 },
160 |offering| {
161 (
162 offering.wire_model_id.clone(),
163 offering.canonical_model.clone(),
164 offering.endpoint_key.clone(),
165 offering.limits,
166 // Matched offering: carry its sourced pricing meter.
167 offering.pricing.clone(),
168 )
169 },
170 )
171 } else {
172 self.scope_selector(provider_kind, &provider_id, &logical_model, class)?
173 };
174
175 let endpoint = ResolvedEndpoint {
176 base_url: req
177 .base_url_override
178 .clone()
179 .unwrap_or_else(|| descriptor.default_base_url().to_string()),
180 endpoint_key,
181 protocol: descriptor.protocol(),
182 };
183
184 // Advisory validation (#1519): a non-loopback `http://` endpoint sends
185 // credentials in plaintext. This is advisory, not a hard fail, so
186 // `ok` stays true and local `http://localhost` runtimes (Ollama / vLLM /
187 // SGLang defaults) stay clean.
188 let mut messages = Vec::new();
189 if endpoint_uses_insecure_http(&endpoint.base_url) {
190 messages
191 .push("endpoint uses insecure http:// (credentials sent in plaintext)".to_string());
192 }
193 let validation = ValidationReport { ok: true, messages };
194
195 Ok(ReadyRouteCandidate::new(
196 provider_id,
197 provider_kind,
198 logical_model,
199 canonical_model,
200 wire_model_id,
201 endpoint,
202 ResolvedAuthSource::Missing,
203 descriptor.protocol(),
204 limits,
205 // #3085: honest pricing projected from the matched offering (the
206 // catalog layer maps sourced cost → SKU); `UnknownOrStale` whenever
207 // no offering was matched or the offering carried no price.
208 Some(pricing),
209 validation,
210 ))
211 }
212
213 /// Interpret a concrete (non-auto) selector strictly within provider scope.
214 fn scope_selector(
215 &self,
216 provider_kind: ProviderKind,
217 provider_id: &ProviderId,
218 logical_model: &LogicalModelRef,
219 class: ProviderClass,
220 ) -> Result<
221 (
222 WireModelId,
223 Option<ModelId>,
224 String,
225 RouteLimits,
226 PricingSku,
227 ),
228 RouteError,
229 > {
230 let raw = provider_scoped_wire_alias(provider_kind, logical_model.raw(), class);
231
232 // Try to match a catalog offering owned by THIS provider, either by
233 // canonical model id or by exact wire id. This keeps interpretation
234 // inside provider scope; offerings from other providers are ignored.
235 for offering in &self.offerings {
236 if offering.provider != *provider_id {
237 continue;
238 }
239 let matches_canonical = offering
240 .canonical_model
241 .as_ref()
242 .is_some_and(|m| m.as_str() == raw);
243 let matches_wire = offering.wire_model_id.as_str() == raw;
244 if matches_canonical || matches_wire {
245 return Ok((
246 offering.wire_model_id.clone(),
247 offering.canonical_model.clone(),
248 offering.endpoint_key.clone(),
249 offering.limits,
250 // Matched offering: carry its sourced pricing meter (#3085).
251 offering.pricing.clone(),
252 ));
253 }
254 }
255
256 // No catalog match. Apply class-specific pass-through rules.
257 match class {
258 ProviderClass::StrictDirect => {
259 if self.selector_matches_other_provider_offering(provider_id, raw) {
260 return Err(RouteError::ForeignModelForDirectProvider {
261 provider: provider_id.clone(),
262 model: raw.to_string(),
263 });
264 }
265 // A clearly-foreign selector for a strict direct provider is
266 // rejected. "Clearly foreign" = it carries an aggregator/org
267 // namespace prefix, which a direct provider never expects.
268 if logical_model.namespace_hint().is_some() {
269 return Err(RouteError::ForeignModelForDirectProvider {
270 provider: provider_id.clone(),
271 model: raw.to_string(),
272 });
273 }
274 // A bare, unknown model on a strict direct provider is passed
275 // through verbatim (the provider validates it server-side). No
276 // offering matched, so pricing is honestly unknown (#3085).
277 Ok((
278 WireModelId::from(raw),
279 None,
280 "chat".to_string(),
281 RouteLimits::default(),
282 PricingSku::UnknownOrStale,
283 ))
284 }
285 // Aggregators, local runtimes, and custom OpenAI-compatible
286 // endpoints legitimately accept arbitrary / prefixed ids verbatim.
287 ProviderClass::Aggregator | ProviderClass::LocalOrCustom => {
288 let _ = provider_kind;
289 // No offering matched: pricing is honestly unknown (#3085).
290 Ok((
291 WireModelId::from(raw),
292 None,
293 "chat".to_string(),
294 RouteLimits::default(),
295 PricingSku::UnknownOrStale,
296 ))
297 }
298 }
299 }
300
301 fn default_offering(&self, provider_id: &ProviderId) -> Option<&ProviderModelOffering> {
302 self.offerings
303 .iter()
304 .find(|offering| offering.provider == *provider_id && offering.default_for_provider)
305 }
306
307 /// True when `raw` names an offering that lives on a *different* provider.
308 ///
309 /// The `wire_model_id` arm catches the common case (a bare id another
310 /// provider serves). The `canonical_model` arm covers catalog rows whose
311 /// canonical id is slash-free: Models.dev canonical ids normally contain a
312 /// namespace (`zhipuai/glm-5.2`) and are already caught by the
313 /// `namespace_hint()` guard at the call site, but a bare canonical id (or a
314 /// hand-authored offering) would slip through wire-id matching alone. It is
315 /// kept deliberately so a bare canonical selector cannot masquerade as a
316 /// pass-through model on the wrong provider.
317 fn selector_matches_other_provider_offering(
318 &self,
319 provider_id: &ProviderId,
320 raw: &str,
321 ) -> bool {
322 self.offerings.iter().any(|offering| {
323 offering.provider != *provider_id
324 && (offering.wire_model_id.as_str() == raw
325 || offering
326 .canonical_model
327 .as_ref()
328 .is_some_and(|model| model.as_str() == raw))
329 })
330 }
331}
332
333/// Normalize aliases whose provider wire identity is publicly documented but
334/// intentionally absent from the offline offering catalog. Keeping this seam
335/// provider-scoped avoids claiming unverified limits or pricing while ensuring
336/// receipts and HTTP requests carry the exact upstream model id.
337fn provider_scoped_wire_alias(
338 provider_kind: ProviderKind,
339 raw: &str,
340 class: ProviderClass,
341) -> &str {
342 if class != ProviderClass::LocalOrCustom {
343 if provider_kind == ProviderKind::Together
344 && (raw.eq_ignore_ascii_case("inkling") || raw.eq_ignore_ascii_case("together-inkling"))
345 {
346 return "thinkingmachines/inkling";
347 }
348 if provider_kind == ProviderKind::Openrouter
349 && (raw.eq_ignore_ascii_case("qwen3.7-plus")
350 || raw.eq_ignore_ascii_case("qwen-3.7-plus"))
351 {
352 return "qwen/qwen3.7-plus";
353 }
354 }
355 raw
356}
357
358/// Build the default resolver offerings from the bundled Models.dev asset.
359///
360/// [`bundled_offerings`] is an empty override seam (#4139): when it later gains
361/// curated rows again, those win a `(provider, wire id)` collision over the
362/// asset. Today the asset is the sole bundled source of truth.
363fn default_offerings() -> Vec<ProviderModelOffering> {
364 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
365 let mut out = Vec::new();
366 let asset_rows = bundled_catalog_offerings()
367 .iter()
368 .map(CatalogOffering::to_offering)
369 .collect::<Vec<_>>();
370 // Seam first so it wins identity collisions, then asset-only rows follow.
371 for offering in bundled_offerings().into_iter().chain(asset_rows) {
372 let key = (
373 offering.provider.as_str().to_string(),
374 offering.wire_model_id.as_str().to_string(),
375 );
376 if seen.insert(key) {
377 out.push(offering);
378 }
379 }
380 out
381}
382
383/// The resolver's minimal route classification.
384///
385/// Intentionally narrower than tui's `validate_route`.
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387enum ProviderClass {
388 /// Strict direct provider: rejects clearly-foreign (prefixed) selectors.
389 StrictDirect,
390 /// Aggregator: serves many catalogs under prefixed wire ids.
391 Aggregator,
392 /// Local runtime or custom OpenAI-compatible endpoint: pass-through.
393 LocalOrCustom,
394}
395
396/// Classify a provider kind for resolver pass-through rules.
397///
398/// Only a SMALL set of providers are strict-direct. Everything else passes
399/// through, so the resolver stays permissive by default.
400fn classify(kind: ProviderKind) -> ProviderClass {
401 match kind {
402 // Strict first-party direct providers.
403 ProviderKind::Deepseek | ProviderKind::Zai => ProviderClass::StrictDirect,
404 // Local runtimes / custom OpenAI-compatible endpoints.
405 ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang | ProviderKind::Openai => {
406 ProviderClass::LocalOrCustom
407 }
408 // Everything else is treated as an aggregator-style pass-through.
409 _ => ProviderClass::Aggregator,
410 }
411}
412
413fn request_uses_custom_endpoint(
414 descriptor: &ProviderDescriptor,
415 base_url_override: Option<&str>,
416) -> bool {
417 base_url_override
418 .is_some_and(|base_url| provider_preserves_custom_base_url_model(descriptor.kind, base_url))
419}
420
421/// True when `base_url` is an `http://` endpoint whose host is NOT loopback
422/// (#1519). Such an endpoint sends credentials in plaintext over the network;
423/// loopback (`localhost` / `127.0.0.1` / `::1`) is exempt because local
424/// runtimes (Ollama / vLLM / SGLang) default to plain `http://localhost`.
425fn endpoint_uses_insecure_http(base_url: &str) -> bool {
426 let trimmed = base_url.trim();
427 // Scheme match is case-insensitive but must be `http`, not `https`.
428 let Some(rest) = strip_http_scheme(trimmed) else {
429 return false;
430 };
431 !is_loopback_host(host_of_authority(rest))
432}
433
434/// Strip a leading case-insensitive `http://` scheme, returning the remainder.
435/// Returns `None` for any other scheme (including `https://`) or no scheme.
436fn strip_http_scheme(base_url: &str) -> Option<&str> {
437 let idx = base_url.find("://")?;
438 let (scheme, rest) = base_url.split_at(idx);
439 if scheme.eq_ignore_ascii_case("http") {
440 Some(&rest[3..])
441 } else {
442 None
443 }
444}
445
446/// Extract the bare host from an authority+path string: take the authority up
447/// to the first `/`, drop any `user@` userinfo and `:port` suffix, and unwrap
448/// `[..]` IPv6 brackets.
449fn host_of_authority(rest: &str) -> &str {
450 let authority = rest.split('/').next().unwrap_or(rest);
451 // Drop userinfo (`user:pass@host`) if present.
452 let authority = authority.rsplit('@').next().unwrap_or(authority);
453 if let Some(inner) = authority.strip_prefix('[') {
454 // Bracketed IPv6 literal: host is everything up to the closing bracket.
455 return inner.split(']').next().unwrap_or(inner);
456 }
457 // Otherwise strip a trailing `:port`.
458 authority.split(':').next().unwrap_or(authority)
459}
460
461/// Whether `host` is an IPv4/IPv6/name loopback address.
462fn is_loopback_host(host: &str) -> bool {
463 let host = host.trim().trim_matches(|c| c == '[' || c == ']');
464 host.eq_ignore_ascii_case("localhost")
465 || host == "127.0.0.1"
466 || host == "::1"
467 // Any 127.0.0.0/8 address is loopback.
468 || host
469 .strip_prefix("127.")
470 .is_some_and(|_| host.split('.').count() == 4)
471}