Skip to main content

agentd/intel/
endpoints.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The intelligence endpoint *list* and per-endpoint credentials.
3//!
4//! `--intelligence` / `AGENTD_INTELLIGENCE` is an **ordered, comma-separated
5//! list** (`https://gw-a.example,https://gw-b.example`), and list order IS
6//! failover priority: `eps[0]` is the primary. Each element goes through the
7//! HTTPS-only transport resolver — plaintext `http://` is admitted for loopback
8//! only — and resolves its **own** credential by env name. With a single
9//! element the failover and breaker machinery is inert, so a one-endpoint list
10//! behaves exactly like a plain dial.
11//!
12//! Per-endpoint credential naming: the default `AGENTD_INTELLIGENCE_TOKEN`
13//! belongs to endpoint 1, then `_2`, `_3`, … 1-indexed by list position. Each
14//! has a `…_FILE` variant read through [`crate::sec::secret`], which is what
15//! makes credential rotation possible without restarting. **The list URI itself
16//! carries no key**: the resolved value is held as an opaque `String` in the
17//! dialer only, never placed in a config or manifest struct, and never logged or
18//! serialized — that is why no `Serialize` impl may ever be added to a type
19//! holding it.
20
21use std::time::Duration;
22
23use super::client::{IntelError, Provider, Transport, resolve};
24use super::health::{BreakerConfig, HealthRecord};
25
26/// The default per-endpoint credential env var, belonging to endpoint 1. This is
27/// the branded spelling agentd documents and emits.
28const TOKEN_ENV: &str = "AGENTD_INTELLIGENCE_TOKEN";
29
30/// The neutral, de-branded credential env var accepted as an input alias for
31/// [`TOKEN_ENV`], so a host that standardises on vendor-neutral `AGENT_*` names
32/// needs no agentd-specific variable. It is an input alias only: the resolved
33/// value is still held opaquely and never logged or serialized.
34const TOKEN_ENV_NEUTRAL: &str = "AGENT_INTELLIGENCE_TOKEN";
35
36/// A single resolved endpoint: its transport + the per-request HTTP framing +
37/// its resolved credential + live health/breaker state.
38pub struct Endpoint {
39    /// The dialer-ready transport (tcp+tls; plaintext only for loopback dev).
40    pub(super) transport: Transport,
41    pub(super) http_path: String,
42    pub(super) host_header: String,
43    /// The resolved bearer credential for THIS endpoint (never logged/serialized).
44    pub(super) token: Option<String>,
45    pub(super) provider: Provider,
46    /// Structural transport scheme reported in the observable resource body:
47    /// `https`, or `http` for the loopback dev carve-out. Never the URL, which
48    /// can carry a credential in its userinfo or path.
49    pub(super) scheme: &'static str,
50    /// Structural address reported in the observable resource body — `host[:port]`
51    /// only, with no scheme, no path and therefore no secret.
52    pub(super) addr: String,
53    /// Extra request headers: the resolved `intelligence.headers`, pushed on
54    /// every dial. Empty unless configured.
55    pub(super) extra_headers: Vec<(String, String)>,
56    /// An optional per-request signer — AWS SigV4 over the exact body — added on
57    /// every dial alongside the AAuth headers. Shared across every endpoint of
58    /// one client, since the credential is a property of the caller and not of
59    /// the endpoint it happens to reach.
60    pub(super) signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>>,
61    /// Live health and circuit-breaker state for this endpoint.
62    pub health: HealthRecord,
63}
64
65/// The ordered endpoint list with the sticky-primary `active` cursor.
66pub struct EndpointList {
67    eps: Vec<Endpoint>,
68    /// The index currently preferred. A plain `usize` is sound here because the
69    /// dialer reaches it through a single-threaded `&mut` call path: the
70    /// per-subagent `IntelClient` is never shared across threads.
71    active: usize,
72    breaker: BreakerConfig,
73}
74
75/// Resolve an env var (default impl; overridable in tests).
76fn env(name: &str) -> Option<String> {
77    std::env::var(name).ok()
78}
79
80impl EndpointList {
81    /// Parse the comma-list `uri` into an ordered `EndpointList`, resolving each
82    /// endpoint's credential. `default_token` — the already-resolved
83    /// `--intelligence-token` or its `_FILE` form — supplies endpoint 1's value
84    /// when its env override is unset, and applies to endpoint 1 only.
85    /// Per-endpoint env overrides (`AGENTD_INTELLIGENCE_TOKEN_<N>` / `_FILE`)
86    /// win when present.
87    pub fn parse(uri: &str, default_token: Option<String>) -> Result<EndpointList, IntelError> {
88        Self::parse_with_env(uri, default_token, &env)
89    }
90
91    /// `parse` with an injectable env reader (for tests).
92    pub fn parse_with_env(
93        uri: &str,
94        default_token: Option<String>,
95        env: &dyn Fn(&str) -> Option<String>,
96    ) -> Result<EndpointList, IntelError> {
97        let provider = Provider::OpenAiCompatible;
98        let parts: Vec<&str> = uri
99            .split(',')
100            .map(str::trim)
101            .filter(|s| !s.is_empty())
102            .collect();
103        if parts.is_empty() {
104            return Err(IntelError::Unsupported(
105                "empty intelligence endpoint list".into(),
106            ));
107        }
108        let mut eps = Vec::with_capacity(parts.len());
109        for (i, part) in parts.iter().enumerate() {
110            let (transport, http_path, host_header) = resolve(part, provider)?;
111            let token = resolve_token(i, default_token.as_deref(), env)?;
112            let (scheme, addr) = scheme_and_addr(part);
113            eps.push(Endpoint {
114                transport,
115                http_path,
116                host_header,
117                token,
118                provider,
119                scheme,
120                addr,
121                extra_headers: Vec::new(),
122                signer: None,
123                health: HealthRecord::new(),
124            });
125        }
126        Ok(EndpointList {
127            eps,
128            active: 0,
129            breaker: BreakerConfig::default(),
130        })
131    }
132
133    /// Apply the configured `intelligence.headers` to every endpoint. Called
134    /// once after construction, before any dial.
135    pub fn set_extra_headers(&mut self, headers: Vec<(String, String)>) {
136        for e in &mut self.eps {
137            e.extra_headers = headers.clone();
138        }
139    }
140
141    /// Attach a per-request AWS SigV4 signer to every endpoint in the list.
142    pub fn set_signer(&mut self, signer: std::sync::Arc<dyn ::mcp::http::RequestSigner>) {
143        for e in &mut self.eps {
144            e.signer = Some(signer.clone());
145        }
146    }
147
148    /// Select the wire dialect for every endpoint from `intelligence.dialect`.
149    /// A host-only endpoint resolved its path with the OpenAI default before the
150    /// dialect was known, so a still-defaulted path is re-pointed at the chosen
151    /// dialect's default. A path the operator wrote explicitly is left untouched,
152    /// because they may be routing through a gateway mount. Bedrock overrides the
153    /// path per request in any case.
154    pub fn set_provider(&mut self, provider: Provider) {
155        let default = provider.default_path();
156        for e in &mut self.eps {
157            e.provider = provider;
158            if e.http_path == super::openai::DEFAULT_PATH {
159                e.http_path = default.to_string();
160            }
161        }
162    }
163
164    pub fn len(&self) -> usize {
165        self.eps.len()
166    }
167
168    pub fn is_empty(&self) -> bool {
169        self.eps.is_empty()
170    }
171
172    pub fn active(&self) -> usize {
173        self.active
174    }
175
176    pub fn breaker_config(&self) -> &BreakerConfig {
177        &self.breaker
178    }
179
180    pub fn ep(&self, idx: usize) -> &Endpoint {
181        &self.eps[idx]
182    }
183
184    pub fn iter(&self) -> impl Iterator<Item = &Endpoint> {
185        self.eps.iter()
186    }
187
188    /// The failover attempt order: the **active** index first, then the
189    /// remaining endpoints in ascending list order, skipping any whose breaker is
190    /// OPEN and still cooling. `available` promotes an endpoint whose cooldown
191    /// has elapsed to HALF-OPEN, so it is probed here rather than skipped
192    /// forever. An empty result means every endpoint is down.
193    pub fn attempt_order(&self) -> Vec<usize> {
194        let mut order = Vec::with_capacity(self.eps.len());
195        if self.eps[self.active].health.available(&self.breaker) {
196            order.push(self.active);
197        }
198        for idx in 0..self.eps.len() {
199            if idx == self.active {
200                continue;
201            }
202            if self.eps[idx].health.available(&self.breaker) {
203                order.push(idx);
204            }
205        }
206        order
207    }
208
209    /// Snap `active` back to the lowest-index endpoint whose breaker is not OPEN.
210    /// This is what makes the primary sticky: once it re-closes, the next call
211    /// returns to it, so serving from a fallback is temporary by construction and
212    /// a list never silently drifts onto its last entry. Returns the new active
213    /// index if it changed.
214    pub fn prefer_lowest_healthy(&mut self) -> Option<usize> {
215        let target = (0..self.eps.len()).find(|&i| self.eps[i].health.is_up());
216        if let Some(t) = target
217            && t != self.active
218        {
219            self.active = t;
220            return Some(t);
221        }
222        None
223    }
224
225    /// Mark `idx` as the active endpoint (it just succeeded). Returns the new
226    /// active index if it changed.
227    pub fn set_active(&mut self, idx: usize) -> Option<usize> {
228        if idx != self.active {
229            self.active = idx;
230            Some(idx)
231        } else {
232            None
233        }
234    }
235
236    /// True when no endpoint is available, i.e. every breaker is OPEN and still
237    /// cooling.
238    pub fn all_down(&self) -> bool {
239        self.attempt_order().is_empty()
240    }
241
242    /// The active endpoint's bounded structural identity `(index,
243    /// transport-scheme)` for the child→supervisor
244    /// [`crate::subagent::protocol::AgentMsg::IntelHealth`] report. Transport and
245    /// index ONLY — never the URL, host, cid or credential — matching the
246    /// redaction the served resource body applies, so neither route can leak
247    /// what the other withholds.
248    pub fn active_identity(&self) -> (usize, &'static str) {
249        (self.active, self.eps[self.active].scheme)
250    }
251
252    /// The `agentd://intelligence` resource body: the endpoint list by transport
253    /// and index, which one is active, and each one's health — state, latency
254    /// and error rate. It must contain no secret and no URL: only the bounded
255    /// structural `transport` and `addr` (a bare `host[:port]`, which cannot
256    /// carry a scheme-borne credential) plus the live health atomics. Anything
257    /// added here becomes readable by every holder of the resource.
258    pub fn body(&self, model: Option<&str>) -> serde_json::Value {
259        use serde_json::json;
260        let cfg = &self.breaker;
261        let endpoints: Vec<serde_json::Value> = self
262            .eps
263            .iter()
264            .enumerate()
265            .map(|(i, ep)| {
266                let h = &ep.health;
267                let mut e = json!({
268                    "index": i,
269                    "transport": ep.scheme,
270                    "addr": ep.addr,
271                    "state": h.state().as_str(),
272                    "active": i == self.active,
273                    "ewma_latency_ms": h.ewma_latency_ms(),
274                    "error_rate": h.error_rate(),
275                    "consec_fail": h.consec_fail(),
276                });
277                if let serde_json::Value::Object(m) = &mut e {
278                    if let Some(ms) = h.last_ok_ms_ago() {
279                        m.insert("last_ok_ms_ago".into(), json!(ms));
280                    }
281                    if h.state() == super::health::BreakerState::Open {
282                        if let Some(ms) = h.opened_ms_ago() {
283                            m.insert("opened_ms_ago".into(), json!(ms));
284                        }
285                        m.insert(
286                            "cooldown_ms".into(),
287                            json!(h.cooldown(cfg).as_millis() as u64),
288                        );
289                        m.insert("last_err".into(), json!(h.last_err_kind().as_str()));
290                    }
291                }
292                e
293            })
294            .collect();
295        json!({
296            "active": self.active,
297            "all_down": self.all_down(),
298            "model": model,
299            "endpoints": endpoints,
300        })
301    }
302}
303
304/// Resolve endpoint `idx`'s credential. `idx` is 0-based but the env override is
305/// 1-indexed: endpoint 0 reads `AGENTD_INTELLIGENCE_TOKEN` or falls back to the
306/// already-resolved default, endpoint 1 reads `AGENTD_INTELLIGENCE_TOKEN_2`, and
307/// so on. A `…_FILE` variant is read through the secret-file reader, which is
308/// what allows the credential to be rotated on disk. An env override wins over
309/// the default. Absence is not an error: an endpoint with no token dials
310/// unauthenticated, which a public gateway legitimately allows.
311fn resolve_token(
312    idx: usize,
313    default_token: Option<&str>,
314    env: &dyn Fn(&str) -> Option<String>,
315) -> Result<Option<String>, IntelError> {
316    // Endpoint 1 (idx 0) uses the bare names; later endpoints are 1-indexed
317    // (`_2`, `_3`, …). Each branded `AGENTD_*` name has a neutral `AGENT_*`
318    // alias accepted on input; the branded spelling is always still honoured.
319    let (inline_var, file_var, inline_var_n, file_var_n) = if idx == 0 {
320        (
321            TOKEN_ENV.to_string(),
322            format!("{TOKEN_ENV}_FILE"),
323            TOKEN_ENV_NEUTRAL.to_string(),
324            format!("{TOKEN_ENV_NEUTRAL}_FILE"),
325        )
326    } else {
327        let n = idx + 1;
328        (
329            format!("{TOKEN_ENV}_{n}"),
330            format!("{TOKEN_ENV}_{n}_FILE"),
331            format!("{TOKEN_ENV_NEUTRAL}_{n}"),
332            format!("{TOKEN_ENV_NEUTRAL}_{n}_FILE"),
333        )
334    };
335    // Precedence: explicit inline env override > file override > the resolved
336    // default (only for endpoint 0). Higher-precedence inline wins. At each tier
337    // the neutral `AGENT_*` spelling is read first, then the branded `AGENTD_*`.
338    if let Some(v) = env(&inline_var_n).or_else(|| env(&inline_var)) {
339        return Ok(Some(v));
340    }
341    if let Some(path) = env(&file_var_n).or_else(|| env(&file_var)) {
342        let tok = crate::sec::secret::read_token_file(&path).map_err(IntelError::Unsupported)?;
343        return Ok(Some(tok));
344    }
345    if idx == 0 {
346        return Ok(default_token.map(str::to_string));
347    }
348    Ok(None)
349}
350
351/// The structural `(scheme, addr)` published in the observable resource body:
352/// the bounded transport identity only, never the URL path or any secret.
353/// `http` appears only for the loopback dev carve-out, because
354/// [`resolve`](super::client) has already rejected every other non-HTTPS form
355/// before an endpoint reaches here.
356fn scheme_and_addr(uri: &str) -> (&'static str, String) {
357    if let Some(rest) = uri.strip_prefix("https://") {
358        ("https", host_only(rest))
359    } else if let Some(rest) = uri.strip_prefix("http://") {
360        ("http", host_only(rest))
361    } else {
362        ("unknown", String::new())
363    }
364}
365
366/// The host[:port] of an `http(s)://host[:port]/path`, dropping the path (it may
367/// be sensitive and is not addressing).
368fn host_only(rest: &str) -> String {
369    rest.split('/').next().unwrap_or(rest).to_string()
370}
371
372/// A tool name in provider-safe wire form: OpenAI/Anthropic require tool names to
373/// match `^[a-zA-Z0-9_-]+$`, so every other char — notably the `.` in agentd's
374/// namespaced self-tools (`resource.read`, `subagent.spawn`) — becomes `_`. A
375/// per-request reverse map restores the original name for routing.
376fn wire_tool_name(name: &str) -> String {
377    name.chars()
378        .map(|c| {
379            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
380                c
381            } else {
382                '_'
383            }
384        })
385        .collect()
386}
387
388impl Endpoint {
389    /// When a process AAuth identity is installed, sign the intelligence dial
390    /// with an RFC 9421 HTTP message signature so the gateway can attest the agent by
391    /// signature rather than by source IP. The headers only add identity cover:
392    /// a gateway that does not understand them ignores them, and the bearer
393    /// token, if any, still rides alongside. Returns empty without
394    /// `--features aauth` or with no identity configured, so an unsigned dial is
395    /// the no-identity default rather than a failure.
396    #[cfg(feature = "aauth")]
397    fn aauth_headers(&self, method: &str, path: &str, body: &[u8]) -> Vec<(String, String)> {
398        match crate::aauth::signer() {
399            Some(signer) => signer.sign(method, &self.host_header, path, body),
400            None => Vec::new(),
401        }
402    }
403    #[cfg(not(feature = "aauth"))]
404    fn aauth_headers(&self, _method: &str, _path: &str, _body: &[u8]) -> Vec<(String, String)> {
405        Vec::new()
406    }
407
408    /// Build the request body and headers for this endpoint's dialect, then dial
409    /// and round-trip once. Returns the parsed response and the measured
410    /// round-trip latency, which the caller feeds into this endpoint's health
411    /// record. Endpoint selection happens above this call; nothing here consults
412    /// the list or the breaker.
413    pub(super) fn complete_once(
414        &self,
415        req: &crate::wire::intel::Request,
416        timeout: Duration,
417        trace_id: Option<&str>,
418    ) -> Result<(crate::wire::intel::Response, Duration), IntelError> {
419        use super::{anthropic, openai};
420        use crate::net::http;
421        use std::collections::HashMap;
422        use std::time::Instant;
423
424        // Provider tool-name compatibility: real OpenAI/Anthropic reject tool names
425        // that aren't `^[a-zA-Z0-9_-]+$`, but agentd uses dotted namespaced names
426        // (`resource.read`, `subagent.spawn`, …). Sanitize every place a name rides
427        // the wire — the `tools` definitions AND the prior `tool_calls` in the
428        // assistant message history (which get re-sent each turn) — and map the
429        // returned `tool_calls` back to the originals so routing is unaffected.
430        // No-op (no clone) when every name is already wire-safe.
431        use crate::wire::intel::Message;
432        let dirty = |n: &str| wire_tool_name(n) != n;
433        let must_sanitize = req.tools.iter().any(|t| dirty(&t.name))
434            || req.messages.iter().any(|m| {
435                matches!(m, Message::Assistant { tool_calls, .. }
436                    if tool_calls.iter().any(|tc| dirty(&tc.name)))
437            });
438        let mut wire_to_orig: HashMap<String, String> = HashMap::new();
439        let owned_req;
440        let req: &crate::wire::intel::Request = if must_sanitize {
441            let mut r = req.clone();
442            for t in &mut r.tools {
443                let w = wire_tool_name(&t.name);
444                if w != t.name {
445                    wire_to_orig.insert(w.clone(), t.name.clone());
446                    t.name = w;
447                }
448            }
449            for m in &mut r.messages {
450                if let Message::Assistant { tool_calls, .. } = m {
451                    for tc in tool_calls {
452                        let w = wire_tool_name(&tc.name);
453                        if w != tc.name {
454                            wire_to_orig.insert(w.clone(), tc.name.clone());
455                            tc.name = w;
456                        }
457                    }
458                }
459            }
460            owned_req = r;
461            &owned_req
462        } else {
463            req
464        };
465
466        use super::bedrock;
467        let (body, mut headers) = match self.provider {
468            Provider::OpenAiCompatible => openai::build_request(req, self.token.as_deref()),
469            Provider::Anthropic => anthropic::build_request(req, self.token.as_deref()),
470            Provider::Bedrock => bedrock::build_request(req, self.token.as_deref()),
471        };
472        // The effective request path: fixed for OpenAI and Anthropic; Bedrock
473        // derives `/model/{modelId}/converse` from the request. The SAME string
474        // is sent on the wire AND fed to the AAuth/SigV4 signers below, so the
475        // signature covers the exact request-target. Recomputing it separately
476        // for either use would silently invalidate every signature.
477        let path = self.provider.request_path(&self.http_path, req);
478        if let Some(tid) = trace_id {
479            headers.push((
480                "traceparent".into(),
481                crate::obs::trace::outbound_traceparent(tid),
482            ));
483        }
484        // The configured `intelligence.headers` ride every dial — a gateway
485        // routing header, for instance. Pushed BEFORE the AAuth headers so the
486        // signature covers a header set that is already final; a header the
487        // dialect set is not removed.
488        for (k, v) in &self.extra_headers {
489            headers.push((k.clone(), v.clone()));
490        }
491        // AAuth: sign the dial over the exact body bytes (content-digest cover
492        // applies when discovery flagged it) before we borrow `headers`.
493        for (k, v) in self.aauth_headers("POST", &path, &body) {
494            headers.push((k, v));
495        }
496        // An AWS SigV4 signature over the exact body, present when an `aws`
497        // intelligence auth is configured — native Bedrock, or a Bedrock or
498        // API-Gateway-fronted endpoint. Signed over `path`, so the dynamic
499        // Bedrock target is inside the signature rather than appended after it.
500        if let Some(signer) = &self.signer {
501            for (k, v) in signer.sign("POST", &self.host_header, &path, &body) {
502                headers.push((k, v));
503            }
504        }
505        let header_refs: Vec<(&str, &str)> = headers
506            .iter()
507            .map(|(k, v)| (k.as_str(), v.as_str()))
508            .collect();
509
510        // Same-endpoint transient retry. Every dial opens a fresh connection, so
511        // a re-dial is safe. A 429 or 5xx is usually a momentary provider blip,
512        // so it is retried a bounded number of times with short backoff BEFORE
513        // the error escapes to cross-endpoint failover — or, in `once` mode
514        // which arms no higher-level retry loop, straight to exit 4. A
515        // non-transient 4xx (bad request, auth) is a caller error that would be
516        // identical on a re-dial, so it surfaces immediately rather than burning
517        // the run deadline. The body and headers, including the AAuth signature
518        // over that body, are built once above and unchanged across attempts, so
519        // every re-dial sends byte-identical bytes and the signature stays valid.
520        const TRANSIENT_RETRIES: u32 = 2;
521        let mut attempt: u32 = 0;
522        let (resp, latency) = loop {
523            let start = Instant::now();
524            let mut stream = self.transport.connect(timeout)?;
525            let resp = http::send(
526                stream.as_mut(),
527                &self.host_header,
528                "POST",
529                &path,
530                &header_refs,
531                &body,
532            )?;
533            let latency = start.elapsed();
534            if resp.is_success() {
535                break (resp, latency);
536            }
537            if super::failover::is_transient_status(resp.status) && attempt < TRANSIENT_RETRIES {
538                attempt += 1;
539                // Short exponential backoff (250ms, 500ms) — enough to ride out a
540                // blip, bounded so total added wait stays well under a second.
541                std::thread::sleep(Duration::from_millis(250 * (1u64 << (attempt - 1))));
542                continue;
543            }
544            let snippet: String = resp.body_str().chars().take(512).collect();
545            return Err(IntelError::Http(resp.status, snippet));
546        };
547
548        let mut parsed = match self.provider {
549            Provider::OpenAiCompatible => openai::parse_response(&resp.body),
550            Provider::Anthropic => anthropic::parse_response(&resp.body),
551            Provider::Bedrock => bedrock::parse_response(&resp.body),
552        }
553        .map_err(IntelError::Parse)?;
554        // Undo the wire sanitization: route by the original (dotted) tool names.
555        if !wire_to_orig.is_empty() {
556            for tc in &mut parsed.tool_calls {
557                if let Some(orig) = wire_to_orig.get(&tc.name) {
558                    tc.name = orig.clone();
559                }
560            }
561        }
562        Ok((parsed, latency))
563    }
564
565    /// Model-discovery probe: one hand-rolled HTTP **GET** to the `/v1/models`
566    /// sibling of this endpoint's chat path, over the SAME transport and the
567    /// SAME bearer auth the chat call uses — no second client, no streaming.
568    /// Returns the discovered model `id`s.
569    ///
570    /// **Best-effort with silent degrade.** The `anthropic` dialect has no list
571    /// endpoint and returns `vec![]`. For an OpenAI-compatible endpoint, a
572    /// connection or transport failure, a non-2xx status (a 404 simply means
573    /// discovery is unsupported), or a body that is not the expected JSON all
574    /// yield `vec![]` too. None of these is a failover-class error and none is
575    /// fatal: the endpoint stays fully usable without discovery, since the
576    /// configured model is dialed regardless. Recording a probe failure against
577    /// the endpoint's health would let an optional feature open a breaker on a
578    /// perfectly healthy provider, which is why nothing here touches health. The
579    /// caller bounds this with a short timeout.
580    pub(super) fn discover_models(&self, timeout: Duration) -> Vec<String> {
581        use super::openai;
582        use crate::net::http;
583
584        // The dialect is already settled by the configured provider, so there is
585        // nothing to sniff here. Anthropic has no list endpoint.
586        if self.provider != Provider::OpenAiCompatible {
587            return Vec::new();
588        }
589
590        let path = openai::models_path(&self.http_path);
591        // Same auth header the chat call sends (`Authorization: Bearer …`), no body.
592        let mut headers: Vec<(String, String)> = Vec::new();
593        if let Some(tok) = self.token.as_deref() {
594            headers.push(("authorization".into(), format!("Bearer {tok}")));
595        }
596        // Sign the discovery GET too (over its own `/v1/models` path), so a
597        // signature-attesting gateway accepts it exactly like the chat dial.
598        for (k, v) in self.aauth_headers("GET", &path, &[]) {
599            headers.push((k, v));
600        }
601        let header_refs: Vec<(&str, &str)> = headers
602            .iter()
603            .map(|(k, v)| (k.as_str(), v.as_str()))
604            .collect();
605
606        // Connect → GET → parse. Any error degrades to [] (silent, never fatal).
607        let Ok(mut stream) = self.transport.connect(timeout) else {
608            return Vec::new();
609        };
610        let Ok(resp) = http::send(
611            stream.as_mut(),
612            &self.host_header,
613            "GET",
614            &path,
615            &header_refs,
616            &[],
617        ) else {
618            return Vec::new();
619        };
620        if !resp.is_success() {
621            // 404 / 4xx / 5xx → discovery unsupported for this endpoint.
622            return Vec::new();
623        }
624        openai::parse_models(&resp.body)
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn extra_headers_apply_to_every_endpoint() {
634        // `intelligence.headers` (and a device-login bearer) reach the wire via
635        // `set_extra_headers`, applied to every failover endpoint, not just the
636        // primary.
637        let mut list = EndpointList::parse(
638            "https://a.example/v1,https://b.example/v1",
639            Some("tok".into()),
640        )
641        .unwrap();
642        assert!(list.eps.iter().all(|e| e.extra_headers.is_empty()));
643        list.set_extra_headers(vec![("X-Team".into(), "ops".into())]);
644        for e in &list.eps {
645            assert_eq!(
646                e.extra_headers,
647                vec![("X-Team".to_string(), "ops".to_string())]
648            );
649        }
650    }
651
652    #[test]
653    fn wire_tool_name_maps_only_illegal_chars() {
654        // The provider pattern is ^[a-zA-Z0-9_-]+$: dots (agentd's namespace
655        // separator) and anything else become `_`; legal names pass through.
656        assert_eq!(wire_tool_name("resource.read"), "resource_read");
657        assert_eq!(wire_tool_name("subagent.spawn"), "subagent_spawn");
658        assert_eq!(wire_tool_name("math.factorial"), "math_factorial");
659        // Already-legal names are untouched (so the fast path stays a no-op).
660        assert_eq!(wire_tool_name("get_weather"), "get_weather");
661        assert_eq!(wire_tool_name("list-files"), "list-files");
662        assert_eq!(
663            wire_tool_name("calculate_triangle_area"),
664            "calculate_triangle_area"
665        );
666        // Other illegal chars (spaces, slashes) also normalize.
667        assert_eq!(wire_tool_name("a b/c"), "a_b_c");
668    }
669
670    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
671        move |k: &str| {
672            pairs
673                .iter()
674                .find(|(n, _)| *n == k)
675                .map(|(_, v)| (*v).to_string())
676        }
677    }
678
679    #[test]
680    fn comma_list_parses_to_n_endpoints_in_order() {
681        let env = env_of(&[]);
682        let list = EndpointList::parse_with_env(
683            "https://gw-a.example:8443,https://gw-b.example:8444,https://intel.example",
684            None,
685            &env,
686        )
687        .unwrap();
688        assert_eq!(list.len(), 3);
689        assert_eq!(list.ep(0).scheme, "https");
690        assert_eq!(list.ep(0).addr, "gw-a.example:8443");
691        assert_eq!(list.ep(1).addr, "gw-b.example:8444");
692        assert_eq!(list.ep(2).scheme, "https");
693        assert_eq!(list.active(), 0);
694    }
695
696    #[test]
697    fn whitespace_around_elements_is_trimmed() {
698        let env = env_of(&[]);
699        let list =
700            EndpointList::parse_with_env(" https://a.example , https://b.example ", None, &env)
701                .unwrap();
702        assert_eq!(list.len(), 2);
703        assert_eq!(list.ep(0).addr, "a.example");
704        assert_eq!(list.ep(1).addr, "b.example");
705    }
706
707    #[test]
708    fn empty_list_is_an_error() {
709        let env = env_of(&[]);
710        assert!(EndpointList::parse_with_env("", None, &env).is_err());
711        assert!(EndpointList::parse_with_env("   ,  ,", None, &env).is_err());
712    }
713
714    #[test]
715    fn bad_element_scheme_is_an_error() {
716        let env = env_of(&[]);
717        let r = EndpointList::parse_with_env("https://a.example,ftp://nope", None, &env);
718        assert!(matches!(r, Err(IntelError::Unsupported(_))));
719        // Non-HTTPS transports are rejected at the same chokepoint.
720        for uri in ["unix:/a", "vsock:3:8080", "http://not-loopback.example"] {
721            let r = EndpointList::parse_with_env(uri, None, &env);
722            assert!(matches!(r, Err(IntelError::Unsupported(_))), "{uri}");
723        }
724    }
725
726    #[test]
727    fn per_endpoint_token_env_resolves_by_position() {
728        // endpoint 1 uses the bare name (or the default); endpoint 2 uses `_2`.
729        let env = env_of(&[
730            ("AGENTD_INTELLIGENCE_TOKEN", "tok-a"),
731            ("AGENTD_INTELLIGENCE_TOKEN_2", "tok-b"),
732        ]);
733        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
734            .unwrap();
735        assert_eq!(list.ep(0).token.as_deref(), Some("tok-a"));
736        assert_eq!(list.ep(1).token.as_deref(), Some("tok-b"));
737    }
738
739    #[test]
740    fn endpoint_0_falls_back_to_default_token_when_env_unset() {
741        let env = env_of(&[]);
742        let list = EndpointList::parse_with_env(
743            "https://a.example,https://b.example",
744            Some("default".into()),
745            &env,
746        )
747        .unwrap();
748        // endpoint 0 inherits the resolved default; endpoint 1 has none.
749        assert_eq!(list.ep(0).token.as_deref(), Some("default"));
750        assert_eq!(list.ep(1).token, None);
751    }
752
753    #[test]
754    fn per_endpoint_env_override_wins_over_default() {
755        let env = env_of(&[("AGENTD_INTELLIGENCE_TOKEN", "from-env")]);
756        let list = EndpointList::parse_with_env("https://a.example", Some("default".into()), &env)
757            .unwrap();
758        assert_eq!(list.ep(0).token.as_deref(), Some("from-env"));
759    }
760
761    #[test]
762    fn neutral_token_env_is_accepted_as_an_alias() {
763        // The neutral `AGENT_INTELLIGENCE_TOKEN[_N]` spelling is accepted on
764        // input (endpoint 1 bare; later endpoints 1-indexed).
765        let env = env_of(&[
766            ("AGENT_INTELLIGENCE_TOKEN", "neutral-a"),
767            ("AGENT_INTELLIGENCE_TOKEN_2", "neutral-b"),
768        ]);
769        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
770            .unwrap();
771        assert_eq!(list.ep(0).token.as_deref(), Some("neutral-a"));
772        assert_eq!(list.ep(1).token.as_deref(), Some("neutral-b"));
773    }
774
775    #[test]
776    fn branded_token_env_wins_over_neutral_on_conflict() {
777        // Both spellings set: the neutral name is read first. The branded form
778        // is still honoured whenever the neutral one is absent.
779        let env = env_of(&[
780            ("AGENT_INTELLIGENCE_TOKEN", "neutral"),
781            ("AGENTD_INTELLIGENCE_TOKEN", "branded"),
782        ]);
783        let list = EndpointList::parse_with_env("https://a.example", None, &env).unwrap();
784        assert_eq!(list.ep(0).token.as_deref(), Some("neutral"));
785
786        // The branded name alone resolves when no neutral one is set.
787        let env = env_of(&[("AGENTD_INTELLIGENCE_TOKEN", "branded")]);
788        let list = EndpointList::parse_with_env("https://a.example", None, &env).unwrap();
789        assert_eq!(list.ep(0).token.as_deref(), Some("branded"));
790    }
791
792    #[test]
793    fn token_file_variant_reads_from_disk() {
794        use std::io::Write;
795        let mut f = tempfile::NamedTempFile::new().unwrap();
796        writeln!(f, "file-secret").unwrap();
797        let path = f.path().to_str().unwrap().to_string();
798        let pairs = [("AGENTD_INTELLIGENCE_TOKEN_2_FILE", path.as_str())];
799        let env = env_of(&pairs);
800        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
801            .unwrap();
802        assert_eq!(list.ep(1).token.as_deref(), Some("file-secret"));
803    }
804
805    #[test]
806    fn single_element_list_has_inert_failover() {
807        let env = env_of(&[]);
808        let list = EndpointList::parse_with_env("https://intel.example", None, &env).unwrap();
809        assert_eq!(list.len(), 1);
810        // The failover machinery is inert: attempt order is just [0].
811        assert_eq!(list.attempt_order(), vec![0]);
812        assert!(!list.all_down());
813    }
814
815    #[test]
816    fn attempt_order_skips_open_endpoint_and_snaps_back() {
817        use super::super::health::ErrKind;
818        let env = env_of(&[]);
819        let mut list =
820            EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
821                .unwrap();
822        let cfg = *list.breaker_config();
823        // open endpoint 0's breaker (threshold 3)
824        for _ in 0..3 {
825            list.ep(0).health.record_failure(ErrKind::Refused, &cfg);
826        }
827        // attempt order now skips 0, yields [1]
828        assert_eq!(list.attempt_order(), vec![1]);
829        // and 1 is the lowest healthy → prefer_lowest_healthy moves active there
830        assert_eq!(list.prefer_lowest_healthy(), Some(1));
831        assert_eq!(list.active(), 1);
832        // endpoint 0 recovers → snap back to it
833        list.ep(0).health.record_success(Duration::from_millis(5));
834        assert_eq!(list.prefer_lowest_healthy(), Some(0));
835        assert_eq!(list.active(), 0);
836    }
837
838    #[test]
839    fn resource_body_has_health_and_no_url_or_token() {
840        use super::super::health::ErrKind;
841        let env = env_of(&[("AGENTD_INTELLIGENCE_TOKEN", "super-secret-tok")]);
842        let list = EndpointList::parse_with_env(
843            "https://gw-a.example:8443,https://gw-b.example/v1/secret-path",
844            None,
845            &env,
846        )
847        .unwrap();
848        // make endpoint 1 broken, endpoint 0 healthy + active
849        list.ep(0).health.record_success(Duration::from_millis(41));
850        let cfg = *list.breaker_config();
851        for _ in 0..3 {
852            list.ep(1).health.record_failure(ErrKind::Refused, &cfg);
853        }
854        let body = list.body(Some("claude-opus-4"));
855        let text = body.to_string();
856        // schema: active/all_down/model/endpoints[]
857        assert_eq!(body["active"], 0);
858        assert_eq!(body["model"], "claude-opus-4");
859        assert_eq!(body["endpoints"][0]["transport"], "https");
860        assert_eq!(body["endpoints"][0]["addr"], "gw-a.example:8443");
861        assert_eq!(body["endpoints"][0]["state"], "closed");
862        assert_eq!(body["endpoints"][0]["active"], true);
863        assert_eq!(body["endpoints"][0]["ewma_latency_ms"], 41);
864        assert_eq!(body["endpoints"][1]["state"], "open");
865        assert_eq!(body["endpoints"][1]["last_err"], "refused");
866        // The body must carry neither the token nor a full URL (no scheme
867        // prefix, no path).
868        assert!(!text.contains("super-secret-tok"), "token leaked: {text}");
869        assert!(!text.contains("https://"), "full URI leaked: {text}");
870        assert!(!text.contains("secret-path"), "URL path leaked: {text}");
871    }
872
873    #[test]
874    fn all_down_when_every_breaker_open() {
875        use super::super::health::ErrKind;
876        let env = env_of(&[]);
877        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
878            .unwrap();
879        let cfg = *list.breaker_config();
880        for ep in list.iter() {
881            for _ in 0..3 {
882                ep.health.record_failure(ErrKind::Refused, &cfg);
883            }
884        }
885        assert!(list.all_down());
886        assert!(list.attempt_order().is_empty());
887    }
888}