Skip to main content

allama_http/
options.rs

1//! Per-request transport options.
2
3use std::collections::BTreeMap;
4
5use allama::ProviderOptions;
6use serde::de::{Error as _, IgnoredAny};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::{Credential, CredentialKind, Headers, is_credential_header};
10#[cfg(doc)]
11use crate::{CredentialSlot, is_sensitive_header};
12
13/// The options namespace of [`HttpRequestOptions`]: `"allama-http"`.
14///
15/// It belongs to no provider: every HTTP adapter reads it through
16/// [`HttpRequest::apply_options`](crate::HttpRequest::apply_options), and no
17/// provider mints state under it.
18pub const OPTIONS_NAMESPACE: &str = "allama-http";
19
20/// Transport options for one request: HTTP headers and credentials to send
21/// with it.
22///
23/// Attach them to a [`ChatRequest`](allama::ChatRequest),
24/// [`EmbeddingRequest`](allama::EmbeddingRequest) or
25/// [`DecisionRequest`](allama::DecisionRequest) like any options. Every
26/// bundled HTTP adapter applies them with
27/// [`HttpRequest::apply_options`](crate::HttpRequest::apply_options), and so
28/// should third-party ones. Use them for gateway tags (tenant, route,
29/// trace, a request id), and for per-tenant credentials:
30///
31/// ```
32/// use allama::ChatRequest;
33/// use allama_http::{Credential, HttpRequestOptions};
34///
35/// let request = ChatRequest::new("gpt-4o-mini").user("Hi").with_option(
36///     HttpRequestOptions::new()
37///         .header("x-tenant-id", "acme")
38///         .credential("openai", Credential::api_key("sk-tenant-key")),
39/// );
40/// # let _ = request;
41/// ```
42///
43/// # From a wrapper
44///
45/// Header and credential injection happen at the transport, and these
46/// options are how a wrapper reaches it: a wrapper sees the portable
47/// request, never the HTTP request (ADR 0013). A wrapper that tags every
48/// call, with a request id header for example, adds its header to the
49/// options the request already carries, through
50/// [`ChatRequest::option_mut`](allama::ChatRequest::option_mut), so the
51/// caller's headers and credentials survive; a fresh `HttpRequestOptions`
52/// given to `with_option` would replace them.
53///
54/// ```
55/// use allama::ChatRequest;
56/// use allama_http::{Credential, HttpRequestOptions};
57///
58/// fn tag(request: &ChatRequest, request_id: &str) -> ChatRequest {
59///     let mut request = request.clone();
60///     match request.option_mut::<HttpRequestOptions>() {
61///         Some(options) => options.headers.insert("x-request-id", request_id),
62///         None => {
63///             let options = HttpRequestOptions::new().header("x-request-id", request_id);
64///             request = request.with_option(options);
65///         }
66///     }
67///     request
68/// }
69///
70/// let request = ChatRequest::new("m").user("Hi").with_option(
71///     HttpRequestOptions::new().credential("openai", Credential::api_key("sk-tenant")),
72/// );
73/// let tagged = tag(&request, "req-1");
74/// let options = tagged.option::<HttpRequestOptions>().unwrap();
75/// assert_eq!(options.headers.get("x-request-id"), Some("req-1"));
76/// assert!(options.credential_for("openai").is_some(), "the caller's key survives");
77/// ```
78///
79/// # Headers
80///
81/// A header given here replaces every header of the same name (ignoring
82/// case) that the adapter set; headers with other names are added. A plain
83/// header never carries a credential: one named on the exact credential list
84/// ([`is_credential_header`]), or one the adapter's [`CredentialSlot`]
85/// writes or replaces, is refused before anything is sent.
86///
87/// # Credentials
88///
89/// [`credential`](Self::credential) addresses a [`Credential`] to a
90/// **credential scope**: the account it belongs to. Only the adapter whose
91/// [`CredentialSlot`] has that scope sends it, so in a fallback chain each
92/// provider gets its own key and no other. Bundled adapters default their
93/// scope to their provider name (`openai`, `anthropic`, `groq`, ...), each
94/// crate publishes it as `DEFAULT_CREDENTIAL_SCOPE`, and their builders
95/// change it with `credential_scope`.
96///
97/// - The scope's [`Credential::api_key`] replaces the key the adapter was
98///   configured with. An adapter configured with a key still sends its own
99///   key when no credential is addressed to its scope, so a mistyped scope
100///   falls back to the configured key; an adapter built without a key
101///   refuses such a request instead.
102/// - A [`Credential::header`] is added to requests for its scope, replacing
103///   a configured header of the same name.
104///
105/// Two accounts of one provider in a fallback chain need two scopes: by
106/// default they share one, and both would send the same key.
107///
108/// Which hops are protected: plain headers are refused by each adapter as it
109/// sends, so a plain header that only one adapter owns (`x-nimbus-key`)
110/// still reaches the providers tried before it in a fallback chain. Only
111/// the exact credential list protects every hop. Send third-party keys as a
112/// [`Credential::header`] addressed to their scope.
113///
114/// # Secrets stay in the process
115///
116/// - `Debug` output redacts credentials and credential headers (the names
117///   [`is_sensitive_header`] matches), as for [`Headers`].
118/// - Serialization omits credentials and the headers on the exact
119///   credential list ([`is_credential_header`]), so a serialized request, a
120///   `RecordingProvider` exchange, or a fixture never holds them. A request
121///   rebuilt from one carries no credentials. Two requests that differ only
122///   in credentials compare equal.
123/// - The JSON form (`with_options_json("allama-http", ..)`) rejects them
124///   with [`Error::InvalidRequest`](allama::Error::InvalidRequest): JSON
125///   options are exported and printed as given. Set credentials with the
126///   typed value.
127///
128/// Every other header is a plain header, exported and accepted in the JSON
129/// form as given, even one whose name only looks like a credential
130/// (`Idempotency-Key`, `x-gateway-token`): an idempotency key must survive
131/// a recording and a replay. `Debug` still redacts those by name. A secret
132/// belongs in a [`Credential::header`], never in a plain header.
133///
134/// Adapters never put header values on tracing spans.
135///
136/// # JSON form
137///
138/// `{"headers": {"x-tenant-id": "acme"}}` under [`OPTIONS_NAMESPACE`]. A
139/// header given more than once is exported once, with its values joined by
140/// `", "`.
141#[derive(Debug, Clone, Default)]
142#[non_exhaustive]
143pub struct HttpRequestOptions {
144    /// Headers to send, in order. Each replaces the adapter's headers of the
145    /// same name.
146    pub headers: Headers,
147    /// Credentials by scope, in order: at most one placed by the adapter
148    /// per scope, and one header credential per scope and header name.
149    credentials: Vec<(String, Credential)>,
150}
151
152impl HttpRequestOptions {
153    /// No options.
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Add a header to send.
160    ///
161    /// A header that carries a credential is refused when the request is
162    /// sent; use [`credential`](Self::credential) for those.
163    #[must_use]
164    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
165        self.headers.insert(name, value);
166        self
167    }
168
169    /// Address `credential` to the credential scope `scope`.
170    ///
171    /// It replaces the scope's earlier credential of the same kind: its
172    /// [`Credential::api_key`], or its [`Credential::header`] of the same
173    /// name.
174    #[must_use]
175    pub fn credential(mut self, scope: impl Into<String>, credential: Credential) -> Self {
176        let scope = scope.into();
177        self.credentials.retain(|(existing_scope, existing)| {
178            *existing_scope != scope || !same_slot(existing, &credential)
179        });
180        self.credentials.push((scope, credential));
181        self
182    }
183
184    /// The credential addressed to `scope` that the adapter places itself:
185    /// its [`Credential::api_key`] (header credentials are added by
186    /// [`HttpRequest::apply_options`](crate::HttpRequest::apply_options)).
187    ///
188    /// An adapter with a [`CredentialPlacement::Custom`] slot reads its
189    /// credential here, after `apply_options`.
190    ///
191    /// [`CredentialPlacement::Custom`]: crate::CredentialPlacement::Custom
192    #[must_use]
193    pub fn credential_for(&self, scope: &str) -> Option<&Credential> {
194        self.credentials
195            .iter()
196            .find(|(existing, credential)| existing == scope && credential.is_placed_by_adapter())
197            .map(|(_, credential)| credential)
198    }
199
200    /// Every credential, with the scope it is addressed to, in order.
201    pub fn credentials(&self) -> impl Iterator<Item = (&str, &Credential)> {
202        self.credentials
203            .iter()
204            .map(|(scope, credential)| (scope.as_str(), credential))
205    }
206
207    /// The header credentials addressed to `scope`, in order.
208    pub(crate) fn header_credentials_for<'a>(
209        &'a self,
210        scope: &'a str,
211    ) -> impl Iterator<Item = (&'a str, &'a str)> {
212        self.credentials
213            .iter()
214            .filter(move |(existing, _)| existing == scope)
215            .filter_map(|(_, credential)| match credential.kind() {
216                CredentialKind::Header(name) => Some((name.as_str(), credential.expose_secret())),
217                _ => None,
218            })
219    }
220}
221
222impl ProviderOptions for HttpRequestOptions {
223    const NAMESPACE: &'static str = OPTIONS_NAMESPACE;
224}
225
226/// Writes the plain headers; credentials and the headers on the exact
227/// credential list are omitted.
228impl Serialize for HttpRequestOptions {
229    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
230        let mut headers: BTreeMap<&str, String> = BTreeMap::new();
231        let exported = self
232            .headers
233            .iter()
234            .filter(|(name, _)| !is_credential_header(name));
235        for (name, value) in exported {
236            let key = headers
237                .keys()
238                .find(|key| key.eq_ignore_ascii_case(name))
239                .copied()
240                .unwrap_or(name);
241            headers
242                .entry(key)
243                .and_modify(|joined| {
244                    joined.push_str(", ");
245                    joined.push_str(value);
246                })
247                .or_insert_with(|| value.to_owned());
248        }
249        Wire {
250            headers,
251            credentials: None,
252        }
253        .serialize(serializer)
254    }
255}
256
257/// Reads the JSON form, which takes no credentials.
258impl<'de> Deserialize<'de> for HttpRequestOptions {
259    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
260        let wire = Wire::<String>::deserialize(deserializer)?;
261        if wire.credentials.is_some() {
262            return Err(D::Error::custom(
263                "credentials cannot be set in the JSON form, which is exported and printed \
264                 as given; attach them with the typed `HttpRequestOptions::credential`",
265            ));
266        }
267        if let Some(name) = wire.headers.keys().find(|name| is_credential_header(name)) {
268            return Err(D::Error::custom(format!(
269                "header `{name}` carries a credential and JSON options are exported as \
270                 given; send credentials with the typed `HttpRequestOptions::credential`, \
271                 whose export omits them"
272            )));
273        }
274        Ok(Self {
275            headers: wire.headers.into_iter().collect(),
276            credentials: Vec::new(),
277        })
278    }
279}
280
281/// The JSON form of [`HttpRequestOptions`].
282#[derive(Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284struct Wire<K: Ord> {
285    #[serde(default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty")]
286    headers: BTreeMap<K, String>,
287    /// Present only to refuse credentials with a clear message.
288    #[serde(default, skip_serializing)]
289    credentials: Option<IgnoredAny>,
290}
291
292/// Whether `new` takes `existing`'s place in a scope: both placed by the
293/// adapter, or both headers of the same name.
294fn same_slot(existing: &Credential, new: &Credential) -> bool {
295    match (existing.kind(), new.kind()) {
296        (CredentialKind::Header(a), CredentialKind::Header(b)) => a.eq_ignore_ascii_case(b),
297        (CredentialKind::Header(_), _) | (_, CredentialKind::Header(_)) => false,
298        _ => true,
299    }
300}
301
302#[cfg(all(test, not(target_arch = "wasm32")))]
303mod tests {
304    use allama::{ChatRequest, RequestOptions};
305    use serde_json::json;
306
307    use super::*;
308
309    #[test]
310    fn export_omits_credentials_and_joins_repeated_headers() {
311        let options = HttpRequestOptions::new()
312            .header("Authorization", "Bearer SECRET1")
313            .header("x-api-key", "SECRET2")
314            .header("x-tag", "a")
315            .header("X-Tag", "b")
316            .header("x-tenant-id", "acme");
317        assert_eq!(
318            serde_json::to_value(&options).unwrap(),
319            json!({"headers": {"x-tag": "a, b", "x-tenant-id": "acme"}})
320        );
321        assert_eq!(
322            serde_json::to_value(HttpRequestOptions::new().header("x-api-key", "SECRET")).unwrap(),
323            json!({})
324        );
325    }
326
327    #[test]
328    fn json_form_round_trips_and_rejects_credentials() {
329        let mut options = RequestOptions::new();
330        options.insert_json(
331            OPTIONS_NAMESPACE,
332            json!({"headers": {"x-tenant-id": "acme"}}),
333        );
334        let resolved = options.resolve::<HttpRequestOptions>().unwrap().unwrap();
335        assert_eq!(resolved.headers.get("x-tenant-id"), Some("acme"));
336
337        options.insert_json(
338            OPTIONS_NAMESPACE,
339            json!({"headers": {"Authorization": "Bearer SECRET"}}),
340        );
341        let err = options.resolve::<HttpRequestOptions>().unwrap_err();
342        assert!(matches!(err, allama::Error::InvalidRequest { .. }), "{err}");
343        assert!(err.to_string().contains("`Authorization`"), "{err}");
344        assert!(!err.to_string().contains("SECRET"), "{err}");
345
346        options.insert_json(OPTIONS_NAMESPACE, json!({"header": {}}));
347        assert!(options.resolve::<HttpRequestOptions>().is_err());
348        options.insert_json(OPTIONS_NAMESPACE, json!({}));
349        assert!(
350            options
351                .resolve::<HttpRequestOptions>()
352                .unwrap()
353                .unwrap()
354                .headers
355                .iter()
356                .next()
357                .is_none()
358        );
359    }
360
361    #[test]
362    fn json_form_rejects_credentials() {
363        let mut options = RequestOptions::new();
364        options.insert_json(
365            OPTIONS_NAMESPACE,
366            json!({"credentials": [{"scope": "openai", "kind": "api_key", "secret": "SECRET"}]}),
367        );
368        let err = options.resolve::<HttpRequestOptions>().unwrap_err();
369        assert!(matches!(err, allama::Error::InvalidRequest { .. }), "{err}");
370        assert!(err.to_string().contains("credentials"), "{err}");
371        assert!(!err.to_string().contains("SECRET"), "{err}");
372
373        options.insert_json(OPTIONS_NAMESPACE, json!({"headers": {"x-api-key": "k"}}));
374        assert!(options.resolve::<HttpRequestOptions>().is_err());
375    }
376
377    /// A plain header whose name merely looks like a credential is
378    /// accepted in the JSON form and survives an export, so an idempotency
379    /// key is never dropped silently; `Debug` still redacts it.
380    #[test]
381    fn plain_headers_that_look_like_credentials_round_trip() {
382        let mut options = RequestOptions::new();
383        options.insert_json(
384            OPTIONS_NAMESPACE,
385            json!({"headers": {"Idempotency-Key": "idem-1", "x-author": "a"}}),
386        );
387        let resolved = options.resolve::<HttpRequestOptions>().unwrap().unwrap();
388        assert_eq!(resolved.headers.get("idempotency-key"), Some("idem-1"));
389        let typed = HttpRequestOptions::new()
390            .header("Idempotency-Key", "idem-1")
391            .header("x-author", "a")
392            .header("Authorization", "Bearer SECRET");
393        assert_eq!(
394            serde_json::to_value(&typed).unwrap(),
395            json!({"headers": {"Idempotency-Key": "idem-1", "x-author": "a"}})
396        );
397        let debug = format!("{typed:?}");
398        assert!(
399            !debug.contains("idem-1") && !debug.contains("SECRET"),
400            "{debug}"
401        );
402    }
403
404    #[test]
405    fn credentials_stay_out_of_debug_and_serialized_requests() {
406        let request = ChatRequest::new("m").user("Hi").with_option(
407            HttpRequestOptions::new()
408                .header("Idempotency-Key", "idem-0")
409                .credential("openai", Credential::api_key("SECRET1"))
410                .credential(
411                    "openai",
412                    Credential::header("cf-aig-authorization", "Bearer SECRET2"),
413                )
414                .header("x-tenant-id", "acme"),
415        );
416        let debug = format!("{request:?}");
417        assert!(!debug.contains("SECRET"), "{debug}");
418        let options_debug = format!("{:?}", request.options.get::<HttpRequestOptions>());
419        assert!(!options_debug.contains("SECRET"), "{options_debug}");
420        assert!(options_debug.contains("acme"), "{options_debug}");
421        assert!(
422            options_debug.contains("cf-aig-authorization"),
423            "{options_debug}"
424        );
425
426        let serialized = serde_json::to_string(&request).unwrap();
427        assert!(!serialized.contains("SECRET"), "{serialized}");
428        assert!(!serialized.contains("credentials"), "{serialized}");
429        assert!(serialized.contains(
430            r#""allama-http":{"headers":{"Idempotency-Key":"idem-0","x-tenant-id":"acme"}}"#
431        ));
432
433        let rebuilt: ChatRequest = serde_json::from_str(&serialized).unwrap();
434        let resolved = rebuilt
435            .options
436            .resolve::<HttpRequestOptions>()
437            .unwrap()
438            .unwrap();
439        assert_eq!(resolved.headers.get("Idempotency-Key"), Some("idem-0"));
440        assert_eq!(resolved.headers.get("x-tenant-id"), Some("acme"));
441        assert_eq!(resolved.credentials().count(), 0);
442        assert_eq!(rebuilt, request);
443    }
444}