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}