ai-lib 0.4.0

A unified AI SDK for Rust providing a single interface for multiple AI providers with hybrid architecture
Documentation
use std::borrow::Cow;

use crate::{client::Provider, transport::TransportError, types::AiLibError};

use super::catalog;

#[derive(Debug, Clone)]
pub struct ModelResolver;

#[derive(Debug, Clone)]
pub struct ModelResolution {
    pub model: String,
    pub source: ModelResolutionSource,
    pub doc_url: &'static str,
}

impl ModelResolution {
    pub fn new(
        model: impl Into<String>,
        source: ModelResolutionSource,
        doc_url: &'static str,
    ) -> Self {
        Self {
            model: model.into(),
            source,
            doc_url,
        }
    }
}

#[derive(Debug, Clone)]
pub enum ModelResolutionSource {
    Explicit,
    CustomDefault,
    EnvOverride,
    ProviderDefault,
    ProfileFallback,
}

impl ModelResolver {
    pub fn new() -> Self {
        Self
    }

    pub fn resolve_chat_model(
        &self,
        provider: Provider,
        requested: Option<&str>,
    ) -> ModelResolution {
        let profile = catalog::profile(provider);
        let doc_url = profile.doc_url;
        if let Some(model) = requested {
            return ModelResolution::new(
                model.to_string(),
                ModelResolutionSource::Explicit,
                doc_url,
            );
        }

        if let Some(env_model) = self.env_override(provider) {
            return ModelResolution::new(env_model, ModelResolutionSource::EnvOverride, doc_url);
        }

        ModelResolution::new(
            profile.default_chat_model(),
            ModelResolutionSource::ProviderDefault,
            doc_url,
        )
    }

    pub fn fallback_after_invalid(
        &self,
        provider: Provider,
        failed_model: &str,
    ) -> Option<ModelResolution> {
        let profile = catalog::profile(provider);
        let doc_url = profile.doc_url;

        if let Some(env_model) = self.env_override(provider) {
            if !equals_ignore_case(&env_model, failed_model) {
                return Some(ModelResolution::new(
                    env_model,
                    ModelResolutionSource::EnvOverride,
                    doc_url,
                ));
            }
        }

        for candidate in profile.fallback_models {
            if !equals_ignore_case(candidate, failed_model) {
                return Some(ModelResolution::new(
                    *candidate,
                    ModelResolutionSource::ProfileFallback,
                    doc_url,
                ));
            }
        }

        if !equals_ignore_case(profile.default_chat_model(), failed_model) {
            return Some(ModelResolution::new(
                profile.default_chat_model(),
                ModelResolutionSource::ProviderDefault,
                doc_url,
            ));
        }

        None
    }

    pub fn doc_url(&self, provider: Provider) -> &'static str {
        catalog::profile(provider).doc_url
    }

    pub fn suggestions(&self, provider: Provider) -> Vec<String> {
        let mut list = Vec::new();

        if let Some(env_override) = self.env_override(provider) {
            push_unique(&mut list, env_override);
        }

        let profile = catalog::profile(provider);
        push_unique(&mut list, profile.default_chat_model().to_string());
        for candidate in profile.fallback_models {
            push_unique(&mut list, (*candidate).to_string());
        }

        list
    }

    pub fn looks_like_invalid_model(&self, err: &AiLibError) -> bool {
        match err {
            AiLibError::ModelNotFound(_) => true,
            AiLibError::InvalidRequest(msg)
            | AiLibError::ProviderError(msg)
            | AiLibError::InvalidModelResponse(msg) => contains_invalid_keyword(msg),
            AiLibError::TransportError(TransportError::ClientError { status, message })
            | AiLibError::TransportError(TransportError::ServerError { status, message }) => {
                (*status == 400 || *status == 404) && contains_invalid_keyword(message)
            }
            _ => false,
        }
    }

    pub fn decorate_invalid_model_error(
        &self,
        provider: Provider,
        requested_model: &str,
        err: AiLibError,
    ) -> AiLibError {
        let doc_url = self.doc_url(provider);
        let suggestions = self.suggestions(provider);
        let provider_name = format!("{provider:?}");
        let suggestion_text = if suggestions.is_empty() {
            Cow::Borrowed("no known fallback models configured")
        } else {
            Cow::Owned(suggestions.join(", "))
        };

        AiLibError::ModelNotFound(format!(
            "Model `{}` is not available for provider {}. Try: {}. Docs: {}. Original error: {}",
            requested_model, provider_name, suggestion_text, doc_url, err
        ))
    }

    fn env_override(&self, provider: Provider) -> Option<String> {
        let var = format!("{}_MODEL", provider.env_prefix());
        std::env::var(var)
            .ok()
            .filter(|value| !value.trim().is_empty())
    }
}

fn push_unique(list: &mut Vec<String>, value: String) {
    if !list
        .iter()
        .any(|existing| equals_ignore_case(existing, &value))
    {
        list.push(value);
    }
}

fn equals_ignore_case(a: &str, b: &str) -> bool {
    a.eq_ignore_ascii_case(b)
}

fn contains_invalid_keyword(message: &str) -> bool {
    let lower = message.to_ascii_lowercase();
    lower.contains("invalid model")
        || lower.contains("model_not_found")
        || lower.contains("model not found")
        || lower.contains("unknown model")
        || lower.contains("unsupported model")
        || lower.contains("\"code\":\"1500\"")
}