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}