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}