reka 0.1.0

Async Rust SDK for the Reka API.
Documentation
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";

/// Configuration used to build a [`crate::Client`].
///
/// Most applications can use [`ClientConfig::from_env`] indirectly through
/// [`crate::Client::from_env`]. Use [`ClientConfig::builder`] when you need to
/// override base URLs, timeouts, headers, or the user agent.
#[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 {
    /// Starts building a configuration from an API key.
    ///
    /// ```rust
    /// use std::time::Duration;
    ///
    /// use reka::ClientConfig;
    ///
    /// let config = ClientConfig::builder("test-api-key")
    ///     .timeout(Duration::from_secs(10))
    ///     .connect_timeout(Duration::from_secs(3))
    ///     .build()?;
    ///
    /// assert_eq!(config.timeout(), Duration::from_secs(10));
    /// # Ok::<(), reka::ConfigError>(())
    /// ```
    pub fn builder(api_key: impl Into<String>) -> ClientConfigBuilder {
        ClientConfigBuilder::new(api_key)
    }

    /// Builds a configuration from supported environment variables.
    ///
    /// Recognized variables:
    /// - `REKA_API_KEY`
    /// - `REKA_BASE_URL`
    /// - `REKA_VISION_BASE_URL`
    /// - `REKA_TIMEOUT_SECS`
    /// - `REKA_CONNECT_TIMEOUT_SECS`
    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
    }
}

/// Builder for [`ClientConfig`].
#[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 {
    /// Creates a builder with default base URLs and timeouts.
    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(),
        }
    }

    /// Overrides the chat API base URL.
    pub fn chat_base_url(mut self, url: impl Into<String>) -> Self {
        self.chat_base_url = url.into();
        self
    }

    /// Overrides the vision API base URL.
    pub fn vision_base_url(mut self, url: impl Into<String>) -> Self {
        self.vision_base_url = url.into();
        self
    }

    /// Overrides the request timeout.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Overrides the connection timeout.
    pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
        self.connect_timeout = connect_timeout;
        self
    }

    /// Overrides the `User-Agent` header.
    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
        self.user_agent = user_agent.into();
        self
    }

    /// Sets headers that will be sent on every request.
    pub fn default_headers(mut self, default_headers: HeaderMap) -> Self {
        self.default_headers = default_headers;
        self
    }

    /// Validates and builds the final configuration.
    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"
        );
    }
}