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