Skip to main content

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::ProviderKind;
38use crate::catalog::{CatalogOffering, bundled_catalog_offerings};
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 = logical_model.raw();
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/// Build the default resolver offerings: the bundled Models.dev asset rows
334/// merged under the hand seam, with the seam winning a `(provider, wire id)`
335/// collision.
336///
337/// The seam is appended *after* the asset rows and de-duplicated keeping the
338/// first-seen row per identity, so seam rows (which carry the curated canonical
339/// joins and the deliberately unpriced DeepSeek-native entries the route
340/// invariants assert) shadow any asset row with the same `(provider, wire id)`.
341/// Order is otherwise preserved for deterministic resolution.
342fn default_offerings() -> Vec<ProviderModelOffering> {
343    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
344    let mut out = Vec::new();
345    let asset_rows = bundled_catalog_offerings()
346        .iter()
347        .map(CatalogOffering::to_offering)
348        .collect::<Vec<_>>();
349    // Seam first so it wins identity collisions, then asset-only rows follow.
350    for offering in bundled_offerings().into_iter().chain(asset_rows) {
351        let key = (
352            offering.provider.as_str().to_string(),
353            offering.wire_model_id.as_str().to_string(),
354        );
355        if seen.insert(key) {
356            out.push(offering);
357        }
358    }
359    out
360}
361
362/// The resolver's minimal route classification.
363///
364/// Intentionally narrower than tui's `validate_route`.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366enum ProviderClass {
367    /// Strict direct provider: rejects clearly-foreign (prefixed) selectors.
368    StrictDirect,
369    /// Aggregator: serves many catalogs under prefixed wire ids.
370    Aggregator,
371    /// Local runtime or custom OpenAI-compatible endpoint: pass-through.
372    LocalOrCustom,
373}
374
375/// Classify a provider kind for resolver pass-through rules.
376///
377/// Only a SMALL set of providers are strict-direct. Everything else passes
378/// through, so the resolver stays permissive by default.
379fn classify(kind: ProviderKind) -> ProviderClass {
380    match kind {
381        // Strict first-party direct providers.
382        ProviderKind::Deepseek | ProviderKind::Zai => ProviderClass::StrictDirect,
383        // Local runtimes / custom OpenAI-compatible endpoints.
384        ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang | ProviderKind::Openai => {
385            ProviderClass::LocalOrCustom
386        }
387        // Everything else is treated as an aggregator-style pass-through.
388        _ => ProviderClass::Aggregator,
389    }
390}
391
392fn request_uses_custom_endpoint(
393    descriptor: &ProviderDescriptor,
394    base_url_override: Option<&str>,
395) -> bool {
396    base_url_override.is_some_and(|base_url| {
397        normalize_route_base_url(base_url)
398            != normalize_route_base_url(descriptor.default_base_url())
399    })
400}
401
402fn normalize_route_base_url(base_url: &str) -> String {
403    let trimmed = base_url.trim().trim_end_matches('/');
404    let deepseek_domains = ["api.deepseek.com", "api.deepseeki.com"];
405    if deepseek_domains
406        .iter()
407        .any(|domain| trimmed.to_ascii_lowercase().contains(domain))
408    {
409        return trimmed.trim_end_matches("/v1").to_string();
410    }
411    if let Some(idx) = trimmed.find("://") {
412        let (scheme, rest) = trimmed.split_at(idx);
413        let scheme = scheme.to_ascii_lowercase();
414        let rest = &rest[3..];
415        let (authority, path) = match rest.find('/') {
416            Some(p) => (&rest[..p], &rest[p..]),
417            None => (rest, ""),
418        };
419        return format!("{scheme}://{}{path}", authority.to_ascii_lowercase());
420    }
421    trimmed.to_ascii_lowercase()
422}
423
424/// True when `base_url` is an `http://` endpoint whose host is NOT loopback
425/// (#1519). Such an endpoint sends credentials in plaintext over the network;
426/// loopback (`localhost` / `127.0.0.1` / `::1`) is exempt because local
427/// runtimes (Ollama / vLLM / SGLang) default to plain `http://localhost`.
428fn endpoint_uses_insecure_http(base_url: &str) -> bool {
429    let trimmed = base_url.trim();
430    // Scheme match is case-insensitive but must be `http`, not `https`.
431    let Some(rest) = strip_http_scheme(trimmed) else {
432        return false;
433    };
434    !is_loopback_host(host_of_authority(rest))
435}
436
437/// Strip a leading case-insensitive `http://` scheme, returning the remainder.
438/// Returns `None` for any other scheme (including `https://`) or no scheme.
439fn strip_http_scheme(base_url: &str) -> Option<&str> {
440    let idx = base_url.find("://")?;
441    let (scheme, rest) = base_url.split_at(idx);
442    if scheme.eq_ignore_ascii_case("http") {
443        Some(&rest[3..])
444    } else {
445        None
446    }
447}
448
449/// Extract the bare host from an authority+path string: take the authority up
450/// to the first `/`, drop any `user@` userinfo and `:port` suffix, and unwrap
451/// `[..]` IPv6 brackets.
452fn host_of_authority(rest: &str) -> &str {
453    let authority = rest.split('/').next().unwrap_or(rest);
454    // Drop userinfo (`user:pass@host`) if present.
455    let authority = authority.rsplit('@').next().unwrap_or(authority);
456    if let Some(inner) = authority.strip_prefix('[') {
457        // Bracketed IPv6 literal: host is everything up to the closing bracket.
458        return inner.split(']').next().unwrap_or(inner);
459    }
460    // Otherwise strip a trailing `:port`.
461    authority.split(':').next().unwrap_or(authority)
462}
463
464/// Whether `host` is an IPv4/IPv6/name loopback address.
465fn is_loopback_host(host: &str) -> bool {
466    let host = host.trim().trim_matches(|c| c == '[' || c == ']');
467    host.eq_ignore_ascii_case("localhost")
468        || host == "127.0.0.1"
469        || host == "::1"
470        // Any 127.0.0.0/8 address is loopback.
471        || host
472            .strip_prefix("127.")
473            .is_some_and(|_| host.split('.').count() == 4)
474}