Skip to main content

subx_cli/services/ai/
hosted_hint.rs

1//! Hint-emission helpers shared by the hosted AI provider clients.
2//!
3//! When a hosted provider (`openai`, `openrouter`, `azure-openai`) fails in a
4//! pattern that strongly suggests the user actually meant to call an
5//! OpenAI-compatible local / LAN endpoint, the hosted client appends the
6//! canonical advisory string from
7//! [`crate::services::ai::security::local_provider_hint`] to the resulting
8//! [`crate::error::SubXError::AiService`] message.
9//!
10//! This module centralises the predicates and the appender so every hosted
11//! client emits the hint identically.
12
13use url::Url;
14
15use crate::services::ai::error_sanitizer::sanitize_url_in_error;
16use crate::services::ai::security::local_provider_hint;
17
18/// Return `true` when `url` points at a host that is unambiguously private
19/// (loopback, RFC1918, RFC4193, link-local) or a hostname commonly used to
20/// refer to a local / LAN endpoint (`localhost`, `*.local`, `*.lan`,
21/// `*.internal`, `*.localdomain`).
22///
23/// When the URL has no host (e.g. `data:` or `file:` schemes) the function
24/// returns `false`. Hostnames that resolve dynamically are NOT looked up;
25/// the predicate inspects only the syntactic form of the URL.
26pub(crate) fn is_private_host(url: &Url) -> bool {
27    let Some(host) = url.host_str() else {
28        return false;
29    };
30    is_private_host_str(host)
31}
32
33/// Same predicate as [`is_private_host`] but operating on a raw host string.
34/// Exposed so connection-error paths that have only the configured URL (and
35/// not a parsed `Url`) can still classify the host.
36pub(crate) fn is_private_host_str(host: &str) -> bool {
37    // IPv6 literals in URLs are wrapped in brackets; `host_str()` already
38    // strips them, but we guard against callers passing the bracketed form.
39    let host = host.trim().trim_start_matches('[').trim_end_matches(']');
40
41    // Try IPv4 first.
42    if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
43        return is_private_ipv4(v4);
44    }
45    // Then IPv6.
46    if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
47        return is_private_ipv6(v6);
48    }
49
50    // Hostname syntactic checks.
51    let lower = host.to_ascii_lowercase();
52    if lower == "localhost" {
53        return true;
54    }
55    // Common conventions for non-public TLDs / suffixes used on LANs.
56    for suffix in [".local", ".lan", ".internal", ".localdomain", ".home.arpa"] {
57        if lower.ends_with(suffix) {
58            return true;
59        }
60    }
61    false
62}
63
64fn is_private_ipv4(addr: std::net::Ipv4Addr) -> bool {
65    // 127.0.0.0/8 — loopback
66    if addr.is_loopback() {
67        return true;
68    }
69    // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 — RFC1918
70    if addr.is_private() {
71        return true;
72    }
73    // 169.254.0.0/16 — link-local
74    if addr.is_link_local() {
75        return true;
76    }
77    false
78}
79
80fn is_private_ipv6(addr: std::net::Ipv6Addr) -> bool {
81    // ::1 — loopback
82    if addr.is_loopback() {
83        return true;
84    }
85    let segments = addr.segments();
86    // fc00::/7 — unique local addresses (RFC4193). The high 7 bits of the
87    // first segment must be 1111110, i.e. the segment lies in [0xfc00, 0xfdff].
88    if (segments[0] & 0xfe00) == 0xfc00 {
89        return true;
90    }
91    // fe80::/10 — link-local. High 10 bits 1111111010, segment in
92    // [0xfe80, 0xfebf].
93    if (segments[0] & 0xffc0) == 0xfe80 {
94        return true;
95    }
96    false
97}
98
99/// Predicate for the *connection refused / DNS failure to a private host*
100/// branch of the *Hosted Provider Errors Hint Toward Local Provider*
101/// requirement.
102///
103/// Returns `true` when:
104///
105/// 1. `err` is a connect-time failure (`reqwest::Error::is_connect()`) **or**
106///    a request error against an unresolved hostname, and
107/// 2. the configured base URL parses and points at a private host
108///    (per [`is_private_host`]).
109///
110/// The function deliberately favours false-negatives over false-positives:
111/// when in doubt (unparseable URL, public host, or non-transport error) it
112/// returns `false` so the hint is not emitted spuriously for genuine
113/// upstream failures.
114pub(crate) fn should_hint_for_transport(err: &reqwest::Error, configured_url: &str) -> bool {
115    // Only transport-layer failures qualify. Status-coded responses (4xx /
116    // 5xx) come back as `Ok(Response)` — they never reach this predicate.
117    if !(err.is_connect() || err.is_request() || err.is_timeout()) {
118        return false;
119    }
120    let Ok(url) = Url::parse(configured_url) else {
121        return false;
122    };
123    is_private_host(&url)
124}
125
126/// Predicate for the *HTTP 200 with non-OpenAI-canonical body* branch.
127///
128/// Returns `true` when the response body parsed as JSON (`body_was_json`)
129/// but the canonical `choices[0].message.content` field is missing — i.e.
130/// the upstream is reachable and speaking JSON but does not implement the
131/// OpenAI chat-completions schema. In that situation the user almost
132/// certainly pointed a hosted provider at a non-OpenAI endpoint.
133///
134/// `parse_error_msg` is accepted for symmetry with future predicates (e.g.
135/// distinguishing different `serde_json` error kinds) but is not currently
136/// inspected.
137pub(crate) fn should_hint_for_parse(body_was_json: bool, _parse_error_msg: &str) -> bool {
138    body_was_json
139}
140
141/// Append the canonical local-provider hint to `message`, first running the
142/// message through [`sanitize_url_in_error`] so any URLs embedded by
143/// `reqwest` or upstream bodies have their query strings stripped before
144/// the hint is concatenated.
145pub(crate) fn append_local_hint(message: &str) -> String {
146    let sanitized = sanitize_url_in_error(message);
147    format!("{}\n{}", sanitized, local_provider_hint())
148}
149
150/// Wrap an existing [`crate::error::SubXError`] with the local-provider hint
151/// **iff** the configured base URL points at a private host (per
152/// [`is_private_host_str`]) and the error is an [`crate::error::SubXError::AiService`]
153/// variant.
154///
155/// This is the single decision point used by hosted-provider clients
156/// (`OpenAIClient`, `OpenRouterClient`, `AzureOpenAIClient`) when wrapping a
157/// failure surfaced by the shared retry machinery (which converts
158/// `reqwest::Error` into [`crate::error::SubXError`] before the caller can
159/// inspect the original transport-layer kind).
160///
161/// The predicate intentionally classifies on the **configured URL** rather
162/// than the post-conversion error string: when a hosted provider is pointed
163/// at a private host, *every* failure mode (connect refused, DNS failure,
164/// timeout, even an unexpected HTTP status from a non-OpenAI server
165/// listening on that port) implies the same misconfiguration. Conversely
166/// when the configured host is public, no transport failure should be
167/// attributed to a "did you mean local?" mistake — preserving the negative
168/// scenario in the *Hosted Provider Errors Hint Toward Local Provider*
169/// requirement (e.g. HTTP 401 from `https://api.openai.com/v1`).
170pub(crate) fn maybe_attach_local_hint(
171    err: crate::error::SubXError,
172    configured_url: &str,
173) -> crate::error::SubXError {
174    use crate::error::SubXError;
175    let Ok(url) = Url::parse(configured_url) else {
176        return err;
177    };
178    if !is_private_host(&url) {
179        return err;
180    }
181    match err {
182        SubXError::AiService(msg) => SubXError::AiService(append_local_hint(&msg)),
183        other => other,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn url(s: &str) -> Url {
192        Url::parse(s).unwrap()
193    }
194
195    #[test]
196    fn is_private_host_loopback_v4() {
197        assert!(is_private_host(&url("http://127.0.0.1:8080/v1")));
198        assert!(is_private_host(&url("http://127.255.255.254/v1")));
199    }
200
201    #[test]
202    fn is_private_host_loopback_v6() {
203        assert!(is_private_host(&url("http://[::1]:8080/v1")));
204    }
205
206    #[test]
207    fn is_private_host_rfc1918() {
208        assert!(is_private_host(&url("http://10.0.0.5:11434/v1")));
209        assert!(is_private_host(&url("http://172.16.0.1/v1")));
210        assert!(is_private_host(&url("http://172.31.255.255/v1")));
211        assert!(is_private_host(&url("http://192.168.0.1/v1")));
212        assert!(is_private_host(&url("http://192.168.255.255/v1")));
213    }
214
215    #[test]
216    fn is_private_host_link_local() {
217        assert!(is_private_host(&url("http://169.254.1.1/v1")));
218        assert!(is_private_host(&url("http://[fe80::1]/v1")));
219        assert!(is_private_host(&url("http://[febf::1]/v1")));
220    }
221
222    #[test]
223    fn is_private_host_rfc4193() {
224        assert!(is_private_host(&url("http://[fc00::1]/v1")));
225        assert!(is_private_host(&url("http://[fdff::1]/v1")));
226    }
227
228    #[test]
229    fn is_private_host_hostname_aliases() {
230        assert!(is_private_host(&url("http://localhost:11434/v1")));
231        assert!(is_private_host(&url("http://my-box.local/v1")));
232        assert!(is_private_host(&url("http://server.lan/v1")));
233        assert!(is_private_host(&url("http://gpu.internal/v1")));
234        assert!(is_private_host(&url("http://x.localdomain/v1")));
235    }
236
237    #[test]
238    fn is_private_host_public_addresses_negative() {
239        assert!(!is_private_host(&url("https://api.openai.com/v1")));
240        assert!(!is_private_host(&url("https://1.1.1.1/v1")));
241        assert!(!is_private_host(&url("https://8.8.8.8/v1")));
242        assert!(!is_private_host(&url("https://172.32.0.1/v1"))); // outside /12
243        assert!(!is_private_host(&url("https://192.169.0.1/v1"))); // outside /16
244        assert!(!is_private_host(&url("https://[2001:4860:4860::8888]/v1")));
245    }
246
247    #[test]
248    fn is_private_host_str_handles_bracketed_v6() {
249        assert!(is_private_host_str("[::1]"));
250        assert!(is_private_host_str("::1"));
251    }
252
253    #[test]
254    fn should_hint_for_parse_only_when_body_was_json() {
255        assert!(should_hint_for_parse(true, "missing field"));
256        assert!(!should_hint_for_parse(false, "expected value"));
257    }
258
259    #[test]
260    fn append_local_hint_appends_full_advisory_and_strips_query() {
261        let appended = append_local_hint("oops at https://x.test/a?token=secret");
262        // The sanitizer drops the query string.
263        assert!(!appended.contains("token=secret"));
264        // Original message preserved up to the URL boundary.
265        assert!(appended.contains("oops at https://x.test/a"));
266        // Canonical hint appears after a newline.
267        assert!(
268            appended.contains("ai.provider"),
269            "missing canonical hint: {appended}"
270        );
271        assert!(appended.contains("ollama"));
272        assert!(appended.contains('\n'));
273    }
274
275    #[test]
276    fn append_local_hint_uses_canonical_helper() {
277        // Sanity: the appended tail must equal the canonical helper output.
278        let appended = append_local_hint("x");
279        assert!(appended.ends_with(local_provider_hint()));
280    }
281
282    // Note: `should_hint_for_transport` is exercised indirectly through the
283    // hosted-client integration tests in `openai.rs`, `openrouter.rs`, and
284    // `azure_openai.rs`, where genuine `reqwest::Error` values are produced
285    // by attempting connections to ports without a listener. Constructing a
286    // `reqwest::Error` directly requires private constructors, so the
287    // predicate is best validated end-to-end.
288
289    #[test]
290    fn maybe_attach_local_hint_appends_when_url_private() {
291        use crate::error::SubXError;
292        let err = SubXError::AiService("connection refused".to_string());
293        let wrapped = maybe_attach_local_hint(err, "http://127.0.0.1:11434/v1");
294        let msg = wrapped.to_string();
295        assert!(msg.contains("connection refused"));
296        assert!(msg.contains("ollama"), "missing canonical hint: {msg}");
297        assert!(msg.contains("ai.provider"));
298    }
299
300    #[test]
301    fn maybe_attach_local_hint_skips_when_url_public() {
302        use crate::error::SubXError;
303        let err = SubXError::AiService("HTTP 401".to_string());
304        let wrapped = maybe_attach_local_hint(err, "https://api.openai.com/v1");
305        let msg = wrapped.to_string();
306        assert!(msg.contains("HTTP 401"));
307        assert!(
308            !msg.contains("ollama"),
309            "hint must not be emitted for public hosts: {msg}"
310        );
311    }
312
313    #[test]
314    fn maybe_attach_local_hint_skips_when_url_unparseable() {
315        use crate::error::SubXError;
316        let err = SubXError::AiService("boom".to_string());
317        let wrapped = maybe_attach_local_hint(err, "not a url");
318        assert!(!wrapped.to_string().contains("ollama"));
319    }
320
321    #[test]
322    fn maybe_attach_local_hint_passthrough_for_non_ai_service_errors() {
323        use crate::error::SubXError;
324        let err = SubXError::config("bad");
325        let wrapped = maybe_attach_local_hint(err, "http://127.0.0.1/v1");
326        // Non-AiService variants are returned untouched.
327        assert!(matches!(wrapped, SubXError::Config { .. }));
328    }
329}