| 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 | |
| 30 | use super::candidate::{ |
| 31 | LimitField, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint, |
| 32 | SourcedLimitOverride, ValidationReport, |
| 33 | }; |
| 34 | use super::capabilities::RouteCapabilities; |
| 35 | use super::descriptor::ProviderDescriptor; |
| 36 | use super::errors::RouteError; |
| 37 | use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId}; |
| 38 | use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings}; |
| 39 | use crate::catalog::{CatalogOffering, bundled_catalog_offerings}; |
| 40 | use crate::provider::WirePolicy; |
| 41 | use 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)] |
| 48 | pub 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)] |
| 66 | pub 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. |
| 72 | struct 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 | |
| 81 | impl 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 | |
| 105 | impl Default for RouteResolver { |
| 106 | fn default() -> Self { |
| 107 | Self::new() |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | impl 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 | // A model-aware protocol row is an exact provider-endpoint fact. |
| 194 | // OpenCode Zen's published roster is closed; DeepSeek's direct route |
| 195 | // deliberately preserves its existing future-model pass-through and |
| 196 | // sends unknown bare ids over Chat until an exact Responses row exists. |
| 197 | // A custom DeepSeek-compatible endpoint retains that pass-through, but |
| 198 | // a custom URL must not weaken another model-aware provider's closed |
| 199 | // protocol roster. |
| 200 | let require_catalog_match = model_aware && provider_kind != ProviderKind::Deepseek; |
| 201 | let mut selected = if is_auto { |
| 202 | match default_offering { |
| 203 | None if require_catalog_match => { |
| 204 | return Err(RouteError::UnsupportedModelProtocol { |
| 205 | provider: provider_id.clone(), |
| 206 | model: descriptor.default_wire_model().as_str().to_string(), |
| 207 | endpoint_key: "unproven".to_string(), |
| 208 | }); |
| 209 | } |
| 210 | None => ResolvedOffering::unknown(descriptor.default_wire_model()), |
| 211 | Some(offering) => ResolvedOffering::from_offering(offering), |
| 212 | } |
| 213 | } else { |
| 214 | self.scope_selector( |
| 215 | provider_kind, |
| 216 | &provider_id, |
| 217 | &logical_model, |
| 218 | class, |
| 219 | require_catalog_match, |
| 220 | )? |
| 221 | }; |
| 222 | if provider_kind == ProviderKind::Deepseek { |
| 223 | if custom_endpoint { |
| 224 | selected.endpoint_key = "chat".to_string(); |
| 225 | } else if selected.canonical_model.is_none() |
| 226 | && deepseek_versioned_model_prefers_responses(selected.wire_model_id.as_str()) |
| 227 | { |
| 228 | // DeepSeek introduced its native agent wire on V4 Flash. Keep |
| 229 | // exact catalog rows authoritative (notably V4 Pro => Chat), |
| 230 | // while allowing future versioned direct models to adopt the |
| 231 | // new Responses surface without a Codewhale release. |
| 232 | selected.endpoint_key = "responses".to_string(); |
| 233 | } |
| 234 | } |
| 235 | if custom_endpoint { |
| 236 | // A documented first-party server tool is an endpoint-owned fact. |
| 237 | // Reusing a provider enum/model id against a custom compatible |
| 238 | // endpoint cannot carry that fact across the authority boundary. |
| 239 | selected.capabilities.server_side_web_search = |
| 240 | super::capabilities::CapabilityState::Unknown; |
| 241 | } |
| 242 | |
| 243 | let protocol = descriptor |
| 244 | .protocol_for_endpoint(&selected.endpoint_key) |
| 245 | .ok_or_else(|| RouteError::UnsupportedModelProtocol { |
| 246 | provider: provider_id.clone(), |
| 247 | model: selected.wire_model_id.as_str().to_string(), |
| 248 | endpoint_key: selected.endpoint_key.clone(), |
| 249 | })?; |
| 250 | let endpoint = ResolvedEndpoint { |
| 251 | base_url: req |
| 252 | .base_url_override |
| 253 | .clone() |
| 254 | .unwrap_or_else(|| descriptor.default_base_url().to_string()), |
| 255 | endpoint_key: selected.endpoint_key, |
| 256 | protocol, |
| 257 | }; |
| 258 | |
| 259 | // Advisory validation (#1519): a non-loopback `http://` endpoint sends |
| 260 | // credentials in plaintext. This is advisory, not a hard fail, so |
| 261 | // `ok` stays true and local `http://localhost` runtimes (Ollama / vLLM / |
| 262 | // SGLang defaults) stay clean. |
| 263 | let mut messages = Vec::new(); |
| 264 | if endpoint_uses_insecure_http(&endpoint.base_url) { |
| 265 | messages |
| 266 | .push("endpoint uses insecure http:// (credentials sent in plaintext)".to_string()); |
| 267 | } |
| 268 | let validation = ValidationReport { ok: true, messages }; |
| 269 | |
| 270 | // Apply caller-requested limit overrides in order, BEFORE the candidate |
| 271 | // is minted. The candidate is immutable afterwards; the applied |
| 272 | // overrides are recorded on it as provenance. |
| 273 | let mut limits = selected.limits; |
| 274 | for limit_override in &req.limit_overrides { |
| 275 | match limit_override.field { |
| 276 | LimitField::ContextTokens => limits.context_tokens = limit_override.value, |
| 277 | LimitField::InputTokens => limits.input_tokens = limit_override.value, |
| 278 | LimitField::OutputTokens => limits.output_tokens = limit_override.value, |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | Ok(ReadyRouteCandidate::new( |
| 283 | provider_id, |
| 284 | provider_kind, |
| 285 | logical_model, |
| 286 | selected.canonical_model, |
| 287 | selected.wire_model_id, |
| 288 | endpoint, |
| 289 | // The resolver never inspects credentials: auth is honestly |
| 290 | // `Unresolved` at resolution time, not a claimed `Missing`. |
| 291 | ResolvedAuthSource::Unresolved, |
| 292 | protocol, |
| 293 | limits, |
| 294 | selected.capabilities, |
| 295 | // #3085: honest pricing projected from the matched offering (the |
| 296 | // catalog layer maps sourced cost → SKU); `UnknownOrStale` whenever |
| 297 | // no offering was matched or the offering carried no price. |
| 298 | Some(selected.pricing), |
| 299 | validation, |
| 300 | req.limit_overrides.clone(), |
| 301 | )) |
| 302 | } |
| 303 | |
| 304 | /// Interpret a concrete (non-auto) selector strictly within provider scope. |
| 305 | fn scope_selector( |
| 306 | &self, |
| 307 | provider_kind: ProviderKind, |
| 308 | provider_id: &ProviderId, |
| 309 | logical_model: &LogicalModelRef, |
| 310 | class: ProviderClass, |
| 311 | require_catalog_match: bool, |
| 312 | ) -> Result<ResolvedOffering, RouteError> { |
| 313 | // OpenCode Go publishes one combined model roster across two wire |
| 314 | // protocols. Codewhale's provider is deliberately Chat Completions |
| 315 | // only, so this allowlist must sit at the sole route-candidate seam. |
| 316 | // In particular, a custom base URL must not reopen generic |
| 317 | // LocalOrCustom pass-through for Messages-only model ids. |
| 318 | let raw = if provider_kind == ProviderKind::OpencodeGo { |
| 319 | opencode_go_chat_model_id(logical_model.raw()).ok_or_else(|| { |
| 320 | RouteError::ForeignModelForDirectProvider { |
| 321 | provider: provider_id.clone(), |
| 322 | model: logical_model.raw().to_string(), |
| 323 | } |
| 324 | })? |
| 325 | } else if provider_kind == ProviderKind::OpencodeZen { |
| 326 | logical_model |
| 327 | .raw() |
| 328 | .strip_prefix("opencode/") |
| 329 | .or_else(|| logical_model.raw().strip_prefix("opencode-zen/")) |
| 330 | .unwrap_or_else(|| logical_model.raw()) |
| 331 | } else { |
| 332 | provider_scoped_wire_alias(provider_kind, logical_model.raw(), class) |
| 333 | }; |
| 334 | |
| 335 | // Try to match a catalog offering owned by THIS provider, either by |
| 336 | // canonical model id or by exact wire id. This keeps interpretation |
| 337 | // inside provider scope; offerings from other providers are ignored. |
| 338 | for offering in &self.offerings { |
| 339 | if offering.provider != *provider_id { |
| 340 | continue; |
| 341 | } |
| 342 | let matches_canonical = offering |
| 343 | .canonical_model |
| 344 | .as_ref() |
| 345 | .is_some_and(|m| m.as_str() == raw); |
| 346 | let matches_wire = offering.wire_model_id.as_str() == raw; |
| 347 | if matches_canonical || matches_wire { |
| 348 | return Ok(ResolvedOffering::from_offering(offering)); |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // No catalog match. Apply class-specific pass-through rules. |
| 353 | match class { |
| 354 | ProviderClass::StrictDirect => { |
| 355 | if self.selector_matches_other_provider_offering(provider_id, raw) { |
| 356 | return Err(RouteError::ForeignModelForDirectProvider { |
| 357 | provider: provider_id.clone(), |
| 358 | model: raw.to_string(), |
| 359 | }); |
| 360 | } |
| 361 | // A clearly-foreign selector for a strict direct provider is |
| 362 | // rejected. "Clearly foreign" = it carries an aggregator/org |
| 363 | // namespace prefix, which a direct provider never expects. |
| 364 | if logical_model.namespace_hint().is_some() { |
| 365 | return Err(RouteError::ForeignModelForDirectProvider { |
| 366 | provider: provider_id.clone(), |
| 367 | model: raw.to_string(), |
| 368 | }); |
| 369 | } |
| 370 | if require_catalog_match { |
| 371 | return Err(RouteError::UnsupportedModelProtocol { |
| 372 | provider: provider_id.clone(), |
| 373 | model: raw.to_string(), |
| 374 | endpoint_key: "unproven".to_string(), |
| 375 | }); |
| 376 | } |
| 377 | // A bare, unknown model on a strict direct provider is passed |
| 378 | // through verbatim (the provider validates it server-side). No |
| 379 | // offering matched, so pricing is honestly unknown (#3085). |
| 380 | Ok(ResolvedOffering::unknown(WireModelId::from(raw))) |
| 381 | } |
| 382 | // Aggregators, local runtimes, and custom OpenAI-compatible |
| 383 | // endpoints legitimately accept arbitrary / prefixed ids verbatim. |
| 384 | ProviderClass::Aggregator | ProviderClass::LocalOrCustom => { |
| 385 | let _ = provider_kind; |
| 386 | if require_catalog_match { |
| 387 | return Err(RouteError::UnsupportedModelProtocol { |
| 388 | provider: provider_id.clone(), |
| 389 | model: raw.to_string(), |
| 390 | endpoint_key: "unproven".to_string(), |
| 391 | }); |
| 392 | } |
| 393 | // No offering matched: pricing is honestly unknown (#3085). |
| 394 | Ok(ResolvedOffering::unknown(WireModelId::from(raw))) |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | fn default_offering(&self, provider_id: &ProviderId) -> Option<&ProviderModelOffering> { |
| 400 | self.offerings |
| 401 | .iter() |
| 402 | .find(|offering| offering.provider == *provider_id && offering.default_for_provider) |
| 403 | } |
| 404 | |
| 405 | /// True when `raw` names an offering that lives on a *different* provider. |
| 406 | /// |
| 407 | /// The `wire_model_id` arm catches the common case (a bare id another |
| 408 | /// provider serves). The `canonical_model` arm covers catalog rows whose |
| 409 | /// canonical id is slash-free: Models.dev canonical ids normally contain a |
| 410 | /// namespace (`zhipuai/glm-5.2`) and are already caught by the |
| 411 | /// `namespace_hint()` guard at the call site, but a bare canonical id (or a |
| 412 | /// hand-authored offering) would slip through wire-id matching alone. It is |
| 413 | /// kept deliberately so a bare canonical selector cannot masquerade as a |
| 414 | /// pass-through model on the wrong provider. |
| 415 | fn selector_matches_other_provider_offering( |
| 416 | &self, |
| 417 | provider_id: &ProviderId, |
| 418 | raw: &str, |
| 419 | ) -> bool { |
| 420 | self.offerings.iter().any(|offering| { |
| 421 | offering.provider != *provider_id |
| 422 | && (offering.wire_model_id.as_str() == raw |
| 423 | || offering |
| 424 | .canonical_model |
| 425 | .as_ref() |
| 426 | .is_some_and(|model| model.as_str() == raw)) |
| 427 | }) |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | /// Normalize aliases whose provider wire identity is publicly documented but |
| 432 | /// intentionally absent from the offline offering catalog. Keeping this seam |
| 433 | /// provider-scoped avoids claiming unverified limits or pricing while ensuring |
| 434 | /// receipts and HTTP requests carry the exact upstream model id. |
| 435 | fn provider_scoped_wire_alias( |
| 436 | provider_kind: ProviderKind, |
| 437 | raw: &str, |
| 438 | class: ProviderClass, |
| 439 | ) -> &str { |
| 440 | if class != ProviderClass::LocalOrCustom { |
| 441 | if provider_kind == ProviderKind::Together |
| 442 | && (raw.eq_ignore_ascii_case("inkling") || raw.eq_ignore_ascii_case("together-inkling")) |
| 443 | { |
| 444 | return "thinkingmachines/inkling"; |
| 445 | } |
| 446 | if provider_kind == ProviderKind::Openrouter |
| 447 | && (raw.eq_ignore_ascii_case("qwen3.7-plus") |
| 448 | || raw.eq_ignore_ascii_case("qwen-3.7-plus")) |
| 449 | { |
| 450 | return "qwen/qwen3.7-plus"; |
| 451 | } |
| 452 | } |
| 453 | raw |
| 454 | } |
| 455 | |
| 456 | /// Build the default resolver offerings from the bundled Models.dev asset. |
| 457 | /// |
| 458 | /// Curated transport rows win a `(provider, wire id)` collision over the asset; |
| 459 | /// all other offerings continue to come from Models.dev. |
| 460 | fn default_offerings() -> Vec<ProviderModelOffering> { |
| 461 | let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); |
| 462 | let mut out = Vec::new(); |
| 463 | let asset_rows = bundled_catalog_offerings() |
| 464 | .iter() |
| 465 | .map(CatalogOffering::to_offering) |
| 466 | .collect::<Vec<_>>(); |
| 467 | // Seam first so it wins identity collisions, then asset-only rows follow. |
| 468 | for offering in bundled_offerings().into_iter().chain(asset_rows) { |
| 469 | let key = ( |
| 470 | offering.provider.as_str().to_string(), |
| 471 | offering.wire_model_id.as_str().to_string(), |
| 472 | ); |
| 473 | if seen.insert(key) { |
| 474 | out.push(offering); |
| 475 | } |
| 476 | } |
| 477 | out |
| 478 | } |
| 479 | |
| 480 | /// The resolver's minimal route classification. |
| 481 | /// |
| 482 | /// Intentionally narrower than tui's `validate_route`. |
| 483 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 484 | enum ProviderClass { |
| 485 | /// Strict direct provider: rejects clearly-foreign (prefixed) selectors. |
| 486 | StrictDirect, |
| 487 | /// Aggregator: serves many catalogs under prefixed wire ids. |
| 488 | Aggregator, |
| 489 | /// Local runtime or custom OpenAI-compatible endpoint: pass-through. |
| 490 | LocalOrCustom, |
| 491 | } |
| 492 | |
| 493 | /// Classify a provider kind for resolver pass-through rules. |
| 494 | /// |
| 495 | /// Only a SMALL set of providers are strict-direct. Everything else passes |
| 496 | /// through, so the resolver stays permissive by default. |
| 497 | fn classify(kind: ProviderKind) -> ProviderClass { |
| 498 | match kind { |
| 499 | // Strict first-party direct providers. |
| 500 | ProviderKind::Deepseek | ProviderKind::Zai => ProviderClass::StrictDirect, |
| 501 | // Local runtimes / custom OpenAI-compatible endpoints. |
| 502 | ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang | ProviderKind::Openai => { |
| 503 | ProviderClass::LocalOrCustom |
| 504 | } |
| 505 | // Everything else is treated as an aggregator-style pass-through. |
| 506 | _ => ProviderClass::Aggregator, |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | fn request_uses_custom_endpoint( |
| 511 | descriptor: &ProviderDescriptor, |
| 512 | base_url_override: Option<&str>, |
| 513 | ) -> bool { |
| 514 | base_url_override |
| 515 | .is_some_and(|base_url| provider_preserves_custom_base_url_model(descriptor.kind, base_url)) |
| 516 | } |
| 517 | |
| 518 | fn deepseek_versioned_model_prefers_responses(model: &str) -> bool { |
| 519 | model |
| 520 | .trim() |
| 521 | .to_ascii_lowercase() |
| 522 | .strip_prefix("deepseek-v") |
| 523 | .and_then(|suffix| suffix.chars().next()) |
| 524 | .is_some_and(|first| first.is_ascii_digit()) |
| 525 | } |
| 526 | |
| 527 | /// True when `base_url` is an `http://` endpoint whose host is NOT loopback |
| 528 | /// (#1519). Such an endpoint sends credentials in plaintext over the network; |
| 529 | /// loopback (`localhost` / `127.0.0.1` / `::1`) is exempt because local |
| 530 | /// runtimes (Ollama / vLLM / SGLang) default to plain `http://localhost`. |
| 531 | fn endpoint_uses_insecure_http(base_url: &str) -> bool { |
| 532 | let trimmed = base_url.trim(); |
| 533 | // Scheme match is case-insensitive but must be `http`, not `https`. |
| 534 | let Some(rest) = strip_http_scheme(trimmed) else { |
| 535 | return false; |
| 536 | }; |
| 537 | !is_loopback_host(host_of_authority(rest)) |
| 538 | } |
| 539 | |
| 540 | /// Strip a leading case-insensitive `http://` scheme, returning the remainder. |
| 541 | /// Returns `None` for any other scheme (including `https://`) or no scheme. |
| 542 | fn strip_http_scheme(base_url: &str) -> Option<&str> { |
| 543 | let idx = base_url.find("://")?; |
| 544 | let (scheme, rest) = base_url.split_at(idx); |
| 545 | if scheme.eq_ignore_ascii_case("http") { |
| 546 | Some(&rest[3..]) |
| 547 | } else { |
| 548 | None |
| 549 | } |
| 550 | } |
| 551 | |
| 552 | /// Extract the bare host from an authority+path string: take the authority up |
| 553 | /// to the first `/`, drop any `user@` userinfo and `:port` suffix, and unwrap |
| 554 | /// `[..]` IPv6 brackets. |
| 555 | fn host_of_authority(rest: &str) -> &str { |
| 556 | let authority = rest.split('/').next().unwrap_or(rest); |
| 557 | // Drop userinfo (`user:pass@host`) if present. |
| 558 | let authority = authority.rsplit('@').next().unwrap_or(authority); |
| 559 | if let Some(inner) = authority.strip_prefix('[') { |
| 560 | // Bracketed IPv6 literal: host is everything up to the closing bracket. |
| 561 | return inner.split(']').next().unwrap_or(inner); |
| 562 | } |
| 563 | // Otherwise strip a trailing `:port`. |
| 564 | authority.split(':').next().unwrap_or(authority) |
| 565 | } |
| 566 | |
| 567 | /// Whether `host` is an IPv4/IPv6/name loopback address. |
| 568 | fn is_loopback_host(host: &str) -> bool { |
| 569 | let host = host.trim().trim_matches(|c| c == '[' || c == ']'); |
| 570 | if host.eq_ignore_ascii_case("localhost") { |
| 571 | return true; |
| 572 | } |
| 573 | // Parse real addresses rather than pattern-matching a `127.` prefix: the |
| 574 | // old `strip_prefix("127.") && 4 dot-parts` check classified |
| 575 | // `127.evil.example.com` as loopback (2026-08-04 review), which would let |
| 576 | // a hostile hostname inherit local-trust routing. `Ipv4Addr::is_loopback` |
| 577 | // is exactly the 127.0.0.0/8 block; `Ipv6Addr::is_loopback` is `::1`. |
| 578 | if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() { |
| 579 | return v4.is_loopback(); |
| 580 | } |
| 581 | if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() { |
| 582 | return v6.is_loopback(); |
| 583 | } |
| 584 | false |
| 585 | } |
| 586 | |
| 587 | #[cfg(test)] |
| 588 | mod loopback_tests { |
| 589 | use super::{endpoint_uses_insecure_http, is_loopback_host}; |
| 590 | |
| 591 | #[test] |
| 592 | fn loopback_matches_only_real_loopback_addresses() { |
| 593 | assert!(is_loopback_host("localhost")); |
| 594 | assert!(is_loopback_host("LocalHost")); |
| 595 | assert!(is_loopback_host("127.0.0.1")); |
| 596 | assert!(is_loopback_host("127.1.2.3")); // all of 127.0.0.0/8 |
| 597 | assert!(is_loopback_host("::1")); |
| 598 | assert!(is_loopback_host("[::1]")); |
| 599 | |
| 600 | // The 2026-08-04 regression: a hostile hostname that merely starts |
| 601 | // with `127.` and has four dot-parts must NOT be trusted as local. |
| 602 | assert!(!is_loopback_host("127.evil.example.com")); |
| 603 | assert!(!is_loopback_host("127.0.0.1.evil.com")); |
| 604 | assert!(!is_loopback_host("notlocalhost")); |
| 605 | assert!(!is_loopback_host("10.0.0.1")); |
| 606 | assert!(!is_loopback_host("localhost.evil.com")); |
| 607 | } |
| 608 | |
| 609 | #[test] |
| 610 | fn insecure_http_flags_a_hostile_127_lookalike() { |
| 611 | // loopback stays exempt (local runtimes use plain http) |
| 612 | assert!(!endpoint_uses_insecure_http("http://127.0.0.1:11434/v1")); |
| 613 | assert!(!endpoint_uses_insecure_http("http://localhost:8000/v1")); |
| 614 | // a real remote host dressed up as 127.* is insecure http |
| 615 | assert!(endpoint_uses_insecure_http( |
| 616 | "http://127.evil.example.com/v1" |
| 617 | )); |
| 618 | } |
| 619 | } |
| 620 |