Skip to main content

agentd/intel/
client.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Intelligence client — endpoint *list* selection plus one round-trip.
3//!
4//! The transport is **HTTPS**: each `AGENTD_INTELLIGENCE` list element is an
5//! `https://` URL, and plaintext `http://` is admitted only for a loopback host
6//! as a dev carve-out. The wire is HTTP/1.1, with the gateway or provider
7//! speaking an OpenAI-compatible `/chat/completions`. Each request opens its own
8//! connection and sends `Connection: close`: model calls are seconds apart and
9//! minutes long, so a connection pool would buy nothing and cost a whole class
10//! of stale-socket failures.
11//!
12//! `--intelligence` is an ordered list — a primary plus fallbacks — and
13//! `complete()` drives it through the sticky-primary failover policy
14//! ([`super::failover`]) with a per-endpoint health record and circuit breaker
15//! ([`super::health`]). Selection is the only layer the list adds: the
16//! wire/adapter/JSON path underneath is identical either way, and with a single
17//! endpoint the failover machinery is inert, so one endpoint costs nothing.
18
19use crate::net::http::{Stream, Url};
20use crate::wire::intel::{Request, Response};
21use std::cell::RefCell;
22use std::fmt;
23use std::time::Duration;
24
25use super::endpoints::EndpointList;
26use super::{anthropic, bedrock, failover, openai};
27
28/// Which in-binary adapter speaks to the endpoint. OpenAI-compatible is the
29/// default; Anthropic Messages and Bedrock Converse are the other two dialects
30/// compiled in. The set stops here deliberately: any other provider is reached
31/// through a gateway, which keeps provider quirks out of the binary.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Provider {
34    OpenAiCompatible,
35    Anthropic,
36    /// Amazon Bedrock Converse — the model id rides the URL path, not the body,
37    /// and auth is SigV4 (an `intelligence.auth: {kind: aws}`), not a bearer.
38    Bedrock,
39}
40
41impl Provider {
42    pub(super) fn default_path(self) -> &'static str {
43        match self {
44            Provider::OpenAiCompatible => openai::DEFAULT_PATH,
45            Provider::Anthropic => anthropic::DEFAULT_PATH,
46            Provider::Bedrock => bedrock::DEFAULT_PATH,
47        }
48    }
49
50    /// Map the config `intelligence.dialect` selector to a provider. `None`/empty
51    /// ⇒ the OpenAI-compatible default; an unknown value ⇒ `None` (the caller
52    /// keeps the default — validation rejects it earlier at the config layer).
53    pub fn from_dialect(dialect: Option<&str>) -> Option<Provider> {
54        match dialect.map(str::trim).filter(|s| !s.is_empty()) {
55            None | Some("openai") | Some("openai-compatible") => Some(Provider::OpenAiCompatible),
56            Some("anthropic") => Some(Provider::Anthropic),
57            Some("bedrock") => Some(Provider::Bedrock),
58            Some(_) => None,
59        }
60    }
61
62    /// The effective request path for THIS request. Fixed for OpenAI and
63    /// Anthropic (the configured or default path); Bedrock puts the URI-encoded
64    /// model id in the path — `/model/{modelId}/converse` — so the path must be
65    /// computed per request and the signer and the wire must be handed the same
66    /// dynamic target, or the signature will not cover what is sent.
67    pub(super) fn request_path(self, configured: &str, req: &Request) -> String {
68        match self {
69            Provider::Bedrock => bedrock::converse_path(&req.model),
70            _ => configured.to_string(),
71        }
72    }
73}
74
75#[derive(Debug)]
76pub enum IntelError {
77    /// Transport / connection failure. Classed as fatal infrastructure, so a
78    /// one-shot run exits 4 rather than reporting a model-level failure.
79    Transport(std::io::Error),
80    /// Non-2xx HTTP status from the endpoint.
81    Http(u16, String),
82    /// Malformed response body.
83    Parse(String),
84    /// A transport this build doesn't support (e.g. https without `tls`).
85    Unsupported(String),
86    /// Every endpoint in the list is down or broken after the bounded failover
87    /// sweep. The boxed cause is the last failover-class error seen. Maps to the
88    /// same fatal-infrastructure class as `Transport`, so a `once` run exits 4;
89    /// a loop or reactive daemon backs off and retries rather than crashing.
90    AllEndpointsDown(Option<Box<IntelError>>),
91}
92
93impl fmt::Display for IntelError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            IntelError::Transport(e) => write!(f, "intelligence transport error: {e}"),
97            IntelError::Http(code, body) => write!(f, "intelligence HTTP {code}: {body}"),
98            IntelError::Parse(m) => write!(f, "{m}"),
99            IntelError::Unsupported(m) => write!(f, "{m}"),
100            IntelError::AllEndpointsDown(cause) => match cause {
101                Some(e) => write!(f, "all intelligence endpoints down (last error: {e})"),
102                None => write!(f, "all intelligence endpoints down"),
103            },
104        }
105    }
106}
107impl std::error::Error for IntelError {}
108
109impl From<std::io::Error> for IntelError {
110    fn from(e: std::io::Error) -> Self {
111        IntelError::Transport(e)
112    }
113}
114
115/// A resolved intelligence client over an ordered endpoint list.
116pub struct IntelClient {
117    /// The endpoint list, the sticky-primary cursor, and the per-endpoint
118    /// health/breaker state. Behind a `RefCell` so `complete(&self)` can advance
119    /// the cursor and record health without forcing a `&mut` through every call
120    /// site in the loop. This is interior mutability, not sharing: the
121    /// per-subagent client is single-threaded. A hot reload replaces the whole
122    /// `IntelClient` rather than mutating this list in place.
123    list: RefCell<EndpointList>,
124    timeout: Duration,
125    /// The run's trace id; when set, every completion carries a `traceparent`
126    /// header so the model call joins the run's distributed trace.
127    trace_id: Option<String>,
128    /// All-endpoints-down backoff policy. `None` — the default, used by
129    /// `once`-mode — means a single sweep: all-down returns immediately and the
130    /// caller maps it to exit 4. `Some(policy)`, used by loop and reactive
131    /// daemons, re-runs the sweep with bounded jittered backoff so a transient
132    /// host-model roll recovers without the daemon dying; it resumes the instant
133    /// any endpoint half-opens healthy.
134    alldown: Option<AllDownPolicy>,
135    /// Edge-triggered all-down reachability reporter. The model loop runs in a
136    /// CHILD process that owns this breaker state, and the supervisor has no
137    /// model of its own and no live view of it — this callback is the only way
138    /// the reachability reaches the supervisor. When set, `complete()` invokes
139    /// it exactly ONCE per transition of the list's all-down state: on
140    /// **entering** all-down (every breaker open, or the sweep exhausted) and on
141    /// **recovering** (any endpoint usable again). The child turns that into an
142    /// `AgentMsg::IntelHealth` sent upward. Firing on the edge rather than per
143    /// call means the steady state costs one bool compare. The report carries
144    /// transport and index ONLY — never a URL or credential. Nothing here
145    /// touches the data path; it is a pure upward report. Defaults to `None`
146    /// for a one-shot run with no supervisor listening.
147    health_reporter: Option<Box<dyn Fn(IntelHealthReport)>>,
148    /// Last all-down state observed by the reporter, so we only fire on a change.
149    last_all_down: std::cell::Cell<bool>,
150}
151
152/// The edge-triggered intelligence-reachability report a child emits upward.
153/// `active` is the bounded `(index, transport-scheme)` of the serving endpoint:
154/// transport and index ONLY, never a URL, host, cid or credential, because this
155/// crosses a process boundary into the supervisor's observable surface. It is
156/// `None` on entering all-down, when nothing is serving.
157pub struct IntelHealthReport {
158    pub all_down: bool,
159    pub active: Option<(usize, &'static str)>,
160}
161
162/// Bounded, jittered all-down backoff. A daemon re-arms the sweep up to
163/// `max_retries` times, sleeping `base × 2^n` capped at `max` with per-attempt
164/// jitter, before surfacing the terminal all-down. The bound matters: without
165/// `max_retries` a permanently misconfigured endpoint would keep a daemon alive
166/// and silent forever instead of failing visibly.
167#[derive(Debug, Clone, Copy)]
168pub struct AllDownPolicy {
169    pub max_retries: u32,
170    pub base: Duration,
171    pub max: Duration,
172}
173
174impl Default for AllDownPolicy {
175    fn default() -> AllDownPolicy {
176        // 1s..30s jittered: fast enough to ride out a rolling model deploy,
177        // slow enough that eight attempts do not hammer a dead provider.
178        AllDownPolicy {
179            max_retries: 8,
180            base: Duration::from_secs(1),
181            max: Duration::from_secs(30),
182        }
183    }
184}
185
186/// Per-endpoint dial transport, owned by [`super::endpoints`]. Intelligence is
187/// **HTTPS-only**, so there is exactly one TCP shape: `tls: true` in production.
188/// `tls: false` exists solely for the loopback dev/test carve-out that backs the
189/// built-in mock LLM, and [`resolve`] rejects it for any non-loopback host —
190/// the variant cannot be reached with a real remote address.
191#[derive(Debug)]
192pub enum Transport {
193    Tcp { host: String, port: u16, tls: bool },
194}
195
196impl IntelClient {
197    /// Build from explicit parts — the subagent path, driven by the spawn
198    /// payload rather than the CLI `Config`. `uri` is the endpoint *list*, which
199    /// may hold a single element. `default_token` is endpoint 1's resolved
200    /// credential, used only when its own env override is unset; every later
201    /// endpoint resolves its own `_<N>`-suffixed token instead of inheriting
202    /// this one, so a fallback never dials with the primary's credential.
203    pub fn from_parts(uri: &str, default_token: Option<String>) -> Result<IntelClient, IntelError> {
204        let list = EndpointList::parse(uri, default_token)?;
205        Ok(IntelClient {
206            list: RefCell::new(list),
207            // Generous per-call ceiling; the run deadline is the real bound.
208            timeout: Duration::from_secs(120),
209            trace_id: None,
210            alldown: None,
211            health_reporter: None,
212            last_all_down: std::cell::Cell::new(false),
213        })
214    }
215
216    /// Attach the configured `intelligence.headers`, applied to every endpoint
217    /// dial. Builder-style; call before use. An empty list leaves every endpoint
218    /// with only the dialect's own headers.
219    pub fn with_headers(self, headers: Vec<(String, String)>) -> IntelClient {
220        if !headers.is_empty() {
221            self.list.borrow_mut().set_extra_headers(headers);
222        }
223        self
224    }
225
226    /// Attach a per-request signer — AWS SigV4 — applied to every dial. The
227    /// signature covers the method, host, request-target and body of the dial
228    /// that is about to go out, so it is computed after the effective path is
229    /// resolved rather than from the configured path. Builder-style; call before
230    /// use.
231    pub fn with_signer(
232        self,
233        signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>>,
234    ) -> IntelClient {
235        if let Some(s) = signer {
236            self.list.borrow_mut().set_signer(s);
237        }
238        self
239    }
240
241    /// Select the wire dialect from `intelligence.dialect`, applied to every
242    /// endpoint in the list. `None` or an unrecognised value keeps the
243    /// OpenAI-compatible default; config validation rejects unknown dialects
244    /// earlier, so reaching here with one is not a user-visible path.
245    /// Builder-style; call before use.
246    pub fn with_dialect(self, dialect: Option<&str>) -> IntelClient {
247        if let Some(p) = Provider::from_dialect(dialect)
248            && p != Provider::OpenAiCompatible
249        {
250            self.list.borrow_mut().set_provider(p);
251        }
252        self
253    }
254
255    /// Install the edge-triggered all-down reachability reporter: the child
256    /// wires this to send an `AgentMsg::IntelHealth` up to the supervisor on
257    /// each all-down ENTER/EXIT transition. The callback fires only when the
258    /// list's all-down state changes, never once per call, so a steady state
259    /// emits nothing. It sits off the data path entirely — a client with no
260    /// reporter selects and dials identically.
261    pub fn set_health_reporter(&mut self, reporter: Box<dyn Fn(IntelHealthReport)>) {
262        self.health_reporter = Some(reporter);
263    }
264
265    /// Stamp the run's trace id so each completion carries a `traceparent`
266    /// header and the model call joins the run's distributed trace.
267    pub fn set_trace_id(&mut self, trace_id: Option<String>) {
268        self.trace_id = trace_id;
269    }
270
271    /// Enable the all-endpoints-down backoff for a long-lived `loop` or
272    /// `reactive` daemon: on all-down, re-arm the failover sweep with bounded
273    /// jittered backoff instead of surfacing the terminal immediately.
274    /// `once`-mode leaves this unset, so a single sweep leads to exit 4. The run
275    /// deadline still bounds the total wait — backoff never extends it, so a
276    /// daemon cannot outlive its deadline by retrying.
277    pub fn enable_alldown_backoff(&mut self, policy: AllDownPolicy) {
278        self.alldown = Some(policy);
279    }
280
281    /// The number of configured endpoints; one means no fallback exists.
282    pub fn endpoint_count(&self) -> usize {
283        self.list.borrow().len()
284    }
285
286    /// The run's trace id, if stamped. A hot-swap reads it to re-stamp the
287    /// rebuilt client, so the run's trace survives a repoint unbroken.
288    pub fn trace_id(&self) -> Option<&str> {
289        self.trace_id.as_deref()
290    }
291
292    /// Whether the all-endpoints-down backoff is enabled, which it is for a
293    /// long-lived loop or reactive daemon. A hot-swap reads it so the rebuilt
294    /// client preserves the daemon's resilience posture across a repoint rather
295    /// than silently reverting to one-shot semantics.
296    pub fn alldown_enabled(&self) -> bool {
297        self.alldown.is_some()
298    }
299
300    /// One completion round-trip, driven through the failover policy. Every
301    /// error returned here feeds the exit-code path
302    /// (`IntelError` → `LoopAbort::Intel` → exit 4).
303    ///
304    /// When all endpoints are down and the all-down backoff is enabled (loop and
305    /// reactive daemons), the sweep is re-armed with bounded jittered backoff so
306    /// a transient host-model roll recovers without killing the daemon;
307    /// `once`-mode surfaces the terminal immediately and exits 4. A fatal auth
308    /// failure (401/403) is NEVER backed off: it is a misconfiguration that
309    /// retrying cannot repair, and retrying it would only delay the operator's
310    /// signal.
311    pub fn complete(&self, req: &Request) -> Result<Response, IntelError> {
312        // One model call. A no-op when metrics are not enabled.
313        crate::obs::metrics::record_intel_call();
314
315        let mut attempt: u32 = 0;
316        loop {
317            let sweep = {
318                let mut list = self.list.borrow_mut();
319                failover::complete_resilient(&mut list, req, self.timeout, self.trace_id.as_deref())
320            };
321
322            // `set_intel_up` reflects the active endpoint's reachability (in rotation).
323            {
324                let list = self.list.borrow();
325                let all_down = list.all_down();
326                let active_up = list.ep(list.active()).health.is_up();
327                crate::obs::metrics::set_intel_up(active_up && !all_down);
328                // Upward report: fire ONLY on an all-down transition, so the
329                // supervisor latches the child's reachability and the steady
330                // state pays just this bool compare. Carries transport and index
331                // only — never a URL or secret.
332                if let Some(report) = &self.health_reporter
333                    && self.last_all_down.replace(all_down) != all_down
334                {
335                    let active = (!all_down).then(|| list.active_identity());
336                    report(IntelHealthReport { all_down, active });
337                }
338            }
339
340            match sweep.outcome {
341                Ok(resp) => return Ok(resp),
342                Err(e) => {
343                    crate::obs::metrics::record_intel_error(error_reason(&e));
344                    // Back off and re-arm only when all three hold: the list is
345                    // all-down, a daemon backoff policy is installed, and the
346                    // cause is not an auth failure. Anything else — a fatal
347                    // class, no policy, or an exhausted retry budget — surfaces
348                    // to the caller now.
349                    let backoff = match (&self.alldown, &e) {
350                        (Some(p), IntelError::AllEndpointsDown(cause))
351                            if !cause.as_deref().is_some_and(failover::is_auth)
352                                && attempt < p.max_retries =>
353                        {
354                            *p
355                        }
356                        _ => return Err(e),
357                    };
358                    let delay = backoff_delay(&backoff, attempt);
359                    attempt += 1;
360                    std::thread::sleep(delay);
361                    // Loop: the next sweep promotes any elapsed-cooldown breaker to
362                    // half-open and resumes the instant an endpoint recovers.
363                }
364            }
365        }
366    }
367
368    /// Borrow the endpoint list for the read-only `agentd://intelligence`
369    /// resource body. The caller serializes transport, index and health only —
370    /// never the URL or any credential, since that body is exposed to whoever
371    /// can read the resource.
372    pub fn with_list<R>(&self, f: impl FnOnce(&EndpointList) -> R) -> R {
373        f(&self.list.borrow())
374    }
375}
376
377/// The jittered backoff delay for all-down retry `attempt`: the exponential
378/// `base × 2^attempt` capped at `max`, then ±25% jitter. Jitter keeps a fleet of
379/// agents that lost the same provider from re-dialling in lockstep. The PRNG is
380/// a clock-seeded splitmix64 step rather than a `rand` dependency, because the
381/// crate's dependency count is a deliberate constraint and backoff jitter has no
382/// cryptographic requirement.
383fn backoff_delay(policy: &AllDownPolicy, attempt: u32) -> Duration {
384    let shift = attempt.min(20);
385    let scaled = policy.base.saturating_mul(1u32 << shift).min(policy.max);
386    let ms = scaled.as_millis() as u64;
387    // ±25% jitter, drawn from a clock-seeded splitmix64 step.
388    let seed = std::time::SystemTime::now()
389        .duration_since(std::time::UNIX_EPOCH)
390        .map(|d| d.as_nanos() as u64)
391        .unwrap_or(0)
392        ^ (attempt as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
393    let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
394    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
395    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
396    z ^= z >> 31;
397    // Map z → [-25%, +25%] of ms: window width is ms/2 (rounded up).
398    let lo = ms.saturating_sub(ms / 4);
399    let window = (ms / 2) + 1;
400    Duration::from_millis(lo + z % window)
401}
402
403/// Map an [`IntelError`] to the `agentd_intel_errors_total{reason}` label
404/// domain. That domain is frozen at `unreachable`, `auth`, `timeout`, `5xx` and
405/// `other`: adding a label value would silently break every dashboard and alert
406/// built on it, so a new error class must fold into one of these five.
407fn error_reason(e: &IntelError) -> &'static str {
408    match e {
409        IntelError::Transport(io) => match io.kind() {
410            std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout",
411            _ => "unreachable",
412        },
413        IntelError::Http(401 | 403, _) => "auth",
414        IntelError::Http(c, _) if (500..600).contains(c) => "5xx",
415        IntelError::Http(_, _) => "other",
416        IntelError::Parse(_) | IntelError::Unsupported(_) => "other",
417        // All-down classifies by its underlying cause when present.
418        IntelError::AllEndpointsDown(Some(cause)) => error_reason(cause),
419        IntelError::AllEndpointsDown(None) => "unreachable",
420    }
421}
422
423/// Parse one intelligence URI element into (transport, http-path, host-header).
424/// Shared by [`super::endpoints`], the list parser. The transport is HTTPS-only:
425/// `https://host[:port][/path]`. Plaintext `http://` is admitted ONLY for a
426/// loopback host, the dev/test carve-out that lets the built-in mock LLM be
427/// dialled. Everything else — `unix:`, `vsock:`, non-loopback `http://` — is
428/// rejected. This function is the single chokepoint that every construction
429/// path flows through (CLI config, spawn payload, hot reload), which is what
430/// makes the HTTPS rule impossible to bypass by picking another entry point.
431pub(super) fn resolve(
432    uri: &str,
433    provider: Provider,
434) -> Result<(Transport, String, String), IntelError> {
435    // `mock:<script>` — the offline dev endpoint: spawns the built-in mock
436    // LLM in-process and dials it over loopback. Debug builds always carry it;
437    // release only under `--features internal-mocks`, so a production binary
438    // has no way to be pointed at fake intelligence.
439    #[cfg(any(feature = "internal-mocks", debug_assertions))]
440    if let Some(script) = uri.strip_prefix("mock:") {
441        let addr = super::mock::inprocess(script).map_err(IntelError::Unsupported)?;
442        return resolve(&format!("http://{addr}"), provider);
443    }
444    #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
445    if uri.starts_with("mock:") {
446        return Err(IntelError::Unsupported(
447            "mock: intelligence needs a build with --features internal-mocks".into(),
448        ));
449    }
450    let url = Url::parse(uri).map_err(|_| {
451        IntelError::Unsupported(format!(
452            "intelligence endpoint must be https://host[:port][/path] (got: {uri})"
453        ))
454    })?;
455    let tls = url.is_tls();
456    if !tls && !crate::net::http::is_loopback_host(&url.host) {
457        return Err(IntelError::Unsupported(format!(
458            "plaintext http:// intelligence is allowed for loopback only (dev); use https:// (got: {uri})"
459        )));
460    }
461    let http_path = if url.path == "/" {
462        provider.default_path().to_string()
463    } else {
464        url.path.clone()
465    };
466    let host_header = url.host_header();
467    Ok((
468        Transport::Tcp {
469            host: url.host,
470            port: url.port,
471            tls,
472        },
473        http_path,
474        host_header,
475    ))
476}
477
478impl Transport {
479    pub(super) fn connect(&self, timeout: Duration) -> Result<Box<dyn Stream>, IntelError> {
480        use crate::net::http;
481        match self {
482            Transport::Tcp {
483                host,
484                port,
485                tls: false,
486            } => Ok(Box::new(http::connect_tcp(host, *port, timeout)?)),
487            Transport::Tcp {
488                host,
489                port,
490                tls: true,
491            } => connect_tls(host, *port, timeout),
492        }
493    }
494}
495
496#[cfg(feature = "tls")]
497fn connect_tls(host: &str, port: u16, timeout: Duration) -> Result<Box<dyn Stream>, IntelError> {
498    let tcp = crate::net::http::connect_tcp(host, port, timeout)?;
499    Ok(Box::new(
500        // Server-authenticated TLS: the endpoint's certificate is verified, but
501        // no client identity is presented. Intelligence endpoints authenticate
502        // agentd by bearer token or SigV4, not by client certificate.
503        crate::net::tls::connect(tcp, host, None).map_err(IntelError::Transport)?,
504    ))
505}
506
507#[cfg(not(feature = "tls"))]
508fn connect_tls(_host: &str, _port: u16, _timeout: Duration) -> Result<Box<dyn Stream>, IntelError> {
509    Err(IntelError::Unsupported(
510        "https:// intelligence requires building with --features tls".into(),
511    ))
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn resolve_rejects_non_https_transports() {
520        // HTTPS-only: unix: and vsock: targets are rejected at the single
521        // resolve() chokepoint rather than being feature-gated away.
522        for uri in ["unix:/run/intel.sock", "vsock:2:8080", "not-a-url"] {
523            let err = resolve(uri, Provider::OpenAiCompatible).unwrap_err();
524            assert!(
525                matches!(err, IntelError::Unsupported(_)),
526                "{uri} must be rejected, got: {err:?}"
527            );
528        }
529    }
530
531    #[test]
532    fn resolve_allows_plaintext_http_for_loopback_only() {
533        // The dev/test carve-out: the built-in mock LLM binds 127.0.0.1.
534        for uri in [
535            "http://127.0.0.1:8080",
536            "http://localhost:8080",
537            "http://[::1]:8080",
538        ] {
539            let (t, _p, _h) = resolve(uri, Provider::OpenAiCompatible).unwrap();
540            assert!(matches!(t, Transport::Tcp { tls: false, .. }), "{uri}");
541        }
542        let err = resolve("http://intel.example:8080", Provider::OpenAiCompatible).unwrap_err();
543        assert!(
544            matches!(err, IntelError::Unsupported(m) if m.contains("loopback")),
545            "non-loopback plaintext must be rejected"
546        );
547    }
548
549    #[test]
550    fn resolve_https_full_url() {
551        let (t, path, host) = resolve(
552            "https://api.openai.com/v1/chat/completions",
553            Provider::OpenAiCompatible,
554        )
555        .unwrap();
556        assert!(matches!(
557            t,
558            Transport::Tcp {
559                tls: true,
560                port: 443,
561                ..
562            }
563        ));
564        assert_eq!(path, "/v1/chat/completions");
565        assert_eq!(host, "api.openai.com");
566    }
567
568    #[test]
569    fn resolve_https_host_only_uses_default_path() {
570        let (_t, path, _host) =
571            resolve("https://gateway.local", Provider::OpenAiCompatible).unwrap();
572        assert_eq!(path, "/v1/chat/completions");
573    }
574
575    #[test]
576    fn single_endpoint_client_builds() {
577        let c = IntelClient::from_parts("https://intel.example", None).unwrap();
578        assert_eq!(c.endpoint_count(), 1);
579    }
580
581    #[test]
582    fn comma_list_client_builds_with_all_endpoints() {
583        let c = IntelClient::from_parts(
584            "https://a.example,https://b.example,https://c.example",
585            None,
586        )
587        .unwrap();
588        assert_eq!(c.endpoint_count(), 3);
589    }
590
591    #[test]
592    fn all_endpoints_down_maps_to_unreachable_reason() {
593        // The all-down terminal classifies by its underlying cause.
594        let cause = Box::new(IntelError::Http(503, "x".into()));
595        assert_eq!(
596            error_reason(&IntelError::AllEndpointsDown(Some(cause))),
597            "5xx"
598        );
599        assert_eq!(
600            error_reason(&IntelError::AllEndpointsDown(None)),
601            "unreachable"
602        );
603        assert_eq!(error_reason(&IntelError::Http(401, "x".into())), "auth");
604    }
605
606    #[test]
607    fn trace_header_propagates_to_endpoint_dialect() {
608        // Construction does not connect; we only assert the trace id is held and
609        // would be applied per endpoint (the per-endpoint dial appends it).
610        let mut c = IntelClient::from_parts("https://intel.example", None).unwrap();
611        assert!(c.trace_id.is_none());
612        c.set_trace_id(Some("1234567890abcdef1234567890abcdef".into()));
613        assert!(c.trace_id.is_some());
614    }
615}