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}