use std::collections::BTreeMap;
use allama::ProviderOptions;
use serde::de::{Error as _, IgnoredAny};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{Credential, CredentialKind, Headers, is_credential_header};
#[cfg(doc)]
use crate::{CredentialSlot, is_sensitive_header};
pub const OPTIONS_NAMESPACE: &str = "allama-http";
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct HttpRequestOptions {
pub headers: Headers,
credentials: Vec<(String, Credential)>,
}
impl HttpRequestOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name, value);
self
}
#[must_use]
pub fn credential(mut self, scope: impl Into<String>, credential: Credential) -> Self {
let scope = scope.into();
self.credentials.retain(|(existing_scope, existing)| {
*existing_scope != scope || !same_slot(existing, &credential)
});
self.credentials.push((scope, credential));
self
}
#[must_use]
pub fn credential_for(&self, scope: &str) -> Option<&Credential> {
self.credentials
.iter()
.find(|(existing, credential)| existing == scope && credential.is_placed_by_adapter())
.map(|(_, credential)| credential)
}
pub fn credentials(&self) -> impl Iterator<Item = (&str, &Credential)> {
self.credentials
.iter()
.map(|(scope, credential)| (scope.as_str(), credential))
}
pub(crate) fn header_credentials_for<'a>(
&'a self,
scope: &'a str,
) -> impl Iterator<Item = (&'a str, &'a str)> {
self.credentials
.iter()
.filter(move |(existing, _)| existing == scope)
.filter_map(|(_, credential)| match credential.kind() {
CredentialKind::Header(name) => Some((name.as_str(), credential.expose_secret())),
_ => None,
})
}
}
impl ProviderOptions for HttpRequestOptions {
const NAMESPACE: &'static str = OPTIONS_NAMESPACE;
}
impl Serialize for HttpRequestOptions {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut headers: BTreeMap<&str, String> = BTreeMap::new();
let exported = self
.headers
.iter()
.filter(|(name, _)| !is_credential_header(name));
for (name, value) in exported {
let key = headers
.keys()
.find(|key| key.eq_ignore_ascii_case(name))
.copied()
.unwrap_or(name);
headers
.entry(key)
.and_modify(|joined| {
joined.push_str(", ");
joined.push_str(value);
})
.or_insert_with(|| value.to_owned());
}
Wire {
headers,
credentials: None,
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for HttpRequestOptions {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let wire = Wire::<String>::deserialize(deserializer)?;
if wire.credentials.is_some() {
return Err(D::Error::custom(
"credentials cannot be set in the JSON form, which is exported and printed \
as given; attach them with the typed `HttpRequestOptions::credential`",
));
}
if let Some(name) = wire.headers.keys().find(|name| is_credential_header(name)) {
return Err(D::Error::custom(format!(
"header `{name}` carries a credential and JSON options are exported as \
given; send credentials with the typed `HttpRequestOptions::credential`, \
whose export omits them"
)));
}
Ok(Self {
headers: wire.headers.into_iter().collect(),
credentials: Vec::new(),
})
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire<K: Ord> {
#[serde(default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<K, String>,
#[serde(default, skip_serializing)]
credentials: Option<IgnoredAny>,
}
fn same_slot(existing: &Credential, new: &Credential) -> bool {
match (existing.kind(), new.kind()) {
(CredentialKind::Header(a), CredentialKind::Header(b)) => a.eq_ignore_ascii_case(b),
(CredentialKind::Header(_), _) | (_, CredentialKind::Header(_)) => false,
_ => true,
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use allama::{ChatRequest, RequestOptions};
use serde_json::json;
use super::*;
#[test]
fn export_omits_credentials_and_joins_repeated_headers() {
let options = HttpRequestOptions::new()
.header("Authorization", "Bearer SECRET1")
.header("x-api-key", "SECRET2")
.header("x-tag", "a")
.header("X-Tag", "b")
.header("x-tenant-id", "acme");
assert_eq!(
serde_json::to_value(&options).unwrap(),
json!({"headers": {"x-tag": "a, b", "x-tenant-id": "acme"}})
);
assert_eq!(
serde_json::to_value(HttpRequestOptions::new().header("x-api-key", "SECRET")).unwrap(),
json!({})
);
}
#[test]
fn json_form_round_trips_and_rejects_credentials() {
let mut options = RequestOptions::new();
options.insert_json(
OPTIONS_NAMESPACE,
json!({"headers": {"x-tenant-id": "acme"}}),
);
let resolved = options.resolve::<HttpRequestOptions>().unwrap().unwrap();
assert_eq!(resolved.headers.get("x-tenant-id"), Some("acme"));
options.insert_json(
OPTIONS_NAMESPACE,
json!({"headers": {"Authorization": "Bearer SECRET"}}),
);
let err = options.resolve::<HttpRequestOptions>().unwrap_err();
assert!(matches!(err, allama::Error::InvalidRequest { .. }), "{err}");
assert!(err.to_string().contains("`Authorization`"), "{err}");
assert!(!err.to_string().contains("SECRET"), "{err}");
options.insert_json(OPTIONS_NAMESPACE, json!({"header": {}}));
assert!(options.resolve::<HttpRequestOptions>().is_err());
options.insert_json(OPTIONS_NAMESPACE, json!({}));
assert!(
options
.resolve::<HttpRequestOptions>()
.unwrap()
.unwrap()
.headers
.iter()
.next()
.is_none()
);
}
#[test]
fn json_form_rejects_credentials() {
let mut options = RequestOptions::new();
options.insert_json(
OPTIONS_NAMESPACE,
json!({"credentials": [{"scope": "openai", "kind": "api_key", "secret": "SECRET"}]}),
);
let err = options.resolve::<HttpRequestOptions>().unwrap_err();
assert!(matches!(err, allama::Error::InvalidRequest { .. }), "{err}");
assert!(err.to_string().contains("credentials"), "{err}");
assert!(!err.to_string().contains("SECRET"), "{err}");
options.insert_json(OPTIONS_NAMESPACE, json!({"headers": {"x-api-key": "k"}}));
assert!(options.resolve::<HttpRequestOptions>().is_err());
}
#[test]
fn plain_headers_that_look_like_credentials_round_trip() {
let mut options = RequestOptions::new();
options.insert_json(
OPTIONS_NAMESPACE,
json!({"headers": {"Idempotency-Key": "idem-1", "x-author": "a"}}),
);
let resolved = options.resolve::<HttpRequestOptions>().unwrap().unwrap();
assert_eq!(resolved.headers.get("idempotency-key"), Some("idem-1"));
let typed = HttpRequestOptions::new()
.header("Idempotency-Key", "idem-1")
.header("x-author", "a")
.header("Authorization", "Bearer SECRET");
assert_eq!(
serde_json::to_value(&typed).unwrap(),
json!({"headers": {"Idempotency-Key": "idem-1", "x-author": "a"}})
);
let debug = format!("{typed:?}");
assert!(
!debug.contains("idem-1") && !debug.contains("SECRET"),
"{debug}"
);
}
#[test]
fn credentials_stay_out_of_debug_and_serialized_requests() {
let request = ChatRequest::new("m").user("Hi").with_option(
HttpRequestOptions::new()
.header("Idempotency-Key", "idem-0")
.credential("openai", Credential::api_key("SECRET1"))
.credential(
"openai",
Credential::header("cf-aig-authorization", "Bearer SECRET2"),
)
.header("x-tenant-id", "acme"),
);
let debug = format!("{request:?}");
assert!(!debug.contains("SECRET"), "{debug}");
let options_debug = format!("{:?}", request.options.get::<HttpRequestOptions>());
assert!(!options_debug.contains("SECRET"), "{options_debug}");
assert!(options_debug.contains("acme"), "{options_debug}");
assert!(
options_debug.contains("cf-aig-authorization"),
"{options_debug}"
);
let serialized = serde_json::to_string(&request).unwrap();
assert!(!serialized.contains("SECRET"), "{serialized}");
assert!(!serialized.contains("credentials"), "{serialized}");
assert!(serialized.contains(
r#""allama-http":{"headers":{"Idempotency-Key":"idem-0","x-tenant-id":"acme"}}"#
));
let rebuilt: ChatRequest = serde_json::from_str(&serialized).unwrap();
let resolved = rebuilt
.options
.resolve::<HttpRequestOptions>()
.unwrap()
.unwrap();
assert_eq!(resolved.headers.get("Idempotency-Key"), Some("idem-0"));
assert_eq!(resolved.headers.get("x-tenant-id"), Some("acme"));
assert_eq!(resolved.credentials().count(), 0);
assert_eq!(rebuilt, request);
}
}