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}