Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
xAI, Anthropic, Google and DeepSeek converters resolve file references with
the second. A missing DeepSeek reference now names the providers the
reference has, as the AI SDK's `NoSuchProviderReferenceError` does.
- `aimux_provider_utils::{Credential, AuthScheme, ProviderHeaders}`, the
provider-level request headers as data (credential, fixed headers, user
headers, `ai-sdk-<package>/<version>` user-agent suffix), and
`aimux_provider_utils::{UserAgentFetch, transport_user_agent}`, the
user-agent edits a transport makes (RFC-0046 R1). They move out of
`aimux-providers`, whose providers build their headers with them unchanged.
- `aimux_provider_utils::parse_provider_options`, as `@ai-sdk/provider-utils`
5.0.55 `parseProviderOptions` (RFC-0046 R1): one provider's
`providerOptions` namespace deserialized into its options type, `None`
Expand Down
4 changes: 4 additions & 0 deletions aimux-provider-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod media_type;
pub mod multipart;
pub mod parse_provider_options;
pub mod post_to_api;
pub mod provider_headers;
pub mod read_response_with_size_limit;
pub mod resolvable;
pub mod resolve_provider_reference;
Expand All @@ -44,6 +45,7 @@ pub mod tool_result_to_content;
pub mod transform_stream;
pub mod uint8_utils;
pub mod url;
pub mod user_agent_fetch;
/// WebSocket client for realtime provider APIs (RFC-0028). Empty unless the
/// `ws` feature is enabled.
#[cfg(feature = "ws")]
Expand Down Expand Up @@ -81,6 +83,7 @@ pub use media_type::{
pub use multipart::{MultipartForm, media_type_to_extension};
pub use parse_provider_options::{optional_field, parse_provider_options};
pub use post_to_api::{post_form_data_to_api, post_json_to_api, post_to_api};
pub use provider_headers::{AuthScheme, Credential, ProviderHeaders};
pub use resolvable::Resolvable;
pub use resolve_provider_reference::resolve_provider_reference;
pub use response_handler::{
Expand All @@ -98,3 +101,4 @@ pub use uint8_utils::{
convert_uint8_array_to_base64,
};
pub use url::{validate_base_url, without_trailing_slash};
pub use user_agent_fetch::{UserAgentFetch, transport_user_agent};
162 changes: 162 additions & 0 deletions aimux-provider-utils/src/provider_headers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//! The provider-level request headers: where the credential comes from, how
//! it goes on the wire, and the fixed and user headers around it. Upstream
//! each package's `getHeaders` builds these with `loadApiKey`,
//! `combineHeaders` and `withUserAgentSuffix`; here the providers describe
//! them as data and resolve them per request (RFC-0046 §3.2).

use aimux_core::AiMuxError;

use crate::{
HeaderMapOpt, Resolvable, combine_headers, load_api_key, normalize_headers,
with_user_agent_suffix,
};

/// Where the credential of a provider comes from.
#[derive(Clone, Debug)]
pub enum Credential {
/// No credential: no `Authorization` header is produced and no key is
/// resolved (a local server, or `auth: none` in a preset).
None,
/// The caller's key. It is used as given, `""` included, and never falls
/// back to the environment. A [`Resolvable::Future`] is awaited once, an
/// `AsyncFn` on every request.
Explicit(Resolvable<String>),
/// Read the environment variable on every request; unset fails that
/// request with `AiMuxError::LoadApiKey`.
Env { var: String, description: String },
/// Read the environment variable on every request; unset or empty sends
/// no credential (a local runtime started with or without a key).
OptionalEnv(String),
}

impl Credential {
/// `Explicit` when the caller gave a key, otherwise `Env`.
#[must_use]
pub fn explicit_or_env(
api_key: Option<Resolvable<String>>,
var: &str,
description: &str,
) -> Self {
match api_key {
Some(key) => Self::Explicit(key),
None => Self::Env {
var: var.to_string(),
description: description.to_string(),
},
}
}

/// The secret of this request, `None` for [`Credential::None`]. A
/// package that puts the key somewhere other than a header (a query
/// parameter, a JWT claim) asks for it here, per request.
///
/// # Errors
///
/// The error of an `Explicit` key that fails to resolve, or
/// `AiMuxError::LoadApiKey` when the variable of `Env` is unset.
pub async fn secret(&self) -> Result<Option<String>, AiMuxError> {
match self {
Self::None => Ok(None),
Self::Explicit(key) => Ok(Some(key.resolve().await?)),
Self::Env { var, description } => Ok(Some(load_api_key(None, var, description)?)),
Self::OptionalEnv(var) => Ok(std::env::var(var).ok().filter(|key| !key.is_empty())),
}
}
}

/// How the credential is put on the wire.
#[derive(Clone, Copy, Debug)]
pub enum AuthScheme {
/// `Authorization: Bearer <secret>`.
Bearer,
/// `<name>: <secret>` (`x-api-key`).
Header(&'static str),
/// `Authorization: <scheme> <secret>` (`Token <key>`, `Key <key>`).
Scheme(&'static str),
}

/// The provider-level request headers, as data. They are produced on every
/// request: the credential first, then the fixed headers (organization,
/// project, version, ...), then the user's headers, which may override or
/// remove any of them (case-insensitively, `None` removes), and finally the
/// `ai-sdk-{package}/{version}` user-agent suffix when one is set.
#[derive(Clone, Debug)]
pub struct ProviderHeaders {
pub credential: Credential,
pub scheme: AuthScheme,
pub fixed: Vec<(String, String)>,
pub user: Option<HeaderMapOpt>,
/// `(package, version)` of the user-agent suffix.
pub user_agent: Option<(&'static str, &'static str)>,
}

impl ProviderHeaders {
/// Headers with `credential` put on the wire by `scheme`, no user-agent
/// suffix.
#[must_use]
pub fn new(
credential: Credential,
scheme: AuthScheme,
fixed: Vec<(String, String)>,
user: Option<HeaderMapOpt>,
) -> Self {
Self {
credential,
scheme,
fixed,
user,
user_agent: None,
}
}

/// Headers with a bearer credential, no user-agent suffix.
#[must_use]
pub fn bearer(
credential: Credential,
fixed: Vec<(String, String)>,
user: Option<HeaderMapOpt>,
) -> Self {
Self::new(credential, AuthScheme::Bearer, fixed, user)
}

/// The same headers with the `ai-sdk-{package}/{version}` user-agent
/// suffix.
#[must_use]
pub fn with_user_agent(mut self, package: &'static str, version: &'static str) -> Self {
self.user_agent = Some((package, version));
self
}

/// The headers of one request, in the order the type describes.
///
/// # Errors
///
/// The credential's error, as [`Credential::secret`] returns it.
pub async fn resolve(&self) -> Result<HeaderMapOpt, AiMuxError> {
let mut layer = HeaderMapOpt::new();
if let Some(secret) = self.credential.secret().await? {
let (name, value) = match self.scheme {
AuthScheme::Bearer => ("Authorization", format!("Bearer {secret}")),
AuthScheme::Header(name) => (name, secret),
AuthScheme::Scheme(scheme) => ("Authorization", format!("{scheme} {secret}")),
};
layer.insert(name.to_string(), Some(value));
}
for (name, value) in &self.fixed {
layer.insert(name.clone(), Some(value.clone()));
}
let headers = match &self.user {
Some(user) => combine_headers(&[&layer, user]),
None => layer,
};
let Some((package, version)) = self.user_agent else {
return Ok(headers);
};
let mut headers = normalize_headers(headers).into_iter().collect();
with_user_agent_suffix(&mut headers, &format!("ai-sdk-{package}/{version}"));
Ok(headers
.into_iter()
.map(|(name, value)| (name, Some(value)))
.collect())
}
}
1 change: 1 addition & 0 deletions aimux-provider-utils/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod create_provider_stream_error;
mod create_tool_name_mapping;
mod map_reasoning_to_provider;
mod parse_provider_options;
mod provider_headers;
mod resolve_provider_reference;
mod strip_file_extension;
mod tool_result_to_content;
Expand Down
115 changes: 115 additions & 0 deletions aimux-provider-utils/src/tests/provider_headers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! Tests of `ProviderHeaders` and `transport_user_agent`. Upstream has no
//! shared function or test file for them: each package's `getHeaders` and
//! AWS fetch build the same headers inline.

use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};

use crate::{
AuthScheme, Credential, HeaderMapOpt, ProviderHeaders, Resolvable, transport_user_agent,
};

fn key(value: &str) -> Credential {
Credential::Explicit(Resolvable::Value(value.to_owned()))
}

// Not an upstream case: the credential goes on the wire by its scheme, before
// the fixed headers.
#[tokio::test]
async fn puts_the_credential_on_the_wire_by_its_scheme() {
let fixed = vec![("OpenAI-Organization".to_owned(), "org".to_owned())];
let bearer = ProviderHeaders::bearer(key("k"), fixed, None)
.resolve()
.await
.unwrap();
assert_eq!(bearer["Authorization"].as_deref(), Some("Bearer k"));
assert_eq!(bearer["OpenAI-Organization"].as_deref(), Some("org"));

let header = ProviderHeaders::new(key("k"), AuthScheme::Header("x-api-key"), Vec::new(), None)
.resolve()
.await
.unwrap();
assert_eq!(header["x-api-key"].as_deref(), Some("k"));

let scheme = ProviderHeaders::new(key("k"), AuthScheme::Scheme("Token"), Vec::new(), None)
.resolve()
.await
.unwrap();
assert_eq!(scheme["Authorization"].as_deref(), Some("Token k"));

let none = ProviderHeaders::bearer(Credential::None, Vec::new(), None)
.resolve()
.await
.unwrap();
assert!(none.is_empty());
}

// Not an upstream case: the user's headers override or remove any of them,
// case-insensitively, and the user-agent suffix comes last.
#[tokio::test]
async fn lets_user_headers_override_and_appends_the_user_agent_suffix() {
let user = HeaderMapOpt::from([
("authorization".to_owned(), None),
("X-Custom".to_owned(), Some("1".to_owned())),
("User-Agent".to_owned(), Some("app/1".to_owned())),
]);
let headers = ProviderHeaders::bearer(key("k"), Vec::new(), Some(user))
.with_user_agent("openai", "4.0.83")
.resolve()
.await
.unwrap();

assert!(
!headers
.keys()
.any(|name| name.eq_ignore_ascii_case("authorization"))
);
assert_eq!(headers["x-custom"].as_deref(), Some("1"));
assert_eq!(
headers["user-agent"].as_deref(),
Some("app/1 ai-sdk-openai/4.0.83")
);
}

// Not an upstream case: an unset environment variable fails the request for
// `Env` and sends no credential for `OptionalEnv`.
#[tokio::test]
async fn reads_environment_credentials_per_request() {
let var = "AIMUX_PROVIDER_HEADERS_TEST_UNSET_KEY";
let env = Credential::Env {
var: var.to_owned(),
description: "Test".to_owned(),
};
assert!(env.secret().await.is_err());
assert_eq!(
Credential::OptionalEnv(var.to_owned())
.secret()
.await
.unwrap(),
None
);
}

// Not an upstream case: the last `ai-sdk-provider-utils/<v>` part is renamed
// and the suffix appended; with neither, the header is left as it is.
#[test]
fn rewrites_the_provider_utils_part_and_appends_the_suffix() {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static("app/1 ai-sdk-provider-utils/5.0.53"),
);
transport_user_agent(
&mut headers,
Some("5.0.51"),
Some("ai-sdk/amazon-bedrock/5.0.100"),
);
assert_eq!(
headers[USER_AGENT],
"app/1 ai-sdk-provider-utils/5.0.51 ai-sdk/amazon-bedrock/5.0.100"
);

let mut untouched = HeaderMap::new();
untouched.insert(USER_AGENT, HeaderValue::from_static("app/1"));
transport_user_agent(&mut untouched, Some("5.0.51"), None);
assert_eq!(untouched[USER_AGENT], "app/1");
}
Loading
Loading