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