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        // 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.
435fn 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.
460fn 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)]
484enum 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.
497fn 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
510fn 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
518fn 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`.
531fn 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.
542fn 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.
555fn 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.
568fn 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)]
588mod 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}