use std::{ffi::OsString, fmt, time::Duration};
use bytes::Bytes;
use http::{HeaderMap, HeaderValue, Method, Uri, uri::PathAndQuery};
use secrecy::{ExposeSecret, SecretString};
use crate::{
constants::{
API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_MODEL,
DEFAULT_MODEL_ENV, DEFAULT_TIMEOUT, MODELS_PATH, SDK_IDENTIFIER, SYSTEM_ONE_PATH,
},
error::{Error, format_endpoint},
};
#[derive(Default)]
pub(crate) struct Explicit {
pub(crate) api_key: Option<SecretString>,
pub(crate) base_url: Option<String>,
pub(crate) default_model: Option<String>,
pub(crate) timeout: Option<Option<Duration>>,
pub(crate) default_headers: HeaderMap,
pub(crate) max_response_bytes: Option<usize>,
pub(crate) user_agent_product: Option<String>,
pub(crate) omit_runtime_header: bool,
}
pub(crate) struct Config {
authorization: HeaderValue,
endpoints: Endpoints,
default_model: Box<str>,
timeout: Option<Duration>,
default_headers: HeaderMap,
max_response_bytes: usize,
user_agent: HeaderValue,
send_runtime_header: bool,
}
impl Config {
pub(crate) fn resolve<V>(
explicit: Explicit,
env: impl Fn(&str) -> Option<V>,
) -> Result<Self, Error>
where
V: Into<OsString>,
{
let Explicit {
api_key,
base_url,
default_model,
timeout,
default_headers,
max_response_bytes,
user_agent_product,
omit_runtime_header,
} = explicit;
let api_key = match api_key {
Some(key) => key,
None => SecretString::from(from_env(&env, API_KEY_ENV)?.unwrap_or_default()),
};
let authorization = bearer(validate_api_key(api_key.expose_secret())?);
let base_url = match base_url {
Some(url) => Some(url),
None => from_env(&env, BASE_URL_ENV)?,
};
let mut base_url = base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
base_url.truncate(base_url.trim_end_matches('/').len());
let endpoints = endpoints(&base_url)?;
let default_model = match default_model {
Some(model) if is_blank(&model) => {
return Err(Error::config(format!(
"The default model is empty. \
Pass a non-empty default_model or set the {DEFAULT_MODEL_ENV} \
environment variable."
)));
}
Some(model) => model,
None => from_env(&env, DEFAULT_MODEL_ENV)?.unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
};
let timeout = timeout.unwrap_or(Some(DEFAULT_TIMEOUT));
if timeout.is_some_and(|timeout| timeout.is_zero()) {
return Err(Error::config(ZERO_TIMEOUT));
}
let max_response_bytes = max_response_bytes.unwrap_or(DEFAULT_MAX_RESPONSE_BYTES);
if max_response_bytes == 0 {
return Err(Error::config(
"max_response_bytes must be at least 1: every response carries a body.",
));
}
let user_agent = user_agent(user_agent_product.as_deref())?;
Ok(Self {
authorization,
endpoints,
default_model: default_model.into_boxed_str(),
timeout,
default_headers,
max_response_bytes,
user_agent,
send_runtime_header: !omit_runtime_header,
})
}
pub(crate) fn authorization(&self) -> &HeaderValue {
&self.authorization
}
pub(crate) fn endpoints(&self) -> &Endpoints {
&self.endpoints
}
pub(crate) fn default_model(&self) -> &str {
&self.default_model
}
pub(crate) fn timeout(&self) -> Option<Duration> {
self.timeout
}
pub(crate) fn max_response_bytes(&self) -> usize {
self.max_response_bytes
}
pub(crate) fn default_headers(&self) -> &HeaderMap {
&self.default_headers
}
pub(crate) fn user_agent(&self) -> &HeaderValue {
&self.user_agent
}
pub(crate) fn send_runtime_header(&self) -> bool {
self.send_runtime_header
}
}
impl fmt::Debug for Config {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut shown = formatter.debug_struct("Config");
shown
.field("endpoints", &self.endpoints)
.field("default_model", &self.default_model)
.field("timeout", &self.timeout)
.field("max_response_bytes", &self.max_response_bytes)
.field("authorization", &Hidden)
.field("default_headers", &HeaderNames(&self.default_headers));
if self.user_agent != SDK_IDENTIFIER {
shown.field("user_agent", &self.user_agent);
}
if !self.send_runtime_header {
shown.field("send_runtime_header", &false);
}
shown.finish()
}
}
struct Hidden;
impl fmt::Debug for Hidden {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("<redacted>")
}
}
struct HeaderNames<'a>(&'a HeaderMap);
impl fmt::Debug for HeaderNames<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_list().entries(self.0.keys()).finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Endpoints {
system_one: Uri,
models: Uri,
}
impl fmt::Debug for Endpoints {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_list()
.entry(&format_endpoint(&Method::POST, &self.system_one))
.entry(&format_endpoint(&Method::GET, &self.models))
.finish()
}
}
impl Endpoints {
pub(crate) fn system_one(&self) -> &Uri {
&self.system_one
}
pub(crate) fn models(&self) -> &Uri {
&self.models
}
}
pub(crate) fn endpoints(base_url: &str) -> Result<Endpoints, Error> {
if base_url.contains('#') {
return Err(Error::config("The base URL must not carry a fragment ('#...')."));
}
if base_url.contains('?') {
return Err(Error::config("The base URL must not carry a query ('?...')."));
}
let base: Uri =
base_url.parse().map_err(|_| Error::config("The base URL is not a valid URL."))?;
let (Some(scheme), Some(authority)) = (base.scheme_str(), base.authority()) else {
return Err(Error::config(
"The base URL must be absolute, with a scheme and a host, \
such as https://api.typesafe.ai.",
));
};
if !matches!(scheme, "http" | "https") {
return Err(Error::config("The base URL must use http or https."));
}
if authority.as_str().contains('@') {
return Err(Error::config(
"The base URL must not carry credentials; pass the API key on its own instead.",
));
}
if authority.host().is_empty() {
return Err(Error::config("The base URL has an empty host."));
}
let prefix = base.path().trim_end_matches('/');
let join = |path: &str| {
let mut parts = base.clone().into_parts();
parts.path_and_query = Some(
PathAndQuery::from_maybe_shared(Bytes::from(format!("{prefix}{path}")))
.expect("invariant: a parsed path followed by a fixed API path is a valid path"),
);
Uri::from_parts(parts).expect(
"invariant: the scheme and authority were checked present, and the path is valid",
)
};
Ok(Endpoints { system_one: join(SYSTEM_ONE_PATH), models: join(MODELS_PATH) })
}
fn bearer(key: &str) -> HeaderValue {
const SCHEME: &[u8] = b"Bearer ";
let key = key.as_bytes();
debug_assert!(key.iter().all(|byte| (b'!'..=b'~').contains(byte)), "unvalidated API key");
let mut value = Vec::with_capacity(SCHEME.len() + key.len());
value.extend_from_slice(SCHEME);
value.extend_from_slice(key);
let mut value = HeaderValue::from_maybe_shared(Bytes::from(value))
.expect("invariant: validate_api_key admits only printable ASCII without whitespace");
value.set_sensitive(true);
value
}
pub(crate) const MAX_USER_AGENT_PRODUCT_BYTES: usize = 64;
pub(crate) fn user_agent(product: Option<&str>) -> Result<HeaderValue, Error> {
let Some(product) = product else {
return Ok(SDK_IDENTIFIER);
};
if let Err(rule) = check_product(product) {
return Err(Error::config(format!(
"The user_agent_product must be a product token, name/version \
(RFC 9110, section 10.1.5): {rule}."
)));
}
let sdk = SDK_IDENTIFIER;
let mut value = Vec::with_capacity(product.len() + 1 + sdk.len());
value.extend_from_slice(product.as_bytes());
value.push(b' ');
value.extend_from_slice(sdk.as_bytes());
Ok(HeaderValue::from_maybe_shared(Bytes::from(value))
.expect("invariant: a checked token, a space and the SDK identifier are visible ASCII"))
}
fn check_product(product: &str) -> Result<(), &'static str> {
let bytes = product.as_bytes();
if bytes.is_empty() {
return Err("it is empty");
}
if bytes.len() > MAX_USER_AGENT_PRODUCT_BYTES {
return Err("it is longer than 64 bytes");
}
if !product.is_ascii() {
return Err("it contains a character that is not ASCII");
}
if bytes.iter().any(u8::is_ascii_whitespace) {
return Err("it contains whitespace");
}
if bytes.iter().any(u8::is_ascii_control) {
return Err("it contains a control character");
}
let Some((name, version)) = product.split_once('/') else {
return Err("it has no '/' between the name and the version");
};
if version.contains('/') {
return Err("it has more than one '/'");
}
if name.is_empty() {
return Err("the name before the '/' is empty");
}
if version.is_empty() {
return Err("the version after the '/' is empty");
}
if !name.bytes().chain(version.bytes()).all(is_tchar) {
return Err("it contains a character a token cannot hold (RFC 9110, section 5.6.2)");
}
Ok(())
}
fn is_tchar(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)
}
pub(crate) const ZERO_TIMEOUT: &str = "timeout must be a positive, finite number of seconds.";
pub(crate) fn is_python_space(c: char) -> bool {
c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
}
fn is_blank(text: &str) -> bool {
text.chars().all(is_python_space)
}
fn validate_api_key(key: &str) -> Result<&str, Error> {
let key = key.trim_matches(is_python_space);
if key.is_empty() {
return Err(Error::config(format!(
"No API key was provided. \
Pass api_key or set the {API_KEY_ENV} environment variable."
)));
}
if !key.bytes().all(|byte| (b'!'..=b'~').contains(&byte)) {
return Err(Error::config(
"API key must contain only printable ASCII characters without whitespace.",
));
}
Ok(key)
}
fn from_env<V>(env: &impl Fn(&str) -> Option<V>, name: &str) -> Result<Option<String>, Error>
where
V: Into<OsString>,
{
let Some(value) = env(name) else {
return Ok(None);
};
let mut value = value.into().into_string().map_err(|_| {
Error::config(format!("The {name} environment variable is not valid UTF-8."))
})?;
let end = value.trim_end_matches(is_python_space).len();
value.truncate(end);
let start = value.len() - value.trim_start_matches(is_python_space).len();
value.drain(..start);
Ok(if value.is_empty() { None } else { Some(value) })
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;