use std::time::Duration;
use std::{env, fmt};
use reqwest::header::HeaderMap;
use url::Url;
use crate::error::ConfigError;
const DEFAULT_CHAT_BASE_URL: &str = "https://api.reka.ai/v1/";
const DEFAULT_VISION_BASE_URL: &str = "https://vision-agent.api.reka.ai/";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
const ENV_API_KEY: &str = "REKA_API_KEY";
const ENV_CHAT_BASE_URL: &str = "REKA_BASE_URL";
const ENV_VISION_BASE_URL: &str = "REKA_VISION_BASE_URL";
const ENV_TIMEOUT_SECS: &str = "REKA_TIMEOUT_SECS";
const ENV_CONNECT_TIMEOUT_SECS: &str = "REKA_CONNECT_TIMEOUT_SECS";
#[derive(Clone)]
pub struct ClientConfig {
pub(crate) api_key: String,
pub(crate) chat_base_url: Url,
pub(crate) vision_base_url: Url,
pub(crate) timeout: Duration,
pub(crate) connect_timeout: Duration,
pub(crate) user_agent: String,
pub(crate) default_headers: HeaderMap,
}
impl fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientConfig")
.field("api_key", &"<redacted>")
.field("chat_base_url", &self.chat_base_url)
.field("vision_base_url", &self.vision_base_url)
.field("timeout", &self.timeout)
.field("connect_timeout", &self.connect_timeout)
.field("user_agent", &self.user_agent)
.field("default_headers", &self.default_headers)
.finish()
}
}
impl ClientConfig {
pub fn builder(api_key: impl Into<String>) -> ClientConfigBuilder {
ClientConfigBuilder::new(api_key)
}
pub fn from_env() -> Result<Self, ConfigError> {
let api_key = match env::var(ENV_API_KEY) {
Ok(value) => value,
Err(env::VarError::NotPresent) => return Err(ConfigError::MissingApiKey),
Err(source) => {
return Err(ConfigError::InvalidEnvVar {
name: ENV_API_KEY,
source,
});
}
};
let mut builder = Self::builder(api_key);
if let Some(chat_base_url) = optional_env(ENV_CHAT_BASE_URL)? {
builder = builder.chat_base_url(chat_base_url);
}
if let Some(vision_base_url) = optional_env(ENV_VISION_BASE_URL)? {
builder = builder.vision_base_url(vision_base_url);
}
if let Some(timeout_secs) = optional_env(ENV_TIMEOUT_SECS)? {
let timeout_secs = parse_u64_env(ENV_TIMEOUT_SECS, timeout_secs)?;
builder = builder.timeout(Duration::from_secs(timeout_secs));
}
if let Some(connect_timeout_secs) = optional_env(ENV_CONNECT_TIMEOUT_SECS)? {
let connect_timeout_secs =
parse_u64_env(ENV_CONNECT_TIMEOUT_SECS, connect_timeout_secs)?;
builder = builder.connect_timeout(Duration::from_secs(connect_timeout_secs));
}
builder.build()
}
pub fn chat_base_url(&self) -> &Url {
&self.chat_base_url
}
pub fn vision_base_url(&self) -> &Url {
&self.vision_base_url
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn connect_timeout(&self) -> Duration {
self.connect_timeout
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
pub fn default_headers(&self) -> &HeaderMap {
&self.default_headers
}
pub(crate) fn endpoint_url(&self, service: ServiceBase, path: &str) -> Url {
let mut base = match service {
ServiceBase::Chat => self.chat_base_url.clone(),
ServiceBase::Vision => self.vision_base_url.clone(),
};
let trimmed = path.trim_start_matches('/');
base.set_path(&format!("{}{}", base.path(), trimmed));
base
}
}
#[derive(Debug, Clone)]
pub struct ClientConfigBuilder {
api_key: String,
chat_base_url: String,
vision_base_url: String,
timeout: Duration,
connect_timeout: Duration,
user_agent: String,
default_headers: HeaderMap,
}
impl ClientConfigBuilder {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
chat_base_url: DEFAULT_CHAT_BASE_URL.to_string(),
vision_base_url: DEFAULT_VISION_BASE_URL.to_string(),
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
connect_timeout: Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS),
user_agent: format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
default_headers: HeaderMap::new(),
}
}
pub fn chat_base_url(mut self, url: impl Into<String>) -> Self {
self.chat_base_url = url.into();
self
}
pub fn vision_base_url(mut self, url: impl Into<String>) -> Self {
self.vision_base_url = url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
self.connect_timeout = connect_timeout;
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn default_headers(mut self, default_headers: HeaderMap) -> Self {
self.default_headers = default_headers;
self
}
pub fn build(self) -> Result<ClientConfig, ConfigError> {
if self.api_key.trim().is_empty() {
return Err(ConfigError::EmptyApiKey);
}
Ok(ClientConfig {
api_key: self.api_key,
chat_base_url: parse_base_url("chat_base_url", &self.chat_base_url)?,
vision_base_url: parse_base_url("vision_base_url", &self.vision_base_url)?,
timeout: self.timeout,
connect_timeout: self.connect_timeout,
user_agent: self.user_agent,
default_headers: self.default_headers,
})
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub(crate) enum ServiceBase {
Chat,
Vision,
}
fn optional_env(name: &'static str) -> Result<Option<String>, ConfigError> {
match env::var(name) {
Ok(value) => Ok(Some(value)),
Err(env::VarError::NotPresent) => Ok(None),
Err(source) => Err(ConfigError::InvalidEnvVar { name, source }),
}
}
fn parse_u64_env(name: &'static str, value: String) -> Result<u64, ConfigError> {
value.parse().map_err(|source| ConfigError::InvalidNumber {
name,
value,
source,
})
}
fn parse_base_url(field: &'static str, value: &str) -> Result<Url, ConfigError> {
let mut normalized = value.trim().to_string();
if !normalized.ends_with('/') {
normalized.push('/');
}
Url::parse(&normalized).map_err(|source| ConfigError::InvalidBaseUrl {
field,
value: value.to_string(),
source,
})
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{ClientConfig, ServiceBase};
#[test]
fn builder_normalizes_urls() {
let config = ClientConfig::builder("test-key")
.chat_base_url("https://api.reka.ai/v1")
.vision_base_url("https://vision-agent.api.reka.ai")
.timeout(Duration::from_secs(5))
.build()
.expect("config should build");
assert_eq!(
config.endpoint_url(ServiceBase::Chat, "/models").as_str(),
"https://api.reka.ai/v1/models"
);
assert_eq!(
config
.endpoint_url(ServiceBase::Vision, "/v1/videos")
.as_str(),
"https://vision-agent.api.reka.ai/v1/videos"
);
}
}